diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index b8d3ebe2..b9c7c8cf 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -5,6 +5,7 @@ import { getAdminTaskDetail, getAdminTasks, } from "../../services/admin/admin-read-service.js"; +import { closeAdminTask } from "../../services/admin/write/task-actions.js"; import { dispatchAdminTaskKuaishouCloudFulfillment, prepareAdminTaskKuaishouCloudFulfillment, @@ -53,6 +54,20 @@ router.get( ) ); +router.post( + "/tasks/:taskId/close", + requireAdminRoles(["admin", "operator", "support"]), + createJsonHandler( + (req) => closeAdminTask(getTaskId(req), req.adminSession || null), + { + successMessage: "任务已关闭", + errorMessage: "关闭任务失败", + scope: "[admin/tasks/:taskId/close]", + audit: (req, data) => buildTaskAudit("task_closed", req, data), + } + ) +); + router.post( "/tasks/:taskId/kuaishou-cloud/prepare", requireAdminRoles(["admin", "operator"]), diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts new file mode 100644 index 00000000..6a11fe0c --- /dev/null +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -0,0 +1,78 @@ +import { updateClaimToken } from '../../../repositories/claim-token-repo.js' +import { updateTask } from '../../../repositories/task-repo.js' +import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { createHttpError } from '../../../utils/http.js' +import { nowIso } from '../../../utils/time.js' +import { + canViewerCloseTask, + createAdminViewerContext, +} from '../admin-read-shared-helpers.js' +import { + getRequiredTask, + mapTaskActionPayload, +} from '../admin-task-read-helpers.js' + +import type { + AdminEntityIdInput, + AdminViewerSessionInput, +} from '../../../types/admin/read-inputs.js' +import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' + +export async function closeAdminTask( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + + if (!canViewerCloseTask(task, viewerContext)) { + throw createHttpError('当前任务不可关闭', { + statusCode: 409, + errorCode: 'admin_task_close_not_allowed', + }) + } + + const now = nowIso() + const claimTokenId = Number(task.primary_claim_token_id || 0) + + if (claimTokenId > 0 && String(task.primary_claim_token_status || '').trim() !== 'closed') { + await updateClaimToken(claimTokenId, { + status: 'closed', + expired_at: now, + updated_at: now, + }) + } + + const updatedTask = await updateTask(task.id, { + task_status: 'closed', + delivery_status: 'closed', + result_code: 'closed_by_admin', + result_message: '任务已关闭,领取链接已失效', + user_action_status: 'closed', + last_error: '', + claim_expires_at: now, + updated_at: now, + }) + + if (!updatedTask) { + throw createHttpError('任务关闭失败', { + statusCode: 500, + errorCode: 'admin_task_close_failed', + }) + } + + await createTaskEvent(task.id, 'task_closed', { + closedBy: session + ? { + userId: session.userId, + username: session.username, + role: session.role, + } + : null, + claimTokenClosed: claimTokenId > 0, + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + } +} diff --git a/apps/frontend/public/kuaishou-cloud-guide/4.jpg b/apps/frontend/public/kuaishou-cloud-guide/4.jpg new file mode 100644 index 00000000..bcec284c Binary files /dev/null and b/apps/frontend/public/kuaishou-cloud-guide/4.jpg differ diff --git a/apps/frontend/src/views/admin/orders/AdminOrdersView.vue b/apps/frontend/src/views/admin/orders/AdminOrdersView.vue index 32966994..6c466966 100644 --- a/apps/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/apps/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -84,11 +84,6 @@ function resolveItemSummary(item: AdminOrderListItem) { return item.itemSummary || "未识别商品"; } -function resolveBindingTone(completedCount: number, totalCount: number) { - if (completedCount <= 0) return "warning"; - return completedCount === totalCount ? "success" : "primary"; -} - function resolveBindingSummary(item: AdminOrderListItem) { return `共 ${item.totalBindingCount} 条 · 预占 ${item.reservedBindingCount} · 已消耗 ${item.consumedBindingCount} · 已释放 ${item.releasedBindingCount}`; } @@ -297,53 +292,31 @@ onMounted(() => { - + @@ -438,12 +411,6 @@ onMounted(() => { align-content: start; } -.delivery-status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(112px, 1fr)); - gap: var(--space-2); -} - .status-line { display: grid; grid-template-columns: 34px minmax(0, 1fr); @@ -483,54 +450,70 @@ onMounted(() => { white-space: nowrap; } -.progress-row-stack { +.delivery-track-list { display: grid; - grid-template-columns: repeat(2, minmax(112px, 1fr)); + gap: 6px; +} + +.delivery-track { + display: grid; + grid-template-columns: 64px 42px minmax(0, 1fr); gap: var(--space-2); - margin-top: 2px; + align-items: center; + min-height: 28px; + min-width: 0; + padding: 3px 6px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--bg-subtle); +} + +.delivery-track-label { + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: 700; + white-space: nowrap; +} + +.delivery-track strong { + color: var(--text-primary); + font-size: var(--text-xs); + font-weight: 800; + text-align: center; +} + +.delivery-track :deep(.el-tag), +.delivery-track :deep(.el-tag__content) { + width: 100%; + min-width: 0; +} + +.delivery-track :deep(.el-tag) { + justify-content: center; + background: var(--bg-card); +} + +.delivery-track :deep(.el-tag__content) { + display: block; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; } -.progress-chip, .follow-up-chip { display: inline-flex; align-items: center; justify-content: center; - min-height: 26px; + width: 100%; + min-height: 30px; padding: 0 9px; - border-radius: var(--radius-full); + border-radius: var(--radius-sm); font-size: var(--text-xs); - font-weight: 700; + font-weight: 800; border: 1px solid transparent; white-space: nowrap; } - -.progress-chip { - gap: 4px; -} - -.follow-up-chip { - width: 100%; -} - -.progress-chip strong { - color: inherit; - font-weight: 800; -} -.progress-chip[data-tone="success"] { - color: #0f766e; - background: var(--color-success-light); - border-color: #a6f4c5; -} -.progress-chip[data-tone="primary"] { - color: var(--color-primary-dark); - background: var(--color-primary-light); - border-color: #b2ddff; -} -.progress-chip[data-tone="warning"] { - color: #b54708; - background: var(--color-warning-light); - border-color: #fedf89; -} .follow-up-chip[data-tone="success"] { color: #0f766e; background: var(--color-success-light);