导出任务:新增「取消」(后台检测取消标记后停止并删半成品文件)

This commit is contained in:
yueyang 2026-10-08 15:31:10 +08:00
parent 4d5b9bf440
commit ad65f0cc75
6 changed files with 65 additions and 4 deletions

View File

@ -40,6 +40,12 @@ class RunAccountBillsExportTask extends Command
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
try {
if (! empty($task['cancel'])) {
$tasks->patch($task, ['status' => 'cancelled', 'finished_at' => AccountBillsExportTasks::beijingNow()]);
$this->info('任务已取消。');
return self::SUCCESS;
}
$columns = array_values((array) ($task['columns'] ?? []));
if ($columns === []) {
throw new \RuntimeException('导出任务没有可用字段。');
@ -62,6 +68,16 @@ class RunAccountBillsExportTask extends Command
$written++;
if ($written % 2000 === 0) {
$task = $tasks->patch($task, ['status' => 'running', 'rows' => $written]);
// 用户在弹窗里点了「取消」:停止导出并删掉半成品文件。
$fresh = $tasks->find((int) $task['user_id'], (string) $task['token']);
if (! empty($fresh['cancel'])) {
fclose($csv);
@unlink($tasks->csvPath($task));
$tasks->patch($task, ['status' => 'cancelled', 'rows' => $written, 'finished_at' => AccountBillsExportTasks::beijingNow(), 'size' => 0]);
$this->info('任务已取消(已写入 ' . $written . ' 行后停止)。');
return self::SUCCESS;
}
}
}
fclose($csv);

View File

@ -439,6 +439,23 @@ class AccountBills extends Controller
return response()->download($path, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** 取消导出任务:写入取消标记,后台进程检测到后停止并删除半成品文件。 */
public function cancelExportTask(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, '导出任务不存在或已过期。');
$status = (string) ($task['status'] ?? '');
abort_if(in_array($status, ['done', 'failed', 'cancelled'], true), 409, '该任务已结束,无需取消。');
$tasks->patch($task, ['cancel' => true]);
return response()->json(['status' => 'ok'], 200, ['Cache-Control' => 'private, no-store']);
}
/**
* 该页签可导出的字段:field => label。
*

View File

@ -66,6 +66,7 @@ class AccountBillsExportTasks
'started_at' => null,
'finished_at' => null,
'size' => 0,
'cancel' => false,
];
$this->write($task);
@ -156,6 +157,7 @@ class AccountBillsExportTasks
'total' => (int) ($task['total'] ?? 0),
'size' => (int) ($task['size'] ?? 0),
'error' => $task['error'] ?? null,
'cancel' => (bool) ($task['cancel'] ?? false),
'planned_at' => $task['planned_at'] ?? null,
'started_at' => $task['started_at'] ?? null,
'finished_at' => $task['finished_at'] ?? null,

View File

@ -82,7 +82,9 @@
<td>
<a v-if="task.status === 'done'" :href="downloadUrl(task)" class="tw-modal__link">下载</a>
<span v-else-if="task.status === 'failed'">{{ task.error || '失败' }}</span>
<span v-else>—</span>
<template v-else>
<button type="button" class="tw-modal__cancel" @click="cancelExportTask(task)">取消</button>
</template>
</td>
</tr>
<tr v-if="!exportTasks.length"><td colspan="8" class="tw-modal__empty">暂无导出任务</td></tr>
@ -215,7 +217,15 @@ export default {
this.exportTimer=window.setTimeout(()=>this.refreshExportTasks(),3000);
},
statusLabel(task){
return {queued:'排队中',running:'导出中',done:'成功',failed:'失败'}[task.status]||task.status;
return {queued:'排队中',running:'导出中',done:'成功',failed:'失败',cancelled:'已取消'}[task.status]||task.status;
},
async cancelExportTask(task){
try{
await window.axios.post(`${this.tasksBaseUrl}/${encodeURIComponent(task.token)}/cancel`);
await this.refreshExportTasks();
}catch(error){
this.exportHint=(error.response&&error.response.data&&error.response.data.message)||'取消失败,请重试。';
}
},
downloadUrl(task){
return `${this.tasksBaseUrl}/${encodeURIComponent(task.token)}/download`;
@ -241,6 +251,8 @@ export default {
.tw-modal__table td { padding: .4rem .5rem; border-bottom: 1px solid #eeeae2; white-space: nowrap; }
.tw-modal__empty { text-align: center; color: #8a8377; padding: 1.5rem !important; }
.tw-modal__link { color: #856632; font-weight: 600; text-decoration: underline; }
.tw-modal__cancel { border: 1px solid #d8d2c7; border-radius: .375rem; background: #fff; color: #b80000; font: 500 .75rem/1 inherit; padding: .25rem .5rem; cursor: pointer; }
.tw-modal__cancel:hover { border-color: #b80000; }
.tw-modal__foot { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem 1.25rem; border-top: 1px solid #eee7da; }
.tw-export-status { padding: .0625rem .375rem; border-radius: .25rem; background: #f2f2f2; color: #595959; }
.tw-export-status.is-done { background: #e6f0e0; color: #3f6b2b; }

View File

@ -90,7 +90,9 @@
<td>
<a v-if="task.status === 'done'" :href="downloadUrl(task)" class="tw-modal__link">下载</a>
<span v-else-if="task.status === 'failed'">{{ task.error || '失败' }}</span>
<span v-else>—</span>
<template v-else>
<button type="button" class="tw-modal__cancel" @click="cancelExportTask(task)">取消</button>
</template>
</td>
</tr>
<tr v-if="!exportTasks.length"><td colspan="8" class="tw-modal__empty">暂无导出任务</td></tr>
@ -238,7 +240,15 @@ export default {
this.exportTimer = window.setTimeout(() => this.refreshExportTasks(), 3000);
},
statusLabel(task) {
return { queued: '排队中', running: '导出中', done: '成功', failed: '失败' }[task.status] || task.status;
return { queued: '排队中', running: '导出中', done: '成功', failed: '失败', cancelled: '已取消' }[task.status] || task.status;
},
async cancelExportTask(task) {
try {
await window.axios.post(`${this.tasksBaseUrl}/${encodeURIComponent(task.token)}/cancel`);
await this.refreshExportTasks();
} catch (error) {
this.exportHint = (error.response && error.response.data && error.response.data.message) || '取消失败,请重试。';
}
},
downloadUrl(task) {
return `${this.tasksBaseUrl}/${encodeURIComponent(task.token)}/download`;
@ -266,6 +276,8 @@ export default {
.tw-modal__table td { padding: .4rem .5rem; border-bottom: 1px solid #eeeae2; white-space: nowrap; }
.tw-modal__empty { text-align: center; color: #8a8377; padding: 1.5rem !important; }
.tw-modal__link { color: #856632; font-weight: 600; text-decoration: underline; }
.tw-modal__cancel { border: 1px solid #d8d2c7; border-radius: .375rem; background: #fff; color: #b80000; font: 500 .75rem/1 inherit; padding: .25rem .5rem; cursor: pointer; }
.tw-modal__cancel:hover { border-color: #b80000; }
.tw-modal__foot { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem 1.25rem; border-top: 1px solid #eee7da; }
.tw-export-status { padding: .0625rem .375rem; border-radius: .25rem; background: #f2f2f2; color: #595959; }
.tw-export-status.is-done { background: #e6f0e0; color: #3f6b2b; }

View File

@ -71,6 +71,8 @@ Route::get('ecommerce-finance/account-bills/export-tasks/data', [AccountBills::c
->name('ecommerce-finance.account-bills.export-tasks.data');
Route::get('ecommerce-finance/account-bills/export-tasks/{token}/download', [AccountBills::class, 'downloadExportTask'])
->name('ecommerce-finance.account-bills.export-tasks.download');
Route::post('ecommerce-finance/account-bills/export-tasks/{token}/cancel', [AccountBills::class, 'cancelExportTask'])
->name('ecommerce-finance.account-bills.export-tasks.cancel');
Route::get('ecommerce-finance/account-bills/{bill}', [AccountBills::class, 'show'])
->name('ecommerce-finance.account-bills.show');