From 30f69f2af9243502b9c540f513cf7692249c5525 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Thu, 8 Oct 2026 14:49:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BA=97=E9=93=BA=E6=94=B6=E6=AC=BE=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E8=B4=A6=E5=8D=95=EF=BC=9A=E5=AF=BC=E5=87=BA=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=20+=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=BF=9B=E5=BA=A6=E5=88=97=E8=A1=A8=EF=BC=88=E7=8A=B6?= =?UTF-8?q?=E6=80=81/=E4=B8=8B=E8=BD=BD=EF=BC=89=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BE=9D=E8=B5=96=E9=A1=B5=E9=9D=A2=E5=81=9C=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/RunAccountBillsExportTask.php | 147 +++++++++++++++ .../Http/Controllers/AccountBills.php | 98 ++++++++++ .../Http/Controllers/AlipayBills.php | 2 +- .../Services/AccountBillsExportTasks.php | 177 ++++++++++++++++++ .../js/components/business/AccountBills.vue | 128 ++++++++----- .../components/business/TaobaoAlipayBills.vue | 131 ++++++++----- routes/admin.php | 8 + 7 files changed, 603 insertions(+), 88 deletions(-) create mode 100644 app/Console/Commands/RunAccountBillsExportTask.php create mode 100644 modules/EcommerceFinance/Services/AccountBillsExportTasks.php diff --git a/app/Console/Commands/RunAccountBillsExportTask.php b/app/Console/Commands/RunAccountBillsExportTask.php new file mode 100644 index 00000000..90214163 --- /dev/null +++ b/app/Console/Commands/RunAccountBillsExportTask.php @@ -0,0 +1,147 @@ +argument('token'); + $task = $this->findTask($tasks, $token); + if ($task === null) { + $this->error('导出任务不存在:' . $token); + + return self::FAILURE; + } + + $task = $tasks->patch($task, ['status' => 'running', 'started_at' => now()->toDateTimeString()]); + + try { + $columns = array_values((array) ($task['columns'] ?? [])); + if ($columns === []) { + throw new \RuntimeException('导出任务没有可用字段。'); + } + + $csv = fopen($tasks->csvPath($task), 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建导出文件。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_map(static fn (array $column): string => (string) ($column['label'] ?? ''), $columns)); + + $written = 0; + foreach ($this->rows($task, $remarkMap) as $row) { + fputcsv($csv, array_map( + static fn (array $column): string => self::cellValue($row, $column), + $columns, + )); + $written++; + if ($written % 2000 === 0) { + $task = $tasks->patch($task, ['status' => 'running', 'rows' => $written]); + } + } + fclose($csv); + + $task = $tasks->patch($task, [ + 'status' => 'done', + 'rows' => $written, + 'size' => (int) @filesize($tasks->csvPath($task)), + 'finished_at' => now()->toDateTimeString(), + 'error' => null, + ]); + $this->info('导出完成:' . $written . ' 行'); + + return self::SUCCESS; + } catch (Throwable $exception) { + $tasks->patch($task, [ + 'status' => 'failed', + 'finished_at' => now()->toDateTimeString(), + 'error' => mb_substr($exception->getMessage(), 0, 200), + ]); + $this->error('导出失败:' . $exception->getMessage()); + + return self::FAILURE; + } + } + + private function findTask(AccountBillsExportTasks $tasks, string $token): ?array + { + $root = storage_path('app/account-bills-exports'); + foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) { + $task = json_decode((string) file_get_contents($path), true); + if (is_array($task) && ($task['token'] ?? null) === $token) { + return $task; + } + } + + return null; + } + + /** @return iterable> */ + private function rows(array $task, AccountLedgerRemarkMap $remarkMap): iterable + { + $tab = (string) ($task['tab'] ?? ''); + $filters = (array) ($task['filters'] ?? []); + $sort = (string) ($filters['sort'] ?? 'occurred_at'); + $dir = strtolower((string) ($filters['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc'; + unset($filters['sort'], $filters['dir'], $filters['export'], $filters['token'], $filters['total'], $filters['columns']); + + $rows = match ($tab) { + 'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class)->exportRows($filters, $sort, $dir), + 'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir), + 'pinduoduo' => app(PinduoduoSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir), + 'dewu' => app(DewuSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir), + 'jd-transactions', 'jingxi' => JdBillPlatform::isJdBillTab($tab) + ? app(JdDailyBillRepository::class)->forPlatform(JdBillPlatform::platformForBillType($tab))->exportRows($filters, $sort, $dir) + : [], + 'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class)->exportRows($filters, $sort, $dir), + 'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)->exportRows($filters, $sort, $dir), + default => throw new \RuntimeException('该页签暂不支持后台导出:' . $tab), + }; + + return $remarkMap->decorateAll($rows, $tab); + } + + /** @param array $row */ + private static function cellValue(array $row, array $column): string + { + $field = (string) ($column['field'] ?? ''); + $value = str_starts_with($field, 'payload.') + ? data_get($row['payload'] ?? [], substr($field, 8)) + : data_get($row, $field); + + $text = trim((string) ($value ?? '')); + if ($text === '') { + return ''; + } + if (preg_match('/^\d{12,}$/', $text) === 1) { + return '="' . $text . '"'; + } + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } +} diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index 4d5057e1..04c17ec7 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -364,6 +364,104 @@ class AccountBills extends Controller ]); } + /** 新建后台导出任务:立刻返回,导出在后台进程里跑,页面到「导出进度」列表里看状态并下载。 */ + public function createExportTask(Request $request) + { + $this->authorizeStoreReceivingBills(); + + $tab = trim((string) $request->input('tab', '')); + $columns = $this->exportColumnsFor($tab); + abort_if($columns === [], 422, '该页签暂不支持后台导出。'); + + $requested = array_filter(array_map( + static fn (mixed $field): string => trim((string) $field), + (array) $request->input('columns', []), + )); + $selected = []; + foreach ($requested as $field) { + if (isset($columns[$field])) { + $selected[] = ['field' => $field, 'label' => $columns[$field]]; + } + } + if ($selected === []) { + foreach ($columns as $field => $label) { + $selected[] = ['field' => (string) $field, 'label' => (string) $label]; + } + } + + $filters = $request->except(['columns', 'tab', '_token']); + $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); + $task = $tasks->create( + (int) user()->id, + $tab, + $this->mirrorLabel($tab), + $filters, + $selected, + max(0, (int) $request->input('total', 0)), + ); + $tasks->dispatch($task); + + return response()->json(['status' => 'ok', 'token' => $task['token']], 200, [ + 'Cache-Control' => 'private, no-store', + ]); + } + + /** 导出任务列表(当前用户,最近 50 条)。 */ + public function exportTasks() + { + $this->authorizeStoreReceivingBills(); + + $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); + $userId = (int) user()->id; + + return response()->json([ + 'status' => 'ok', + 'data' => array_map(static fn (array $task): array => $tasks->present($task), $tasks->all($userId)), + ], 200, ['Cache-Control' => 'private, no-store']); + } + + /** 下载已完成的导出文件。 */ + public function downloadExportTask(Request $request, string $token) + { + $this->authorizeStoreReceivingBills(); + + $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); + $task = $tasks->find((int) user()->id, $token); + abort_if($task === null, 404, '导出任务不存在或已过期。'); + abort_unless(($task['status'] ?? '') === 'done', 409, '导出尚未完成,请稍后刷新。'); + + $path = $tasks->csvPath($task); + abort_unless(is_file($path), 404, '导出文件已过期,请重新导出。'); + + $name = str_replace(['/', '\\'], '-', (string) ($task['tab_label'] ?? 'account-bills')) + . '_' . substr($token, 0, 8) . '.csv'; + + return response()->download($path, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * 该页签可导出的字段:field => label。 + * + * @return array + */ + private function exportColumnsFor(string $tab): array + { + if ($tab === 'taobao-alipay') { + return AlipayBills::EXPORT_COLUMNS; + } + + $definition = AccountBillColumns::for($tab); + $columns = []; + foreach ((array) ($definition['columns'] ?? []) as $column) { + $field = (string) ($column['field'] ?? ''); + if ($field !== '') { + $columns[$field] = (string) ($column['label'] ?? $field); + } + } + + return $columns; + } + private function exportMirror(Request $request, string $billType) { $exportToken = $this->exportProgressToken($request); diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index f3e9b38e..0a7fc571 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -13,7 +13,7 @@ use RuntimeException; class AlipayBills extends Controller { use DecoratesLedgerRemarks; - private const EXPORT_COLUMNS = [ + public const EXPORT_COLUMNS = [ 'record_code' => '记录编码', 'store_name' => '原始店铺', 'ledger_remark' => '总账备注', diff --git a/modules/EcommerceFinance/Services/AccountBillsExportTasks.php b/modules/EcommerceFinance/Services/AccountBillsExportTasks.php new file mode 100644 index 00000000..4e71b1d1 --- /dev/null +++ b/modules/EcommerceFinance/Services/AccountBillsExportTasks.php @@ -0,0 +1,177 @@ +baseDir((int) $task['user_id']) . '/' . $task['token'] . '.json'; + } + + public function csvPath(array $task): string + { + return $this->baseDir((int) $task['user_id']) . '/' . $task['token'] . '.csv'; + } + + /** + * 新建任务(只落状态,真正的导出由后台进程执行)。 + * + * @param array $filters + * @param list $columns + */ + public function create(int $userId, string $tab, string $tabLabel, array $filters, array $columns, int $total): array + { + $task = [ + 'token' => (string) Str::uuid(), + 'user_id' => $userId, + 'tab' => $tab, + 'tab_label' => $tabLabel, + 'type' => self::TYPE_LABEL, + 'status' => 'queued', + 'rows' => 0, + 'total' => max(0, $total), + 'filters' => $filters, + 'columns' => $columns, + 'error' => null, + 'planned_at' => Carbon::now()->toDateTimeString(), + 'started_at' => null, + 'finished_at' => null, + 'size' => 0, + ]; + + $this->write($task); + + return $task; + } + + public function write(array $task): void + { + $dir = $this->baseDir((int) $task['user_id']); + if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) { + throw new RuntimeException('无法创建导出目录:' . $dir); + } + + file_put_contents($this->jsonPath($task), json_encode($task, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } + + public function patch(array $task, array $patch): array + { + $task = array_merge($task, $patch); + $this->write($task); + + return $task; + } + + public function find(int $userId, string $token): ?array + { + if (preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) !== 1) { + return null; + } + + $path = $this->baseDir($userId) . '/' . $token . '.json'; + if (! is_file($path)) { + return null; + } + + $task = json_decode((string) file_get_contents($path), true); + + return is_array($task) ? $task : null; + } + + /** + * 当前用户的导出任务,按计划时间倒序。 + * + * @return list> + */ + public function all(int $userId, int $limit = 50): array + { + $dir = $this->baseDir($userId); + if (! is_dir($dir)) { + return []; + } + + $tasks = []; + $expireBefore = Carbon::now()->subDays(7)->getTimestamp(); + foreach (glob($dir . '/*.json') ?: [] as $path) { + $task = json_decode((string) file_get_contents($path), true); + if (! is_array($task) || ! isset($task['token'])) { + continue; + } + // 只保留最近 7 天,避免任务文件无限堆积。 + if (($task['status'] ?? '') !== 'running' && filemtime($path) !== false && filemtime($path) < $expireBefore) { + @unlink($path); + $csv = $dir . '/' . $task['token'] . '.csv'; + if (is_file($csv)) { + @unlink($csv); + } + continue; + } + $tasks[] = $task; + } + + usort($tasks, static fn (array $a, array $b): int => strcmp((string) ($b['planned_at'] ?? ''), (string) ($a['planned_at'] ?? ''))); + + return array_slice($tasks, 0, $limit); + } + + /** 列表页展示用的字段(去掉 filters/columns 这类大字段)。 */ + public function present(array $task): array + { + return [ + 'token' => $task['token'], + 'type' => $task['type'] ?? self::TYPE_LABEL, + 'tab' => $task['tab'] ?? '', + 'tab_label' => $task['tab_label'] ?? '', + 'status' => $task['status'] ?? 'queued', + 'rows' => (int) ($task['rows'] ?? 0), + 'total' => (int) ($task['total'] ?? 0), + 'size' => (int) ($task['size'] ?? 0), + 'error' => $task['error'] ?? null, + 'planned_at' => $task['planned_at'] ?? null, + 'started_at' => $task['started_at'] ?? null, + 'finished_at' => $task['finished_at'] ?? null, + ]; + } + + /** 后台执行导出(脱离当前请求)。 */ + public function dispatch(array $task): void + { + $php = PHP_BINDIR . '/php'; + if (! is_file($php)) { + $php = 'php'; + } + + $artisan = base_path('artisan'); + $command = escapeshellcmd($php) . ' ' . escapeshellarg($artisan) + . ' account-bills:export-run ' . escapeshellarg((string) $task['token']) + . ' > /dev/null 2>&1 &'; + + try { + if (function_exists('exec')) { + exec($command); + } + } catch (Throwable) { + // 起不了后台进程时保持 queued,页面会提示重试。 + } + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index c86fa136..f1c33876 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -51,15 +51,36 @@
{{ sourceGroups.length ? '账单明细' : columnsTitle }}{{ total }} 条
- + +
- -
-
-
{{ exportText }}
+ +
+
+ 导出进度 + {{ exportHint }} + + +
+ + + + + + + + + + + + + + + +
序号类型计划时间执行时间状态操作
{{ index + 1 }}{{ task.type }} · {{ task.tab_label }}{{ task.planned_at || '—' }}{{ task.finished_at || task.started_at || '—' }}{{ statusLabel(task) }}下载—
暂无导出任务
@@ -94,11 +115,11 @@ const dateConfig = {dateFormat:'Y-m-d',locale:Mandarin,disableMobile:true,allowI const emptyFilters = billType => ({store_name:'',ledger_remark:'',bill_item:'',start:'',end:'',keyword:'',keyword_match:'contains'}); export default { name:'AccountBills', components:{ TableColumnSettings, flatPicker, [Select.name]:Select, [Option.name]:Option }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, progressBase:{type:String,default:''}, userKey:{type:[String,Number],required:true} }, - data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible',exporting:false,exportedRows:0,exportTotal:0,exportTimer:null};}, + data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible',exporting:false,exportPanelOpen:false,exportTasks:[],exportHint:'导出在后台生成,成功后点「下载」;期间可以继续浏览其它页面。',exportTimer:null};}, computed:{ isDouyin(){return this.billType==='douyin';}, - exportPercent(){return this.exportTotal>0?Math.min(100,Math.round(this.exportedRows*100/this.exportTotal)):0;}, - exportText(){const total=Number(this.exportTotal||0);const minutes=Math.max(1,Math.ceil(total/1800/60));return '共 '+total.toLocaleString()+' 行,预计约 '+minutes+' 分钟 · 已导出 '+Number(this.exportedRows||0).toLocaleString()+' 行('+this.exportPercent+'%)· 导出在后台进行,可以继续查看其它页面';}, + companyPrefix(){const matched=(this.apiBase||'').match(/^\/\d+/);return matched?matched[0]:'';}, + tasksUrl(){return this.companyPrefix+'/ecommerce-finance/account-bills/export-tasks';}, // 费用结算时间标签、来源说明与收支净额统计口径按京东系列页签(京东账单/京喜账单)统一。 isJdBillTab(){return JD_BILL_TABS.indexOf(this.billType)!==-1;}, dateFilterLabel(){return DATE_FILTER_LABELS[this.billType]||'发生时间';}, @@ -130,40 +151,51 @@ export default { exportRows(){ if(!this.canExport||this.exporting)return; if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;} - const params=new URLSearchParams(this.resultFilters); - params.set('export','1');params.set('sort',this.sort.key);params.set('dir',this.sort.dir); - if(this.exportMode==='filtered-visible')this.visibleColumns.forEach(column=>params.append('columns[]',column.field)); - // 服务端逐行生成 CSV:行数大时要几分钟,先告知用户,避免误判成「导不出来」。 - const rows=Number(this.total||0); - const token=`ab${Date.now().toString(36)}${Math.random().toString(36).slice(2,10)}`; - params.set('token',token); - params.set('total',String(rows)); - this.startExportWatch(token,rows); - window.location.assign(`${this.apiBase}?${params.toString()}`); + const payload={...(this.resultFilters||{}),tab:this.billType,sort:this.sort.key,dir:this.sort.dir,total:Number(this.total||0)}; + if(this.exportMode==='filtered-visible'){payload.columns=this.visibleColumns.map(column=>column.field);} + this.createExportTask(payload); }, - // 后台导出:轮询服务端进度,页面可以自由切换,文件由浏览器后台下载。 - startExportWatch(token,total){ - this.stopExportWatch(); + // 导出任务:后台生成,页面只提交任务并到「导出进度」里看状态、下载。 + async createExportTask(payload){ this.exporting=true; - this.exportedRows=0; - this.exportTotal=Number(total||0); - const progressUrl=this.progressBase||`${this.apiBase}/export-progress`; - this.exportTimer=window.setInterval(()=>{ - window.fetch(`${progressUrl}?token=${encodeURIComponent(token)}`,{headers:{Accept:'application/json'},credentials:'same-origin'}) - .then(response=>response.ok?response.json():null) - .then(state=>{ - if(!state||state.status==='unknown'){return;} - this.exportedRows=Number(state.rows||0); - if(state.total){this.exportTotal=Number(state.total);} - if(state.status==='done'||state.status==='failed'){this.stopExportWatch();} - }) - .catch(()=>{ /* 轮询失败忽略,下一轮再试 */ }); - },1000); - window.setTimeout(()=>this.stopExportWatch(),30*60*1000); + this.exportPanelOpen=true; + this.exportHint='已提交导出任务,正在后台生成…'; + try{ + await window.axios.post(this.tasksUrl,payload); + this.exportHint='导出在后台生成,成功后点「下载」;期间可以继续浏览其它页面。'; + await this.refreshExportTasks(); + }catch(error){ + this.exportHint=(error.response&&error.response.data&&error.response.data.message)||'提交导出任务失败,请重试。'; + }finally{ + this.exporting=false; + } }, - stopExportWatch(){ - if(this.exportTimer){window.clearInterval(this.exportTimer);this.exportTimer=null;} - this.exporting=false; + toggleExportPanel(){ + this.exportPanelOpen=!this.exportPanelOpen; + if(this.exportPanelOpen){this.refreshExportTasks();} + }, + async refreshExportTasks(){ + try{ + const response=await window.axios.get(this.tasksUrl,{headers:{Accept:'application/json'}}); + this.exportTasks=(response.data&&response.data.data)||[]; + }catch(error){ + this.exportHint='导出任务列表加载失败,请点「刷新」重试。'; + return; + } + this.scheduleExportRefresh(); + }, + scheduleExportRefresh(){ + if(this.exportTimer){window.clearTimeout(this.exportTimer);this.exportTimer=null;} + if(!this.exportPanelOpen){return;} + const pending=this.exportTasks.some(task=>task.status==='queued'||task.status==='running'); + if(!pending){return;} + this.exportTimer=window.setTimeout(()=>this.refreshExportTasks(),3000); + }, + statusLabel(task){ + return {queued:'排队中',running:'导出中',done:'成功',failed:'失败'}[task.status]||task.status; + }, + downloadUrl(task){ + return `${this.tasksUrl}/${encodeURIComponent(task.token)}/download`; }, downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value,column){const text=String(value===null||value===undefined?'':value);const safe=column.kind==='num'&&/^(?:0\d|\d{12,}$)/.test(text)?`\t${text}`:(/^[=+\-@]/.test(text)?`'${text}`:text);return`"${safe.replace(/"/g,'""')}"`;} } @@ -174,10 +206,20 @@ export default { .table-workspace{padding:1rem;color:#171717;font:400 .8125rem/1.5 "JetBrains Mono","Microsoft YaHei","PingFang SC",monospace}.tw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.375rem;margin-bottom:.375rem}.tw-stat{padding:.375rem .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-label,.tw-note,.tw-count{color:#595959;font-size:.625rem}.tw-value{margin-top:.125rem;font-size:1rem;font-weight:700;font-variant-numeric:tabular-nums}.tw-note{margin-top:.125rem}.is-income{color:#53793e}.is-expense{color:#b80000}.tw-filter{display:flex;flex-wrap:wrap;gap:.375rem;margin-bottom:.375rem;padding:.375rem .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-filter>label,.tw-filter-field{display:flex;flex-direction:column;gap:0;color:#595959;font-size:.625rem;font-weight:500}.tw-filter input,.tw-filter select,.tw-tools select{height:1.75rem;min-width:10rem;padding:0 .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}.tw-filter input:focus,.tw-filter select:focus,.tw-tools select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}.tw-filter .tw-keyword{flex:1;min-width:14rem}.tw-keyword input{width:100%}.tw-date-row,.tw-actions,.tw-tools,.tw-page-actions{display:flex;align-items:center;gap:.5rem}.tw-date-control{display:flex;align-items:stretch}.tw-date-control input{min-width:8.5rem}.tw-date-control.has-picker input{border-radius:.5rem 0 0 .5rem}.tw-date-picker{display:inline-flex;align-items:center;justify-content:center;width:2.125rem;height:2.125rem;padding:0;border:1px solid #d8d2c7;border-left:0;border-radius:0 .5rem .5rem 0;background:#fff;color:#856632;cursor:pointer}.tw-date-picker:hover,.tw-date-picker:focus-visible{background:#f7f0de;color:#6a5129;outline:none}.tw-date-picker:focus-visible{box-shadow:0 0 0 3px rgba(133,102,50,.2)}.tw-date-picker .material-icons-outlined{font-size:1.125rem}.tw-date b{font-weight:400}.tw-filter-error{grid-column:1/-1;margin:0;color:#b80000;font-size:.75rem}.tw-actions{align-self:flex-end;margin-left:auto}.tw-button,.tw-page-actions button{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:1.875rem;padding:0 .75rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:500 .8125rem/1 inherit;cursor:pointer}.tw-button:hover{border-color:#856632;color:#856632}.tw-button:disabled{cursor:not-allowed;opacity:.55}.tw-primary{border-color:#856632;background:#856632;color:#fff}.tw-primary:hover{background:#6a5129;color:#fff}.tw-button .material-icons-outlined{font-size:1.125rem}.tw-panel{overflow:visible;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-alert{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.75rem;padding:.75rem;border:1px solid #b80000;border-radius:.5rem;background:#fff4f4;color:#b80000}.tw-alert button{border:0;background:transparent;color:#b80000;font:inherit;font-weight:600;cursor:pointer}.tw-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-bottom:1px solid #d8d2c7}.tw-toolbar strong{margin-right:.625rem;font-size:.875rem}.tw-tools{flex-wrap:wrap;justify-content:flex-end}.tw-tools>select{min-width:13.75rem}.tw-scroll{position:relative;max-height:min(62vh,42rem);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.tw-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;font-size:.78125rem}.tw-table th,.tw-table td{min-width:7.5rem;padding:.5rem .625rem;border-right:1px solid #eeeae2;border-bottom:1px solid #eeeae2;white-space:nowrap}.tw-table thead th{position:sticky;z-index:4;background:#f7f0de;color:#171717;font-weight:600}.tw-groups th{top:0;height:2rem;text-align:center;color:#856632;font-size:.75rem}.tw-columns th{top:2rem;height:2.25rem;text-align:left;background:#fbfaf7;box-shadow:inset 0 -1px #d8d2c7}.tw-columns.is-single th{top:0}.tw-columns th.is-sortable{cursor:pointer}.tw-columns th.is-sortable:hover,.tw-sort{color:#856632}.tw-table tbody tr:hover{background:#f7f0de}.tw-table .is-number{text-align:right;font-variant-numeric:tabular-nums}.tw-table .is-id{color:#595959}.tw-tag{display:inline-block;padding:.0625rem .375rem;border-radius:.25rem;background:#f7f0de;color:#856632}.tw-empty{padding:3rem 1rem!important;text-align:center!important;color:#595959}.tw-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-top:1px solid #d8d2c7;color:#595959}.tw-page-actions select{height:1.875rem;border:1px solid #d8d2c7;border-radius:.375rem}.tw-page-actions button{width:1.875rem;height:1.875rem;padding:0}.tw-page-actions button:disabled{cursor:not-allowed;opacity:.45}@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter-field,.tw-filter .tw-keyword{width:100%}.tw-filter input,.tw-filter select{width:100%}.tw-date-control{width:100%}.tw-date-control input{min-width:0}.tw-date-control.has-picker input{width:calc(100% - 2.125rem)}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date-row{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}} .tw-toolbar { flex-wrap: wrap; } .tw-tools { flex: 1 1 24rem; min-width: 0; } -.tw-export-progress { padding: .5rem .875rem; border-bottom: 1px solid #d8d2c7; background: #fffdf8; } -.tw-export-progress__bar { height: .5rem; border: 1px solid #d8d2c7; border-radius: 999px; background: #fff; overflow: hidden; } -.tw-export-progress__bar span { display: block; height: 100%; width: 0; background: #856632; transition: width .3s ease; } -.tw-export-progress__text { margin-top: .35rem; color: #595959; font-size: .75rem; } +.tw-export-tasks { padding: .5rem .875rem .75rem; border-bottom: 1px solid #d8d2c7; background: #fffdf8; } +.tw-export-tasks__head { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } +.tw-export-tasks__head strong { font-size: .8125rem; } +.tw-export-tasks__note { flex: 1 1 auto; color: #595959; font-size: .75rem; } +.tw-export-tasks__table { width: 100%; max-height: 11rem; overflow: auto; font-size: .75rem; border-collapse: separate; border-spacing: 0; display: block; } +.tw-export-tasks__table thead th { padding: .25rem .5rem; background: #f7f0de; color: #171717; font-weight: 600; text-align: left; white-space: nowrap; } +.tw-export-tasks__table td { padding: .25rem .5rem; border-bottom: 1px solid #efe8db; white-space: nowrap; } +.tw-export-tasks__empty { text-align: center; color: #8a8377; } +.tw-export-tasks__status { padding: .0625rem .375rem; border-radius: .25rem; background: #f2f2f2; color: #595959; } +.tw-export-tasks__status.is-done { background: #e6f0e0; color: #3f6b2b; } +.tw-export-tasks__status.is-running { background: #f7f0de; color: #8a6a2f; } +.tw-export-tasks__status.is-queued { background: #eef1f7; color: #4a5a75; } +.tw-export-tasks__status.is-failed { background: #fdecec; color: #b80000; } +.tw-export-tasks__link { color: #856632; font-weight: 600; text-decoration: underline; } /* 筛选栏默认一行铺开(店铺 / 总账备注 / 账单项目 / 关键词 / 时间 / 查询),只有容器变窄才折行。 */ .tw-filter{display:flex;flex-wrap:wrap;align-items:flex-end;gap:.375rem .5rem}.tw-filter>label,.tw-filter>.tw-filter-field{flex:0 1 11rem;min-width:9rem}.tw-filter>.tw-keyword{flex:1 1 12rem;min-width:10rem}.tw-filter>.tw-date{flex:0 1 22rem;min-width:15rem}.tw-filter>.tw-actions{flex:0 0 auto;align-self:flex-end;justify-content:flex-end;margin-left:auto} @container(max-width:1024px){.table-workspace .tw-filter>label,.table-workspace .tw-filter>.tw-filter-field{flex:1 1 10rem}.table-workspace .tw-filter>.tw-date{flex:1 1 100%}.table-workspace .tw-filter>.tw-actions{flex:1 1 100%;margin-left:0}} diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 52f7373f..c4d2f937 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -59,15 +59,36 @@ - + + - -
-
-
{{ exportText }}
+ +
+
+ 导出进度 + {{ exportHint }} + + +
+
+ + + + + + + + + + + + + + +
序号类型计划时间执行时间状态操作
{{ index + 1 }}{{ task.type }} · {{ task.tab_label }}{{ task.planned_at || '—' }}{{ task.finished_at || task.started_at || '—' }}{{ statusLabel(task) }}下载—
暂无导出任务
@@ -124,14 +145,14 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; export default { name: 'TaobaoAlipayBills', components: { TableColumnSettings, flatPicker, [Select.name]: Select, [Option.name]: Option }, props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, progressBase: { type: String, default: '' }, userKey: { type: [String, Number], required: true } }, - data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', ledger_remark: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, filterOptions: { shops: [], bill_items: [], ledger_remarks: [] }, filterOptionsLoading: false, filterOptionsError: false, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible', exporting: false, exportedRows: 0, exportTotal: 0, exportTimer: null }; }, + data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', ledger_remark: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, filterOptions: { shops: [], bill_items: [], ledger_remarks: [] }, filterOptionsLoading: false, filterOptionsError: false, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible', exporting: false, exportPanelOpen: false, exportTasks: [], exportHint: '导出在后台生成,成功后点「下载」;期间可以继续浏览其它页面。', exportTimer: null }; }, computed: { defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); }, visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); }, stats() { return [{ label: '收入合计', value: this.moneyWan(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.moneyWan(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支差额', value: this.moneyWan(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, rangeStart() { return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1; }, rangeEnd() { return Math.min(this.currentPage * this.perPage, this.total); }, - exportPercent() { return this.exportTotal > 0 ? Math.min(100, Math.round(this.exportedRows * 100 / this.exportTotal)) : 0; }, - exportText() { const total = Number(this.exportTotal || 0); const minutes = Math.max(1, Math.ceil(total / 1800 / 60)); return '共 ' + total.toLocaleString() + ' 行,预计约 ' + minutes + ' 分钟 · 已导出 ' + Number(this.exportedRows || 0).toLocaleString() + ' 行(' + this.exportPercent + '%)· 导出在后台进行,可以继续查看其它页面'; }, + companyPrefix() { const matched = (this.apiBase || '').match(/^\/\d+/); return matched ? matched[0] : ''; }, + tasksUrl() { return this.companyPrefix + '/ecommerce-finance/account-bills/export-tasks'; }, invalidDateRange() { return Boolean(this.filters.start && this.filters.end && this.filters.start > this.filters.end); }, startDateConfig() { return { ...dateConfig, maxDate: this.filters.end || null }; }, endDateConfig() { return { ...dateConfig, minDate: this.filters.start || null }; }, @@ -150,41 +171,53 @@ export default { cellClass(column) { return { 'is-number': this.isNumber(column.kind), 'is-id': column.kind === 'num', 'is-income': column.kind === 'money_in', 'is-expense': column.kind === 'money_out' }; }, isNumber(kind) { return ['num', 'money', 'money_in', 'money_out'].indexOf(kind) !== -1; }, money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }, moneyWan(value) { return `¥${(Number(value || 0) / 10000).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}万`; }, exportRows() { if (this.exportMode === 'page-visible') { this.downloadCurrentPage(); return; } - const params = new URLSearchParams(this.filters); - params.set('sort', this.sort.key); - params.set('dir', this.sort.dir); - if (this.exportMode === 'filtered-visible') this.visibleColumns.forEach((column) => params.append('columns[]', column.field)); - // 服务端逐行生成 CSV:行数大时要几分钟,先告知用户,避免误判成「导不出来」。 - const rows = Number(this.total || 0); - const token = `ab${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; - params.set('token', token); - params.set('total', String(rows)); - this.startExportWatch(token, rows); - window.location.assign(`${this.exportBase}?${params.toString()}`); + const payload = { ...this.filters, tab: 'taobao-alipay', sort: this.sort.key, dir: this.sort.dir, total: Number(this.total || 0) }; + if (this.exportMode === 'filtered-visible') { + payload.columns = this.visibleColumns.map((column) => column.field); + } + this.createExportTask(payload); }, - // 后台导出:轮询服务端进度,页面可以自由切换,文件由浏览器后台下载。 - startExportWatch(token, total) { - this.stopExportWatch(); + // 导出任务:后台生成,页面只提交任务并到「导出进度」里看状态、下载。 + async createExportTask(payload) { this.exporting = true; - this.exportedRows = 0; - this.exportTotal = Number(total || 0); - const progressUrl = this.progressBase || `${this.apiBase}/export-progress`; - this.exportTimer = window.setInterval(() => { - window.fetch(`${progressUrl}?token=${encodeURIComponent(token)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }) - .then((response) => (response.ok ? response.json() : null)) - .then((state) => { - if (!state || state.status === 'unknown') { return; } - this.exportedRows = Number(state.rows || 0); - if (state.total) { this.exportTotal = Number(state.total); } - if (state.status === 'done' || state.status === 'failed') { this.stopExportWatch(); } - }) - .catch(() => { /* 轮询失败忽略,下一轮再试 */ }); - }, 1000); - window.setTimeout(() => this.stopExportWatch(), 30 * 60 * 1000); + this.exportPanelOpen = true; + this.exportHint = '已提交导出任务,正在后台生成…'; + try { + await window.axios.post(this.tasksUrl, payload); + this.exportHint = '导出在后台生成,成功后点「下载」;期间可以继续浏览其它页面。'; + await this.refreshExportTasks(); + } catch (error) { + this.exportHint = (error.response && error.response.data && error.response.data.message) || '提交导出任务失败,请重试。'; + } finally { + this.exporting = false; + } }, - stopExportWatch() { - if (this.exportTimer) { window.clearInterval(this.exportTimer); this.exportTimer = null; } - this.exporting = false; + toggleExportPanel() { + this.exportPanelOpen = !this.exportPanelOpen; + if (this.exportPanelOpen) { this.refreshExportTasks(); } + }, + async refreshExportTasks() { + try { + const response = await window.axios.get(this.tasksUrl, { headers: { Accept: 'application/json' } }); + this.exportTasks = (response.data && response.data.data) || []; + } catch (error) { + this.exportHint = '导出任务列表加载失败,请点「刷新」重试。'; + return; + } + this.scheduleExportRefresh(); + }, + scheduleExportRefresh() { + if (this.exportTimer) { window.clearTimeout(this.exportTimer); this.exportTimer = null; } + if (!this.exportPanelOpen) { return; } + const pending = this.exportTasks.some((task) => task.status === 'queued' || task.status === 'running'); + if (!pending) { return; } + this.exportTimer = window.setTimeout(() => this.refreshExportTasks(), 3000); + }, + statusLabel(task) { + return { queued: '排队中', running: '导出中', done: '成功', failed: '失败' }[task.status] || task.status; + }, + downloadUrl(task) { + return `${this.tasksUrl}/${encodeURIComponent(task.token)}/download`; }, downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); }, csvCell(value, column) { const text = String(value === null || value === undefined ? '' : value); const safe = column.kind === 'num' && /^(?:0\d|\d{12,}$)/.test(text) ? `\t${text}` : (/^[=+\-@]/.test(text) ? `'${text}` : text); return `"${safe.replace(/"/g, '""')}"`; }, @@ -197,10 +230,20 @@ export default { .tw-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .375rem; margin-bottom: .375rem; }.tw-stat { padding: .375rem .625rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-label,.tw-note,.tw-count { color: #595959; font-size: .625rem; }.tw-value { margin-top: .125rem; font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; }.tw-note { margin-top: .125rem; }.is-income { color: #53793e; }.is-expense { color: #b80000; } .tw-filter { display: flex; flex-wrap: wrap; gap: .375rem; margin-bottom: .375rem; padding: .375rem .625rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-filter>label { display: flex; flex-direction: column; gap: 0; color: #595959; font-size: .6875rem; font-weight: 500; }.tw-filter input,.tw-filter select,.tw-tools select { height: 1.875rem; min-width: 10rem; padding: 0 .625rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; color: #171717; font: 400 .8125rem/1 inherit; }.tw-filter input:focus,.tw-filter select:focus,.tw-tools select:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.2); outline: none; }.tw-filter .tw-keyword { flex: 1; min-width: 14rem; }.tw-keyword input { width: 100%; }.tw-date span,.tw-actions,.tw-tools,.tw-page-actions { display: flex; align-items: center; gap: .5rem; }.tw-date b { font-weight: 400; }.tw-date input { min-width: 8.5rem; }.tw-actions { align-self: flex-end; margin-left: auto; } .tw-button,.tw-page-actions button { display: inline-flex; align-items: center; justify-content: center; gap: .375rem; height: 1.875rem; padding: 0 .75rem; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; color: #171717; font: 500 .8125rem/1 inherit; cursor: pointer; }.tw-button:hover { border-color: #856632; color: #856632; }.tw-primary { border-color: #856632; background: #856632; color: #fff; }.tw-primary:hover { background: #6a5129; color: #fff; }.tw-button .material-icons-outlined { font-size: 1.125rem; } -.tw-export-progress { padding: .5rem .875rem; border-bottom: 1px solid #d8d2c7; background: #fffdf8; } -.tw-export-progress__bar { height: .5rem; border: 1px solid #d8d2c7; border-radius: 999px; background: #fff; overflow: hidden; } -.tw-export-progress__bar span { display: block; height: 100%; width: 0; background: #856632; transition: width .3s ease; } -.tw-export-progress__text { margin-top: .35rem; color: #595959; font-size: .75rem; } +.tw-export-tasks { padding: .5rem .875rem .75rem; border-bottom: 1px solid #d8d2c7; background: #fffdf8; } +.tw-export-tasks__head { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } +.tw-export-tasks__head strong { font-size: .8125rem; } +.tw-export-tasks__note { flex: 1 1 auto; color: #595959; font-size: .75rem; } +.tw-export-tasks__table { width: 100%; max-height: 11rem; overflow: auto; font-size: .75rem; border-collapse: separate; border-spacing: 0; display: block; } +.tw-export-tasks__table thead th { padding: .25rem .5rem; background: #f7f0de; color: #171717; font-weight: 600; text-align: left; white-space: nowrap; } +.tw-export-tasks__table td { padding: .25rem .5rem; border-bottom: 1px solid #efe8db; white-space: nowrap; } +.tw-export-tasks__empty { text-align: center; color: #8a8377; } +.tw-export-tasks__status { padding: .0625rem .375rem; border-radius: .25rem; background: #f2f2f2; color: #595959; } +.tw-export-tasks__status.is-done { background: #e6f0e0; color: #3f6b2b; } +.tw-export-tasks__status.is-running { background: #f7f0de; color: #8a6a2f; } +.tw-export-tasks__status.is-queued { background: #eef1f7; color: #4a5a75; } +.tw-export-tasks__status.is-failed { background: #fdecec; color: #b80000; } +.tw-export-tasks__link { color: #856632; font-weight: 600; text-decoration: underline; } .tw-panel { overflow: visible; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; }.tw-alert { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: .75rem; padding: .75rem; border: 1px solid #935f07; border-radius: .5rem; background: #f7f0de; color: #935f07; }.tw-alert button { border: 0; background: transparent; color: #856632; font: inherit; font-weight: 600; cursor: pointer; }.tw-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; min-height: 3.5rem; padding: .625rem .875rem; border-bottom: 1px solid #d8d2c7; }.tw-toolbar strong { margin-right: .625rem; font-size: .875rem; }.tw-tools { flex-wrap: wrap; justify-content: flex-end; }.tw-tools>select { min-width: 13.75rem; } .tw-scroll { position: relative; max-height: min(62vh, 42rem); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }.tw-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: .78125rem; }.tw-table th,.tw-table td { min-width: 7.5rem; padding: .5rem .625rem; border-right: 1px solid #eeeae2; border-bottom: 1px solid #eeeae2; white-space: nowrap; }.tw-table thead th { position: sticky; z-index: 4; background: #f7f0de; color: #171717; font-weight: 600; }.tw-groups th { top: 0; height: 2rem; text-align: center; color: #856632; font-size: .75rem; }.tw-columns th { top: 2rem; height: 2.25rem; text-align: left; background: #fbfaf7; box-shadow: inset 0 -1px #d8d2c7; }.tw-columns th.is-sortable { cursor: pointer; }.tw-columns th.is-sortable:hover,.tw-sort { color: #856632; }.tw-table tbody tr:hover { background: #f7f0de; }.tw-table .is-number { text-align: right; font-variant-numeric: tabular-nums; }.tw-table .is-id { color: #595959; }.tw-tag { display: inline-block; padding: .0625rem .375rem; border-radius: .25rem; background: #f7f0de; color: #856632; }.tw-empty { padding: 3rem 1rem!important; text-align: center!important; color: #595959; } .tw-pager { display: flex; align-items: center; justify-content: space-between; gap: .75rem; min-height: 3.5rem; padding: .625rem .875rem; border-top: 1px solid #d8d2c7; color: #595959; }.tw-page-actions select { height: 1.875rem; border: 1px solid #d8d2c7; border-radius: .375rem; }.tw-page-actions button { width: 1.875rem; height: 1.875rem; padding: 0; }.tw-page-actions button:disabled { cursor: not-allowed; opacity: .45; } diff --git a/routes/admin.php b/routes/admin.php index 990bda24..af24d198 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -63,6 +63,14 @@ Route::get('ecommerce-finance/account-bills/type/{bill_type}/export', [AccountBi // 导出进度:所有店铺收款账户账单页签共用(token 按用户隔离)。 Route::get('ecommerce-finance/account-bills/export-progress', [AccountBills::class, 'exportProgress']) ->name('ecommerce-finance.account-bills.export-progress'); + +// 后台导出任务:新建 / 列表 / 下载(页面「导出进度」用)。 +Route::post('ecommerce-finance/account-bills/export-tasks', [AccountBills::class, 'createExportTask']) + ->name('ecommerce-finance.account-bills.export-tasks.create'); +Route::get('ecommerce-finance/account-bills/export-tasks', [AccountBills::class, 'exportTasks']) + ->name('ecommerce-finance.account-bills.export-tasks'); +Route::get('ecommerce-finance/account-bills/export-tasks/{token}/download', [AccountBills::class, 'downloadExportTask']) + ->name('ecommerce-finance.account-bills.export-tasks.download'); Route::get('ecommerce-finance/account-bills/{bill}', [AccountBills::class, 'show']) ->name('ecommerce-finance.account-bills.show');