店铺收款账户账单:导出改成后台任务 + 导出进度列表(状态/下载),不再依赖页面停留
This commit is contained in:
parent
18b4b9f0fb
commit
30f69f2af9
147
app/Console/Commands/RunAccountBillsExportTask.php
Normal file
147
app/Console/Commands/RunAccountBillsExportTask.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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' => '总账备注',
|
||||
|
||||
177
modules/EcommerceFinance/Services/AccountBillsExportTasks.php
Normal file
177
modules/EcommerceFinance/Services/AccountBillsExportTasks.php
Normal 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
@ -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; }
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user