订单售后费用明细当前页导出

在订单售后费用明细筛选区增加当前页 CSV 导出,并覆盖空数据和 CSV 安全处理。
This commit is contained in:
renshen 2026-08-17 16:48:24 +08:00
parent 1993996a96
commit 2aec157a19
2 changed files with 106 additions and 5 deletions

View File

@ -167,6 +167,20 @@
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button
type="button"
id="order-data-after-sales-export-button"
data-export-contract="order-data.after-sales.current-page-export"
data-export-table="order-data-after-sales-table"
data-export-filename="订单售后费用明细_{{ $filters['from'] ?? '开始' }}_{{ $filters['to'] ?? '结束' }}_第{{ $rows?->currentPage() ?? 1 }}页.csv"
@class(['upstream-data-button', 'is-disabled' => !$rows || $rows->isEmpty()])
@if (!$rows || $rows->isEmpty()) disabled="disabled" aria-disabled="true" @endif
aria-label="导出当前页"
title="导出当前页"
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出当前页</span>
</button>
</div>
</form>
@ -179,7 +193,7 @@
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table order-data-table order-data-table--wide" aria-label="订单售后费用明细">
<table id="order-data-after-sales-table" class="upstream-data-table order-data-table order-data-table--wide" aria-label="订单售后费用明细">
<thead>
<tr>
@foreach ($columns as $field => $header)
@ -192,7 +206,11 @@
<tr>
@foreach ($columns as $field => $header)
@php $value = data_get($row, $field); @endphp
<td @class(['is-number' => isset($numberFields[$field])])>
<td
data-export-value="{{ $value ?? '' }}"
data-export-number="{{ isset($numberFields[$field]) ? 'true' : 'false' }}"
@class(['is-number' => isset($numberFields[$field])])
>
@if ($field === 'product_link' && $value)
<a class="order-data-link" href="{{ $value }}" target="_blank" rel="noopener" title="打开商品链接" aria-label="打开商品链接">
<span class="material-icons-outlined" aria-hidden="true">open_in_new</span>
@ -236,3 +254,51 @@
</div>
</div>
</div>
<script>
(() => {
const button = document.getElementById('order-data-after-sales-export-button');
if (!button || button.dataset.exportBound === 'true') {
return;
}
button.dataset.exportBound = 'true';
button.addEventListener('click', () => {
const table = document.getElementById(button.dataset.exportTable || '');
const bodyRows = table ? Array.from(table.querySelectorAll('tbody tr:not(.is-empty)')) : [];
if (!table || bodyRows.length === 0) {
return;
}
const protectSpreadsheetFormula = (value) => /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
const csvCell = (value, isNumber = false) => {
const raw = String(value ?? '').replace(/\r\n?/g, '\n');
const normalized = isNumber ? raw : protectSpreadsheetFormula(raw);
return `"${normalized.replace(/"/g, '""')}"`;
};
const header = Array.from(table.querySelectorAll('thead th'))
.map((cell) => csvCell(cell.textContent.trim()));
const rows = bodyRows.map((row) => Array.from(row.querySelectorAll('td'))
.map((cell) => csvCell(
cell.dataset.exportValue ?? cell.textContent.trim(),
cell.dataset.exportNumber === 'true',
)));
const csv = [header, ...rows].map((row) => row.join(',')).join('\r\n');
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = button.dataset.exportFilename || '订单售后费用明细.csv';
document.body.appendChild(link);
link.click();
window.setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 1000);
});
})();
</script>

View File

@ -46,19 +46,31 @@ class OrderDataPagesUpstreamTest extends FeatureTestCase
$factory->shouldReceive('forSource')->twice()->with('operations_replica')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs()
$afterSalesResponse = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'after-sales-expenses',
'order_no' => 'ORDER-1',
'from' => '2026-08-01',
'to' => '2026-08-09',
]))
]));
$afterSalesResponse
->assertOk()
->assertSeeText('ORDER-1')
->assertSeeText('测试店铺')
->assertSeeText('123.45')
->assertSeeText('DIRECT');
->assertSeeText('DIRECT')
->assertSeeText('导出当前页')
->assertSee('id="order-data-after-sales-export-button"', false)
->assertSee('data-export-contract="order-data.after-sales.current-page-export"', false)
->assertSee('data-export-table="order-data-after-sales-table"', false)
->assertSee('data-export-value="ORDER-1"', false)
->assertSee('data-export-value="123.45"', false)
->assertSee('data-export-number="true"', false)
->assertSee('window.setTimeout(() => {', false)
->assertSee('URL.revokeObjectURL(url);', false)
->assertSee('订单售后费用明细_2026-08-01_2026-08-09_第1页.csv', false);
$this->get(route('business.pages.show', [
'section' => 'order-data',
@ -92,6 +104,29 @@ class OrderDataPagesUpstreamTest extends FeatureTestCase
->assertSeeText('暂无数据');
}
public function test_after_sales_export_is_disabled_when_the_current_page_has_no_rows(): void
{
$this->allowOrderDataPages();
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')->once()->andReturn(['rows' => []]);
$cache->shouldReceive('responseHeaders')->once()->andReturn([
'X-Caiwu-Data-Status' => 'direct',
]);
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'after-sales-expenses',
]))
->assertOk()
->assertSeeText('导出当前页')
->assertSee('id="order-data-after-sales-export-button"', false)
->assertSee('disabled="disabled"', false);
}
private function allowOrderDataPages(): void
{
$this->user->update(['locale' => 'zh-CN']);