feat: 客服封存订单功能(号主失联场景)

新增客服「封存订单」动作,用于号主失联、无法联系的场景:单事务内
完成关闭订单、全量原路退款、永久封存关联商品、解散发布群、双方通知
与审计日志。

- 新增 listing 终态 sealed(封存),区别于可恢复的 offline
- listingLockedForOwnerMutation 锁定 sealed,号主无法再编辑/提审/上架
- 解散群聊按 listing_id 查发布群(listing_group)置 archived,
  阻断所有成员发言并留系统消息
- 新增 order.AdminSeal 及 /admin/orders/:id/seal 路由
- 前端:卖家页 PC/移动端 sealed 视为终态禁用操作,客服后台新增
  「封存订单」按钮与二次确认

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-29 22:52:20 +08:00
co-authored by Claude Opus 4.8
parent 8a11c2517e
commit be5d322c10
16 changed files with 217 additions and 4 deletions
@@ -10,6 +10,7 @@ import {
adminRefundOrder,
adminRefundStatus,
adminResetHandoff,
adminSealOrder,
fetchAdminHandoffRecords,
fetchAdminOrder,
type HandoffRecord,
@@ -40,7 +41,7 @@ const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([])
const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('')
const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
@@ -89,10 +90,13 @@ const resetActionLabel = computed(() => {
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
const actionTitle = computed(() => {
if (actionType.value === 'close') return '客服关闭订单'
if (actionType.value === 'seal') return '封存订单'
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
return '标记订单异常'
})
const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。'
const sealActionTip =
'封存订单:终止订单并全量原路退款,自动解散关联群聊,关联商品永久封存,号主无法再次编辑或上架。适用于号主失联、无法联系的场景。'
const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。'
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
const orderTotalCent = computed(
@@ -201,7 +205,7 @@ async function loadRefundStatus() {
}
}
function openAction(type: 'close' | 'abnormal' | 'reset') {
function openAction(type: 'close' | 'seal' | 'abnormal' | 'reset') {
actionType.value = type
reason.value = ''
}
@@ -224,6 +228,24 @@ async function confirmCloseAction() {
openAction('close')
}
async function confirmSealAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'封存会终止订单并全量原路退款,自动解散关联群聊,关联商品将被永久封存,号主无法再次编辑或上架。适用于号主失联场景。确认继续?',
'确认封存订单',
{
confirmButtonText: '继续封存',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
openAction('seal')
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
@@ -231,6 +253,9 @@ async function submitAction() {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else if (actionType.value === 'seal') {
await adminSealOrder(order.value.id, reason.value)
ElMessage.success('订单已封存,群聊已解散')
} else if (actionType.value === 'reset') {
await adminResetHandoff(order.value.id, reason.value)
ElMessage.success(`${resetActionLabel.value}成功`)
@@ -467,6 +492,13 @@ function paymentPaidAt(record: AdminPayment) {
>
</span>
</el-tooltip>
<el-tooltip :content="sealActionTip" placement="top">
<span>
<el-button type="danger" plain :disabled="!canOperate" @click="confirmSealAction"
>封存订单</el-button
>
</span>
</el-tooltip>
<el-tooltip :content="refundActionTip" placement="top">
<span>
<el-button
@@ -341,6 +341,11 @@ export async function adminCloseOrder(id: number, reason: string) {
return data.data
}
export async function adminSealOrder(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Order>>(`/admin/orders/${id}/seal`, { reason })
return data.data
}
export async function adminMarkOrderAbnormal(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Order>>(`/admin/orders/${id}/mark-abnormal`, {
reason,
@@ -162,6 +162,7 @@ function statusTone(status: string) {
completed: 'success',
rented: 'warning',
abnormal: 'danger',
sealed: 'muted',
}
return tones[status] || 'info'
}
@@ -152,6 +152,7 @@ function statusTone(status: string) {
draft: 'info',
offline: 'muted',
completed: 'success',
sealed: 'danger',
rented: 'warning',
abnormal: 'danger',
}
@@ -177,7 +178,7 @@ function showReviewStatus(row: Listing) {
}
function isTerminalListing(row: Listing) {
return row.status === 'rented' || row.status === 'completed'
return row.status === 'rented' || row.status === 'completed' || row.status === 'sealed'
}
function isPendingReview(row: Listing) {
+1
View File
@@ -5,6 +5,7 @@ export const listingStatuses = [
'offline',
'completed',
'abnormal',
'sealed',
] as const
export type ListingStatus = (typeof listingStatuses)[number]
@@ -20,6 +20,7 @@ const listingStatusMap: Record<ListingStatus, string> = {
offline: '已下架',
completed: '已完成',
abnormal: '异常',
sealed: '已封存',
}
const listingReviewStatusMap: Record<ListingReviewStatus, string> = {