店铺收款账户账单:导出改成后台任务 + 导出进度列表(状态/下载),不再依赖页面停留

This commit is contained in:
yueyang 2026-10-08 14:49:11 +08:00
parent 18b4b9f0fb
commit 30f69f2af9
7 changed files with 603 additions and 88 deletions

View File

@ -0,0 +1,147 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Throwable;
/**
* 后台执行「店铺收款账户账单」导出任务:生成 CSV 并更新任务状态,供页面列表下载。
*/
class RunAccountBillsExportTask extends Command
{
protected $signature = 'account-bills:export-run {token : 导出任务 token}';
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$token = (string) $this->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<int,array<string,mixed>> */
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<string,mixed> $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;
}
}

View File

@ -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<string,string>
*/
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);

View File

@ -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' => '总账备注',

View File

@ -0,0 +1,177 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* 店铺收款账户账单的「导出任务」:后台生成 CSV 落盘,页面按任务列表查看状态并下载。
*
* 任务与文件都存在 storage/app/account-bills-exports/{userId}/ 下(JSON 记录状态 + CSV 文件),
* 不依赖浏览器保持连接,也不需要队列表:请求里用 detach 的 php-cli 进程跑导出。
*/
class AccountBillsExportTasks
{
/** 任务类型(列表里的「类型」列)。 */
public const TYPE_LABEL = '平台原始账单导出';
public function baseDir(int $userId): string
{
return storage_path('app/account-bills-exports/' . $userId);
}
public function jsonPath(array $task): string
{
return $this->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<string,mixed> $filters
* @param list<array{field:string,label:string,kind?:string}> $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<array<string,mixed>>
*/
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,页面会提示重试。
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -59,15 +59,36 @@
<option value="filtered-visible">全部筛选结果 · 当前显示列</option>
<option value="filtered-all">全部筛选结果 · 全部字段</option>
</select>
<button type="button" class="tw-button" :disabled="exporting" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>{{ exporting ? '正在导出…' : '导出' }}</button>
<button type="button" class="tw-button" :disabled="exporting" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>{{ exporting ? '提交中…' : '导出' }}</button>
<button type="button" class="tw-button" @click="toggleExportPanel"><span class="material-icons-outlined" aria-hidden="true">assignment</span>导出进度</button>
<table-column-settings :columns="defaultColumns" :user-key="userKey" page-key="store-receiving-bills" tab-key="taobao-alipay" table-key="bill-details" @change="configuredColumns = $event" />
</div>
</div>
<!-- 后台导出进度:带 token 发起下载后轮询服务端进度,用户可以照常翻看其它页面。 -->
<div v-if="exporting" class="tw-export-progress" role="status">
<div class="tw-export-progress__bar"><span :style="{ width: exportPercent + '%' }"></span></div>
<div class="tw-export-progress__text">{{ exportText }}</div>
<!-- 导出进度:任务由后台进程生成,这里只看状态并下载(和聚水潭的导出任务一样)。 -->
<div v-if="exportPanelOpen" class="tw-export-tasks" role="region" aria-label="导出进度">
<div class="tw-export-tasks__head">
<strong>导出进度</strong>
<span class="tw-export-tasks__note">{{ exportHint }}</span>
<button type="button" class="tw-button" @click="refreshExportTasks">刷新</button>
<button type="button" class="tw-button" @click="exportPanelOpen = false">收起</button>
</div>
<table class="tw-export-tasks__table">
<thead>
<tr><th>序号</th><th>类型</th><th>计划时间</th><th>执行时间</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="(task, index) in exportTasks" :key="task.token">
<td>{{ index + 1 }}</td>
<td>{{ task.type }} · {{ task.tab_label }}</td>
<td>{{ task.planned_at || '—' }}</td>
<td>{{ task.finished_at || task.started_at || '—' }}</td>
<td><span class="tw-export-tasks__status" :class="'is-' + task.status">{{ statusLabel(task) }}</span></td>
<td><a v-if="task.status === 'done'" :href="downloadUrl(task)" class="tw-export-tasks__link">下载</a><span v-else>—</span></td>
</tr>
<tr v-if="!exportTasks.length"><td colspan="6" class="tw-export-tasks__empty">暂无导出任务</td></tr>
</tbody>
</table>
</div>
<div ref="scroll" class="tw-scroll">
@ -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; }

View File

@ -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');