init
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminAuditLogs } from '@/services/admin'
|
||||
import type { AdminAuditLogItem, AdminPagination } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
|
||||
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const actorUsername = ref('')
|
||||
const action = ref('')
|
||||
const targetType = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const items = ref<AdminAuditLogItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
async function loadAuditLogs(page = pagination.value.page) {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminAuditLogs({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
actorUsername: actorUsername.value.trim(),
|
||||
action: action.value.trim(),
|
||||
targetType: targetType.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取审计日志失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatPayload(payload: Record<string, unknown>) {
|
||||
const text = stringifyDisplayJson(payload, 0)
|
||||
return text.length > 120 ? `${text.slice(0, 120)}...` : text
|
||||
}
|
||||
|
||||
function resolveTargetLabel(targetType: string, targetId: string) {
|
||||
return `${formatAuditTargetType(targetType)} #${targetId || '-'}`
|
||||
}
|
||||
|
||||
const totalLabel = computed(() => `共 ${pagination.value.total} 条审计记录`)
|
||||
|
||||
onMounted(loadAuditLogs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>操作审计</h1>
|
||||
<p>集中查看高风险后台动作,便于排查谁在什么时间改了什么。</p>
|
||||
</div>
|
||||
<span class="meta-copy">{{ totalLabel }}</span>
|
||||
</header>
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以访问操作审计。</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="filter-bar">
|
||||
<input v-model="actorUsername" class="text-input" placeholder="操作账号" />
|
||||
<select v-model="action" class="text-input select-input">
|
||||
<option v-for="option in adminAuditActionOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="targetType" class="text-input select-input">
|
||||
<option v-for="option in adminAuditTargetTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
||||
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
||||
<el-button round type="primary" @click="() => loadAuditLogs()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">审计日志加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作人</th>
|
||||
<th>角色</th>
|
||||
<th>动作</th>
|
||||
<th>目标</th>
|
||||
<th>摘要</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.logId">
|
||||
<td>{{ formatAdminDateTime(item.createdAt) }}</td>
|
||||
<td>{{ item.actorUsername }}</td>
|
||||
<td><AdminStatusTag :status="item.actorRole" /></td>
|
||||
<td>
|
||||
<strong>{{ formatAuditAction(item.action) }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ resolveTargetLabel(item.targetType, item.targetId) }}</strong>
|
||||
</td>
|
||||
<td class="payload-cell">{{ formatPayload(item.payload) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadAuditLogs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.meta-copy {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-bar,
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.payload-cell {
|
||||
max-width: 420px;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { createAdminCdk, fetchAdminCdks, importAdminCdks, invalidateAdminCdk, releaseAdminInventoryCdk } from '@/services/admin'
|
||||
import type { AdminCdkListItem, AdminPagination } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminCdkStatusOptions } from '@/utils/admin-options'
|
||||
|
||||
const loading = ref(true)
|
||||
const importing = ref(false)
|
||||
const creating = ref(false)
|
||||
const actionLoadingId = ref<number | null>(null)
|
||||
const errorMessage = ref('')
|
||||
const skuCode = ref('')
|
||||
const status = ref('')
|
||||
const batchNo = ref('')
|
||||
const bulkText = ref('')
|
||||
const singleCdkCode = ref('')
|
||||
const items = ref<AdminCdkListItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
async function loadCdks(page = pagination.value.page) {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCdks({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
skuCode: skuCode.value.trim(),
|
||||
status: status.value.trim(),
|
||||
batchNo: batchNo.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 CDK 列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!skuCode.value.trim() || !singleCdkCode.value.trim()) {
|
||||
ElMessage.error('请填写 SKU 和单条 CDK')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
|
||||
try {
|
||||
await createAdminCdk({
|
||||
skuCode: skuCode.value.trim(),
|
||||
batchNo: batchNo.value.trim(),
|
||||
cdkCode: singleCdkCode.value.trim(),
|
||||
})
|
||||
|
||||
ElMessage.success('CDK 已新增')
|
||||
singleCdkCode.value = ''
|
||||
await loadCdks()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '新增 CDK 失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const codes = bulkText.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!skuCode.value.trim() || codes.length === 0) {
|
||||
ElMessage.error('请填写 SKU 并输入至少一条 CDK')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
|
||||
try {
|
||||
const response = await importAdminCdks({
|
||||
skuCode: skuCode.value.trim(),
|
||||
batchNo: batchNo.value.trim(),
|
||||
codes,
|
||||
})
|
||||
|
||||
ElMessage.success(`导入完成,新增 ${response.data.created} 条`)
|
||||
bulkText.value = ''
|
||||
await loadCdks()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runRowAction(cdkId: number, action: () => Promise<unknown>, successMessage: string, confirmText: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoadingId.value = cdkId
|
||||
|
||||
try {
|
||||
await action()
|
||||
ElMessage.success(successMessage)
|
||||
await loadCdks()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCdks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>CDK 管理</h1>
|
||||
<p>查看库存、快速新增 CDK,并处理预占或失效库存。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="import-card">
|
||||
<div class="form-row">
|
||||
<input v-model="skuCode" class="text-input" placeholder="SKU,例如 dnf-cdk-a" />
|
||||
<select v-model="status" class="text-input select-input">
|
||||
<option v-for="option in adminCdkStatusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="batchNo" class="text-input" placeholder="批次号筛选/导入批次号" />
|
||||
<el-button round @click="() => loadCdks()">查询列表</el-button>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-top">
|
||||
<input v-model="singleCdkCode" class="text-input" placeholder="单条新增 CDK" />
|
||||
<el-button :loading="creating" round type="success" @click="submitCreate">单条新增</el-button>
|
||||
<el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="bulkText"
|
||||
class="text-area"
|
||||
placeholder="每行一个 CDK"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">CDK 列表加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>SKU</th>
|
||||
<th>批次</th>
|
||||
<th>状态/绑定</th>
|
||||
<th>订单号</th>
|
||||
<th>预占任务</th>
|
||||
<th>失效原因</th>
|
||||
<th>CDK</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.cdkId">
|
||||
<td>{{ item.cdkId }}</td>
|
||||
<td>{{ item.skuCode }}</td>
|
||||
<td>{{ item.batchNo || '-' }}</td>
|
||||
<td>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="item.status" />
|
||||
<AdminStatusTag :status="item.systemBindingStatus" />
|
||||
<AdminStatusTag :status="item.userBindingStatus" />
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ item.platformOrderId || '-' }}</td>
|
||||
<td>{{ item.reservedByTaskNo || item.reservedByTaskId || '-' }}</td>
|
||||
<td>{{ item.invalidReason || '-' }}</td>
|
||||
<td>{{ item.cdkCode }}</td>
|
||||
<td>
|
||||
<div class="action-stack">
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && item.status === 'reserved'"
|
||||
:loading="actionLoadingId === item.cdkId"
|
||||
link
|
||||
@click="runRowAction(item.cdkId, () => releaseAdminInventoryCdk(item.cdkId), 'CDK 已释放', `确认释放 CDK ${item.cdkCode} 吗?`)"
|
||||
>
|
||||
释放
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && item.status === 'available'"
|
||||
:loading="actionLoadingId === item.cdkId"
|
||||
link
|
||||
type="danger"
|
||||
@click="runRowAction(item.cdkId, () => invalidateAdminCdk(item.cdkId, { reason: '后台手动作废' }), 'CDK 已作废', `确认作废 CDK ${item.cdkCode} 吗?作废后不会再参与分配。`)"
|
||||
>
|
||||
作废
|
||||
</el-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadCdks"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.import-card,
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-row-top {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.text-area {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
min-height: 180px;
|
||||
padding: 14px;
|
||||
margin-top: 14px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchAdminDashboardSummary } from '@/services/admin'
|
||||
import type { AdminDashboardSummary } from '@/types/admin'
|
||||
|
||||
const loading = ref(true)
|
||||
const summary = ref<AdminDashboardSummary | null>(null)
|
||||
const errorMessage = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetchAdminDashboardSummary()
|
||||
summary.value = response.data
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取概览失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', label: '今日订单' },
|
||||
{ key: 'paidPendingClaim', label: '待领取' },
|
||||
{ key: 'claimingTasks', label: '领取中' },
|
||||
{ key: 'redeemedToday', label: '今日成功' },
|
||||
{ key: 'abnormalTasks', label: '异常任务' },
|
||||
{ key: 'skuWithInventory', label: '有库存 SKU' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>系统概览</h1>
|
||||
<p>快速查看订单和交付链路的运行状态。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
|
||||
<div v-if="loading" class="empty-block">概览加载中</div>
|
||||
<div v-else class="summary-grid">
|
||||
<article v-for="card in cards" :key="card.key" class="summary-card">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ summary?.[card.key] ?? 0 }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
box-shadow: 0 18px 44px rgba(30, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
display: block;
|
||||
color: #6a7a91;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
color: #1f324a;
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { logoutAdmin } from '@/services/admin'
|
||||
import { clearAdminSession, getAdminRole, getAdminTokenExpiresAt, getAdminUsername } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const router = useRouter()
|
||||
const isAdmin = computed(() => getAdminRole() === 'admin')
|
||||
const navItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ to: '/admin/dashboard', label: '概览' },
|
||||
{ to: '/admin/orders', label: '订单' },
|
||||
{ to: '/admin/tasks', label: '任务' },
|
||||
{ to: '/admin/cdks', label: 'CDK' },
|
||||
{ to: '/admin/webhook-events', label: 'Webhook' },
|
||||
]
|
||||
|
||||
if (isAdmin.value) {
|
||||
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
|
||||
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
|
||||
}
|
||||
|
||||
return baseItems
|
||||
})
|
||||
|
||||
async function submitLogout() {
|
||||
try {
|
||||
await logoutAdmin()
|
||||
} catch {
|
||||
// 后台退出是无状态的,本地清理优先
|
||||
} finally {
|
||||
clearAdminSession()
|
||||
ElMessage.success('已退出后台')
|
||||
await router.replace('/admin/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="admin-page">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-brand">
|
||||
<strong>运营后台</strong>
|
||||
<p>订单与交付管理</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-meta">
|
||||
<span>当前账号</span>
|
||||
<strong>{{ getAdminUsername() || '未读取' }}</strong>
|
||||
<span>当前角色</span>
|
||||
<strong>{{ getAdminRole() === 'admin' ? '管理员' : '普通运营' }}</strong>
|
||||
<span>登录有效期</span>
|
||||
<strong>{{ formatAdminDateTime(getAdminTokenExpiresAt()) }}</strong>
|
||||
</div>
|
||||
|
||||
<nav class="admin-nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
class="admin-nav-link"
|
||||
:to="item.to"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<el-button round class="logout-btn" @click="submitLogout">退出登录</el-button>
|
||||
</aside>
|
||||
|
||||
<section class="admin-content">
|
||||
<RouterView />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(73, 136, 255, 0.1), transparent 30%),
|
||||
linear-gradient(180deg, #f3f7fc 0%, #edf3fb 100%);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
padding: 24px 18px;
|
||||
border-right: 1px solid rgba(86, 108, 138, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.admin-brand strong {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
color: #203551;
|
||||
}
|
||||
|
||||
.admin-brand p {
|
||||
margin: 8px 0 0;
|
||||
color: #6c7c92;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
margin-top: 28px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-meta {
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: #f5f8fc;
|
||||
color: #5f7289;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-meta strong {
|
||||
color: #1f3c5c;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-nav-link {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
color: #304866;
|
||||
background: #f5f8fc;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.admin-nav-link.router-link-active {
|
||||
color: #114899;
|
||||
border-color: rgba(17, 72, 153, 0.14);
|
||||
background: rgba(17, 72, 153, 0.08);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.12);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { loginAdmin } from '@/services/admin'
|
||||
import { setAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
async function submitLogin() {
|
||||
if (!username.value.trim() || !password.value.trim()) {
|
||||
ElMessage.error('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: username.value.trim(),
|
||||
password: password.value.trim(),
|
||||
})
|
||||
|
||||
setAdminSession(response.data.token, response.data.expiresAt, response.data.user)
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace('/admin/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '后台登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-card">
|
||||
<header class="login-header">
|
||||
<p class="eyebrow">Order Site Admin</p>
|
||||
<h1>运营后台登录</h1>
|
||||
<p>使用账号密码进入后台,并按角色开放不同操作权限。</p>
|
||||
</header>
|
||||
|
||||
<label class="field">
|
||||
<span>账号</span>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="text-input"
|
||||
placeholder="输入账号"
|
||||
@keyup.enter="submitLogin"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="text-input"
|
||||
placeholder="输入密码"
|
||||
@keyup.enter="submitLogin"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<el-button :loading="loading" round type="primary" class="submit-btn" @click="submitLogin">
|
||||
登录后台
|
||||
</el-button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(18, 102, 214, 0.18), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(12, 74, 110, 0.16), transparent 26%),
|
||||
linear-gradient(160deg, #eef5ff 0%, #f7fbff 42%, #edf6f3 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(460px, 100%);
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(38, 88, 155, 0.14);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 8px 0 0;
|
||||
color: #17324f;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 10px 0 0;
|
||||
color: #5f7289;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #1266d6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.2);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||
import type { AdminOrderDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminOrderDetail | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetchAdminOrderDetail(String(route.params.orderId || ''))
|
||||
detail.value = response.data
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取订单详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>订单详情</h1>
|
||||
<p>查看订单原始信息、商品子项和关联任务。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">订单详情加载中</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="info-card">
|
||||
<h2>{{ detail.order.platformOrderId }}</h2>
|
||||
<p>支付状态:<AdminStatusTag :status="detail.order.payStatus" /> · 订单状态:<AdminStatusTag :status="detail.order.orderStatus" /></p>
|
||||
<p>
|
||||
系统绑定:<AdminStatusTag :status="detail.order.bindingSummary.systemBindingStatus" />
|
||||
· 完整绑定:<AdminStatusTag :status="detail.order.bindingSummary.userBindingStatus" />
|
||||
</p>
|
||||
<p>
|
||||
任务进度:{{ detail.order.bindingSummary.completedBindingTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 完整绑定
|
||||
· {{ detail.order.bindingSummary.systemBoundTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 系统绑定
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>商品子项</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>商品</th>
|
||||
<th>数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in detail.items" :key="item.orderItemId">
|
||||
<td>{{ item.skuCode }}</td>
|
||||
<td>{{ item.skuName }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>关联任务</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务号</th>
|
||||
<th>状态</th>
|
||||
<th>系统绑定</th>
|
||||
<th>完整绑定</th>
|
||||
<th>错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in detail.tasks" :key="task.taskId">
|
||||
<td>
|
||||
<RouterLink :to="`/admin/tasks/${task.taskId}`">{{ task.taskNo }}</RouterLink>
|
||||
</td>
|
||||
<td><AdminStatusTag :status="task.status" /></td>
|
||||
<td><AdminStatusTag :status="task.systemBindingStatus" /></td>
|
||||
<td><AdminStatusTag :status="task.userBindingStatus" /></td>
|
||||
<td>{{ task.lastError || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>Webhook 摘要</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件 ID</th>
|
||||
<th>类型</th>
|
||||
<th>验签</th>
|
||||
<th>处理</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in detail.webhookEvents" :key="event.eventId">
|
||||
<td>
|
||||
<RouterLink :to="`/admin/webhook-events/${event.eventId}`">{{ event.eventId }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatAdminWebhookEventType(event.eventType) }}</td>
|
||||
<td><AdminStatusTag :status="event.signatureValid ? 'success' : 'failed'" /></td>
|
||||
<td><AdminStatusTag :status="event.processed ? 'success' : 'failed'" /></td>
|
||||
<td>{{ formatAdminDateTime(event.createdAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="detail.webhookEvents.length === 0">
|
||||
<td colspan="5">当前订单还没有关联的 webhook 记录。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel { display: grid; gap: 16px; }
|
||||
.panel-header h1 { margin: 0; color: #1d3555; }
|
||||
.panel-header p { margin: 8px 0 0; color: #64748b; }
|
||||
.info-card,.table-card,.empty-block,.error-copy {
|
||||
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
|
||||
}
|
||||
.error-copy { color: #b42318; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminOrders } from '@/services/admin'
|
||||
import type { AdminOrderListItem, AdminPagination } from '@/types/admin'
|
||||
import { adminPayStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const platformOrderId = ref('')
|
||||
const payStatus = ref('')
|
||||
const skuCode = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const items = ref<AdminOrderListItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
async function loadOrders(page = pagination.value.page) {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminOrders({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
platformOrderId: platformOrderId.value.trim(),
|
||||
payStatus: payStatus.value.trim(),
|
||||
skuCode: skuCode.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取订单列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>订单列表</h1>
|
||||
<p>查看平台订单、支付状态和对应任务数量。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="platformOrderId" class="text-input" placeholder="筛选订单号" />
|
||||
<select v-model="payStatus" class="text-input select-input">
|
||||
<option v-for="option in adminPayStatusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="skuCode" class="text-input" placeholder="SKU,如 dnf-cdk-a" />
|
||||
</section>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
||||
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
||||
<el-button round type="primary" @click="() => loadOrders()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">订单列表加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>平台</th>
|
||||
<th>订单进度</th>
|
||||
<th>金额</th>
|
||||
<th>任务进度</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.orderId">
|
||||
<td>
|
||||
<RouterLink :to="`/admin/orders/${item.orderId}`">{{ item.platformOrderId }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ item.platform }}</td>
|
||||
<td>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="item.payStatus" />
|
||||
<AdminStatusTag :status="item.orderStatus" />
|
||||
<AdminStatusTag :status="item.systemBindingStatus" />
|
||||
<AdminStatusTag :status="item.userBindingStatus" />
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ item.totalAmount }} {{ item.currency }}</td>
|
||||
<td>
|
||||
<div class="progress-stack">
|
||||
<span
|
||||
class="progress-chip"
|
||||
:data-tone="item.completedBindingTaskCount > 0 ? (item.completedBindingTaskCount === item.taskCount ? 'success' : 'primary') : 'warning'"
|
||||
>
|
||||
完整绑定 {{ item.completedBindingTaskCount }}/{{ item.taskCount }}
|
||||
</span>
|
||||
<span
|
||||
class="progress-chip"
|
||||
:data-tone="item.systemBoundTaskCount > 0 ? (item.systemBoundTaskCount === item.taskCount ? 'success' : 'primary') : 'warning'"
|
||||
>
|
||||
系统绑定 {{ item.systemBoundTaskCount }}/{{ item.taskCount }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatAdminDateTime(item.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-chip[data-tone='success'] {
|
||||
color: #0f766e;
|
||||
background: #ecfdf3;
|
||||
border-color: #a6f4c5;
|
||||
}
|
||||
|
||||
.progress-chip[data-tone='primary'] {
|
||||
color: #175cd3;
|
||||
background: #eff8ff;
|
||||
border-color: #b2ddff;
|
||||
}
|
||||
|
||||
.progress-chip[data-tone='warning'] {
|
||||
color: #b54708;
|
||||
background: #fffaeb;
|
||||
border-color: #fedf89;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: min(360px, 100%);
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTaskScreenshot,
|
||||
fetchAdminTaskDetail,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminTaskCdk,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminTaskDetail | null>(null)
|
||||
const lastClaimUrl = ref('')
|
||||
const screenshotPreviewUrl = ref('')
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||||
detail.value = response.data
|
||||
await loadScreenshotPreview(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
lastClaimUrl.value = response.data.claimUrl
|
||||
}
|
||||
ElMessage.success(successMessage)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
|
||||
clearScreenshotPreview()
|
||||
|
||||
if (!taskDetail.screenshotUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value)
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
onBeforeUnmount(clearScreenshotPreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>任务详情</h1>
|
||||
<p>查看任务全量信息并执行人工操作。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">任务详情加载中</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="info-card">
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{ detail.order?.platformOrderId || '-' }}</p>
|
||||
<p>系统绑定:<AdminStatusTag :status="detail.task.systemBindingStatus" /> · 完整绑定:<AdminStatusTag :status="detail.task.userBindingStatus" /></p>
|
||||
<p>角色:{{ detail.task.roleName || '-' }} / {{ detail.task.roleId || '-' }}</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="lastClaimUrl">新领取链接:{{ lastClaimUrl }}</p>
|
||||
</section>
|
||||
|
||||
<section class="action-row">
|
||||
<el-button
|
||||
:disabled="!detail.operations.canRetry"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="runAction(() => retryAdminTask(detail!.task.taskId), '任务已重试', `确认重试任务 ${detail!.task.taskNo} 吗?`)"
|
||||
>
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:disabled="!detail.operations.canReleaseCdk"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="runAction(() => releaseAdminTaskCdk(detail!.task.taskId), 'CDK 已释放', `确认释放任务 ${detail!.task.taskNo} 当前预占的 CDK 吗?`)"
|
||||
>
|
||||
释放 CDK
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="runAction(() => regenerateAdminTaskClaimLink(detail!.task.taskId), '领取链接已重新生成', `确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`)"
|
||||
>
|
||||
重发链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="runAction(() => markAdminTaskManualReview(detail!.task.taskId), '任务已转人工处理', `确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`)"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@click="runAction(() => closeAdminTask(detail!.task.taskId), '任务已关闭', `确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`)"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>任务信息</h3>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
|
||||
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
|
||||
<tr><th>预占 CDK</th><td>{{ detail.cdk?.cdkCode || '-' }}</td></tr>
|
||||
<tr><th>CDK 状态</th><td><AdminStatusTag :status="detail.cdk?.status || ''" /></td></tr>
|
||||
<tr><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
|
||||
<tr><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
|
||||
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
|
||||
<tr><th>浏览器会话</th><td>{{ detail.task.browserSessionId || '-' }}</td></tr>
|
||||
<tr><th>用户打开链接</th><td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td></tr>
|
||||
<tr><th>用户提交绑定</th><td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td></tr>
|
||||
<tr><th>完整绑定成功</th><td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td></tr>
|
||||
<tr><th>截图路径</th><td>{{ detail.task.screenshotPath || '-' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="detail.screenshotUrl" class="table-card">
|
||||
<h3>结果截图</h3>
|
||||
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取。</p>
|
||||
<a v-else class="screenshot-link" :href="screenshotPreviewUrl" target="_blank" rel="noreferrer">打开原图</a>
|
||||
<img v-if="screenshotPreviewUrl" class="screenshot-preview" :src="screenshotPreviewUrl" alt="任务兑换截图" />
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel { display: grid; gap: 16px; }
|
||||
.panel-header h1 { margin: 0; color: #1d3555; }
|
||||
.panel-header p { margin: 8px 0 0; color: #64748b; }
|
||||
.info-card,.table-card,.empty-block,.error-copy {
|
||||
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
|
||||
}
|
||||
.error-copy { color: #b42318; }
|
||||
.action-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.table-card h3 { margin: 0 0 12px; color: #1d3555; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
|
||||
.data-table th { width: 160px; color: #64748b; }
|
||||
.screenshot-link { display: inline-block; margin-bottom: 12px; color: #175cd3; text-decoration: none; }
|
||||
.screenshot-empty { margin: 0 0 12px; color: #64748b; }
|
||||
.screenshot-preview {
|
||||
display: block;
|
||||
width: min(100%, 720px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(86,108,138,.12);
|
||||
box-shadow: 0 20px 48px rgba(15,23,42,.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminTaskCdk,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
import type { AdminPagination, AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||
|
||||
const loading = ref(true)
|
||||
const actionLoadingId = ref<number | null>(null)
|
||||
const errorMessage = ref('')
|
||||
const status = ref('')
|
||||
const taskNo = ref('')
|
||||
const roleId = ref('')
|
||||
const skuCode = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const items = ref<AdminTaskListItem[]>([])
|
||||
const lastClaimUrl = ref('')
|
||||
const openedActionTaskId = ref<number | null>(null)
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'release_cdk' | 'manual_review' | 'close'
|
||||
|
||||
async function loadTasks(page = pagination.value.page) {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminTasks({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
status: status.value.trim(),
|
||||
taskNo: taskNo.value.trim(),
|
||||
roleId: roleId.value.trim(),
|
||||
skuCode: skuCode.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeActionMenu() {
|
||||
openedActionTaskId.value = null
|
||||
}
|
||||
|
||||
function toggleActionMenu(taskId: number) {
|
||||
openedActionTaskId.value = openedActionTaskId.value === taskId ? null : taskId
|
||||
}
|
||||
|
||||
function handleDocumentClick() {
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTasks()
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
async function runAction(
|
||||
taskId: number,
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoadingId.value = taskId
|
||||
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
lastClaimUrl.value = response.data.claimUrl
|
||||
}
|
||||
ElMessage.success(successMessage)
|
||||
closeActionMenu()
|
||||
await loadTasks()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
||||
const [actionKey, rawTaskId] = command.split(':') as [AdminTaskActionKey, string]
|
||||
const taskId = Number(rawTaskId)
|
||||
const item = items.value.find((entry) => entry.taskId === taskId)
|
||||
|
||||
if (!item) {
|
||||
ElMessage.error('未找到对应任务')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionKey === 'retry') {
|
||||
void runAction(item.taskId, () => retryAdminTask(item.taskId), '任务已重试', `确认重试任务 ${item.taskNo} 吗?`)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionKey === 'regenerate_claim_link') {
|
||||
void runAction(
|
||||
item.taskId,
|
||||
() => regenerateAdminTaskClaimLink(item.taskId),
|
||||
'领取链接已重新生成',
|
||||
`确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效。`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionKey === 'release_cdk') {
|
||||
void runAction(
|
||||
item.taskId,
|
||||
() => releaseAdminTaskCdk(item.taskId),
|
||||
'CDK 已释放',
|
||||
`确认释放任务 ${item.taskNo} 当前预占的 CDK 吗?`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionKey === 'manual_review') {
|
||||
void runAction(
|
||||
item.taskId,
|
||||
() => markAdminTaskManualReview(item.taskId),
|
||||
'任务已转人工处理',
|
||||
`确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
void runAction(
|
||||
item.taskId,
|
||||
() => closeAdminTask(item.taskId),
|
||||
'任务已关闭',
|
||||
`确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>交付任务</h1>
|
||||
<p>查看交付状态、角色识别、预占 CDK 和错误信息。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-bar">
|
||||
<select v-model="status" class="text-input select-input">
|
||||
<option v-for="option in adminTaskStatusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="taskNo" class="text-input" placeholder="任务号" />
|
||||
<input v-model="roleId" class="text-input" placeholder="角色 ID" />
|
||||
</section>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="skuCode" class="text-input" placeholder="SKU" />
|
||||
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
||||
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
||||
<el-button round type="primary" @click="() => loadTasks()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<p v-if="lastClaimUrl" class="info-copy">最近生成的领取链接:{{ lastClaimUrl }}</p>
|
||||
<div v-if="loading" class="empty-block">任务列表加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<colgroup>
|
||||
<col class="col-meta" />
|
||||
<col class="col-progress" />
|
||||
<col class="col-goods" />
|
||||
<col class="col-role" />
|
||||
<col class="col-error" />
|
||||
<col class="col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务信息</th>
|
||||
<th>任务进度</th>
|
||||
<th>商品 / CDK</th>
|
||||
<th>角色绑定</th>
|
||||
<th>错误</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.taskId">
|
||||
<td>
|
||||
<div class="meta-cell">
|
||||
<RouterLink :to="`/admin/tasks/${item.taskId}`" class="cell-link meta-title" :title="item.taskNo">
|
||||
{{ item.taskNo }}
|
||||
</RouterLink>
|
||||
<span class="meta-subline" :title="item.platformOrderId">订单 {{ item.platformOrderId }}</span>
|
||||
<span class="meta-subline meta-muted">重试 {{ item.retryCount }} 次</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress-cell">
|
||||
<div class="progress-row">
|
||||
<span class="progress-label">任务</span>
|
||||
<AdminStatusTag :status="item.status" />
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="progress-label">系统</span>
|
||||
<AdminStatusTag :status="item.systemBindingStatus" />
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="progress-label">完整</span>
|
||||
<AdminStatusTag :status="item.userBindingStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="goods-cell">
|
||||
<span class="goods-title" :title="item.skuName || item.skuCode">{{ item.skuName || item.skuCode }}</span>
|
||||
<span class="goods-subline">SKU {{ item.skuCode || '-' }}</span>
|
||||
<span class="goods-subline" :title="item.reservedCdkCodeMasked || '-'">CDK {{ item.reservedCdkCodeMasked || '-' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-cell">
|
||||
<span class="role-title" :title="item.roleName || item.roleId || '-'">{{ item.roleName || '-' }}</span>
|
||||
<span class="role-subline" :title="item.roleId || '-'">角色ID {{ item.roleId || '-' }}</span>
|
||||
<span class="role-subline">登录 {{ item.loginType || '-' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cell-error" :title="item.lastError || '-'">{{ item.lastError || '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-cell">
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.taskId"
|
||||
class="primary-action"
|
||||
plain
|
||||
type="danger"
|
||||
@click="runAction(item.taskId, () => retryAdminTask(item.taskId), '任务已重试', `确认重试任务 ${item.taskNo} 吗?`)"
|
||||
>
|
||||
重试
|
||||
</el-button>
|
||||
<div
|
||||
v-if="hasAdminRole('admin')"
|
||||
class="more-action-wrap"
|
||||
@click.stop
|
||||
>
|
||||
<el-button class="secondary-action" @click.stop="toggleActionMenu(item.taskId)">
|
||||
更多
|
||||
<span class="dropdown-icon">{{ openedActionTaskId === item.taskId ? '▴' : '▾' }}</span>
|
||||
</el-button>
|
||||
<div v-if="openedActionTaskId === item.taskId" class="more-action-menu">
|
||||
<button class="menu-action-button" type="button" @click="handleActionCommand(`regenerate_claim_link:${item.taskId}`)">
|
||||
重发链接
|
||||
</button>
|
||||
<button class="menu-action-button" type="button" @click="handleActionCommand(`release_cdk:${item.taskId}`)">
|
||||
释放 CDK
|
||||
</button>
|
||||
<button class="menu-action-button" type="button" @click="handleActionCommand(`manual_review:${item.taskId}`)">
|
||||
转人工
|
||||
</button>
|
||||
<button class="menu-action-button menu-action-button-danger" type="button" @click="handleActionCommand(`close:${item.taskId}`)">
|
||||
关闭任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadTasks"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.info-copy {
|
||||
color: #175cd3;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.col-meta { width: 220px; }
|
||||
.col-progress { width: 300px; }
|
||||
.col-goods { width: 220px; }
|
||||
.col-role { width: 180px; }
|
||||
.col-error { width: 160px; }
|
||||
.col-actions { width: 130px; }
|
||||
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid rgba(86, 108, 138, 0.12);
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cell-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cell-link:hover {
|
||||
color: #175cd3;
|
||||
}
|
||||
|
||||
.meta-cell,
|
||||
.goods-cell,
|
||||
.role-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-title,
|
||||
.goods-title,
|
||||
.role-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #101828;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-subline,
|
||||
.goods-subline,
|
||||
.role-subline {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta-muted {
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.cell-error {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.6;
|
||||
color: #475467;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: start;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
min-width: 88px;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: #fff5f4;
|
||||
border-color: #fda29b;
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.primary-action:hover,
|
||||
.primary-action:focus {
|
||||
background: #fee4e2;
|
||||
border-color: #f97066;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
background: #ffffff;
|
||||
border-color: rgba(86, 108, 138, 0.18);
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.secondary-action:hover,
|
||||
.secondary-action:focus {
|
||||
border-color: rgba(23, 92, 211, 0.28);
|
||||
color: #175cd3;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.more-action-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.more-action-menu {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
min-width: 108px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid rgba(86, 108, 138, 0.12);
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.menu-action-button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #344054;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-action-button:hover {
|
||||
background: #eff6ff;
|
||||
color: #175cd3;
|
||||
}
|
||||
|
||||
.menu-action-button-danger {
|
||||
background: #fff5f4;
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.menu-action-button-danger:hover {
|
||||
background: #fee4e2;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-cell :deep(.el-button > span) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.col-progress { width: 280px; }
|
||||
.col-goods { width: 200px; }
|
||||
.col-role { width: 160px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.data-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import {
|
||||
createAdminUser,
|
||||
fetchAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
updateAdminUserRole,
|
||||
updateAdminUserStatus,
|
||||
} from '@/services/admin'
|
||||
import type { AdminPagination, AdminRole, AdminUserListItem } from '@/types/admin'
|
||||
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const actionLoadingId = ref<number | null>(null)
|
||||
const errorMessage = ref('')
|
||||
const username = ref('')
|
||||
const role = ref('')
|
||||
const status = ref('')
|
||||
const createUsername = ref('')
|
||||
const createPassword = ref('')
|
||||
const createRole = ref<AdminRole>('operator')
|
||||
const items = ref<AdminUserListItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
function isCurrentUser(userId: number) {
|
||||
return getAdminUserId() === userId
|
||||
}
|
||||
|
||||
async function loadUsers(page = pagination.value.page) {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminUsers({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
username: username.value.trim(),
|
||||
role: role.value.trim(),
|
||||
status: status.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取后台用户失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createUsername.value.trim() || !createPassword.value.trim()) {
|
||||
ElMessage.error('请填写账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: createUsername.value.trim(),
|
||||
password: createPassword.value.trim(),
|
||||
role: createRole.value,
|
||||
status: 'active',
|
||||
})
|
||||
ElMessage.success('后台用户已创建')
|
||||
createUsername.value = ''
|
||||
createPassword.value = ''
|
||||
createRole.value = 'operator'
|
||||
await loadUsers(1)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '创建后台用户失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRole(item: AdminUserListItem) {
|
||||
const nextRole: AdminRole = item.role === 'admin' ? 'operator' : 'admin'
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将 ${item.username} 调整为${nextRole === 'admin' ? '管理员' : '普通运营'}吗?`, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoadingId.value = item.userId
|
||||
|
||||
try {
|
||||
await updateAdminUserRole(item.userId, { role: nextRole })
|
||||
ElMessage.success('用户角色已更新')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '更新用户角色失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: AdminUserListItem) {
|
||||
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`, '确认操作', {
|
||||
type: nextStatus === 'active' ? 'info' : 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoadingId.value = item.userId
|
||||
|
||||
try {
|
||||
await updateAdminUserStatus(item.userId, { status: nextStatus })
|
||||
ElMessage.success('用户状态已更新')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '更新用户状态失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function promptResetPassword(item: AdminUserListItem) {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`请输入 ${item.username} 的新密码`, '重置密码', {
|
||||
inputType: 'password',
|
||||
inputPlaceholder: '至少 8 位',
|
||||
confirmButtonText: '提交',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: (value) => value.trim().length >= 8 || '密码至少 8 位',
|
||||
})
|
||||
|
||||
actionLoadingId.value = item.userId
|
||||
await resetAdminUserPassword(item.userId, { password: result.value.trim() })
|
||||
ElMessage.success('用户密码已重置')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '重置密码失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>后台用户</h1>
|
||||
<p>管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以访问用户管理。</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="form-card">
|
||||
<div class="form-row">
|
||||
<input v-model="username" class="text-input" placeholder="账号筛选" />
|
||||
<select v-model="role" class="text-input select-input">
|
||||
<option v-for="option in adminUserRoleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="status" class="text-input select-input">
|
||||
<option v-for="option in adminUserStatusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<el-button round @click="() => loadUsers()">查询列表</el-button>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-top">
|
||||
<input v-model="createUsername" class="text-input" placeholder="新账号" />
|
||||
<input v-model="createPassword" class="text-input" type="password" placeholder="新密码,至少 8 位" />
|
||||
<select v-model="createRole" class="text-input select-input">
|
||||
<option value="operator">普通运营</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
<el-button :loading="creating" round type="primary" @click="submitCreate">新增用户</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">用户列表加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>账号</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.userId">
|
||||
<td>{{ item.userId }}</td>
|
||||
<td>{{ item.username }}</td>
|
||||
<td>{{ item.role === 'admin' ? '管理员' : '普通运营' }}</td>
|
||||
<td><AdminStatusTag :status="item.status" /></td>
|
||||
<td>{{ formatAdminDateTime(item.createdAt) }}</td>
|
||||
<td>{{ formatAdminDateTime(item.updatedAt) }}</td>
|
||||
<td>
|
||||
<div class="action-stack">
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
:disabled="isCurrentUser(item.userId)"
|
||||
@click="toggleRole(item)"
|
||||
>
|
||||
{{ item.role === 'admin' ? '设为运营' : '设为管理员' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
:disabled="isCurrentUser(item.userId)"
|
||||
@click="toggleStatus(item)"
|
||||
>
|
||||
{{ item.status === 'active' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
type="primary"
|
||||
@click="promptResetPassword(item)"
|
||||
>
|
||||
重置密码
|
||||
</el-button>
|
||||
<span v-if="isCurrentUser(item.userId)" class="self-copy">当前登录账号</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-row-top {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.self-copy {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { fetchAdminWebhookEventDetail, replayAdminWebhookEvent } from '@/services/admin'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminWebhookEventDetail, AdminWebhookReplayResponse } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
||||
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const replayLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminWebhookEventDetail | null>(null)
|
||||
const lastReplayResult = ref<AdminWebhookReplayResponse['result'] | null>(null)
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminWebhookEventDetail(String(route.params.eventId || ''))
|
||||
detail.value = response.data
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 webhook 详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReplay() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认重放 webhook #${detail.value.eventId} 吗?这会重新推进订单与任务处理。`,
|
||||
'确认重放',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续重放',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
replayLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await replayAdminWebhookEvent(detail.value.eventId)
|
||||
lastReplayResult.value = response.data.result
|
||||
ElMessage.success(`Webhook 已重放,生成/更新任务 ${response.data.result.taskCount} 条`)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重放 webhook 失败')
|
||||
} finally {
|
||||
replayLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prettyJson(value: Record<string, unknown>) {
|
||||
return stringifyDisplayJson(value, 2)
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>Webhook 详情</h1>
|
||||
<p>查看原始请求并手动重放处理流程。</p>
|
||||
</div>
|
||||
<el-button v-if="hasAdminRole('admin')" :loading="replayLoading" round type="primary" @click="submitReplay">重放处理</el-button>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">Webhook 详情加载中</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="info-card">
|
||||
<p>ID:{{ detail.eventId }} · 平台:{{ detail.platform }} · 类型:{{ formatAdminWebhookEventType(detail.eventType) }}</p>
|
||||
<p>
|
||||
验签:<AdminStatusTag :status="detail.signatureValid ? 'success' : 'failed'" />
|
||||
· 订单:{{ detail.relatedOrderId || '-' }}
|
||||
</p>
|
||||
<p>
|
||||
处理状态:
|
||||
<AdminStatusTag :status="detail.processed ? 'success' : 'failed'" />
|
||||
<span v-if="!detail.processed && detail.processError" class="inline-error">{{ detail.processError }}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="lastReplayResult" class="table-card">
|
||||
<h3>最近一次重放结果</h3>
|
||||
<p class="result-copy">
|
||||
平台订单:{{ lastReplayResult.platformOrderId }} · 事件:{{ formatAdminWebhookEventType(lastReplayResult.eventType) }} · 任务数:{{ lastReplayResult.taskCount }}
|
||||
</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 ID</th>
|
||||
<th>任务号</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in lastReplayResult.tasks" :key="task.taskId">
|
||||
<td>{{ task.taskId }}</td>
|
||||
<td>{{ task.taskNo }}</td>
|
||||
<td><AdminStatusTag :status="task.status" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>Headers</h3>
|
||||
<pre class="json-block">{{ prettyJson(detail.headers) }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>Query</h3>
|
||||
<pre class="json-block">{{ prettyJson(detail.query) }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<h3>Body</h3>
|
||||
<pre class="json-block">{{ prettyJson(detail.body) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel { display: grid; gap: 16px; }
|
||||
.panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.panel-header h1 { margin: 0; color: #1d3555; }
|
||||
.panel-header p { margin: 8px 0 0; color: #64748b; }
|
||||
.info-card,.table-card,.empty-block,.error-copy {
|
||||
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
|
||||
}
|
||||
.error-copy { color: #b42318; }
|
||||
.inline-error { margin-left: 8px; color: #b42318; }
|
||||
.result-copy { margin: 0 0 12px; color: #64748b; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); color: #334155; }
|
||||
.json-block {
|
||||
margin: 12px 0 0;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminWebhookEvents, replayAdminWebhookEvent } from '@/services/admin'
|
||||
import type { AdminPagination, AdminWebhookEventListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
||||
import { adminWebhookProcessedOptions } from '@/utils/admin-options'
|
||||
|
||||
const loading = ref(true)
|
||||
const actionLoadingId = ref<number | null>(null)
|
||||
const errorMessage = ref('')
|
||||
const items = ref<AdminWebhookEventListItem[]>([])
|
||||
const platform = ref('')
|
||||
const processed = ref('')
|
||||
const relatedOrderId = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
async function loadEvents(page = pagination.value.page) {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminWebhookEvents({
|
||||
page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
platform: platform.value.trim(),
|
||||
processed: processed.value.trim(),
|
||||
relatedOrderId: relatedOrderId.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
})
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 webhook 列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReplay(item: AdminWebhookEventListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认重放 webhook #${item.eventId} 吗?`,
|
||||
'确认重放',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续重放',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoadingId.value = item.eventId
|
||||
|
||||
try {
|
||||
await replayAdminWebhookEvent(item.eventId)
|
||||
ElMessage.success('Webhook 已重放')
|
||||
await loadEvents()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重放 webhook 失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>Webhook 日志</h1>
|
||||
<p>查看平台推送处理情况和错误信息。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="platform" class="text-input" placeholder="平台,如 agiso" />
|
||||
<select v-model="processed" class="text-input select-input">
|
||||
<option v-for="option in adminWebhookProcessedOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="relatedOrderId" class="text-input" placeholder="关联订单 ID" />
|
||||
</section>
|
||||
|
||||
<section class="filter-bar">
|
||||
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
||||
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
||||
<el-button round type="primary" @click="() => loadEvents()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="empty-block">Webhook 列表加载中</div>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>平台</th>
|
||||
<th>事件类型</th>
|
||||
<th>验签</th>
|
||||
<th>处理结果</th>
|
||||
<th>订单</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.eventId">
|
||||
<td>
|
||||
<RouterLink :to="`/admin/webhook-events/${item.eventId}`">{{ item.eventId }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ item.platform }}</td>
|
||||
<td>{{ formatAdminWebhookEventType(item.eventType) }}</td>
|
||||
<td><AdminStatusTag :status="item.signatureValid ? 'success' : 'failed'" /></td>
|
||||
<td>
|
||||
<AdminStatusTag :status="item.processed ? 'success' : 'failed'" />
|
||||
<span v-if="!item.processed && item.processError" class="error-inline">{{ item.processError }}</span>
|
||||
</td>
|
||||
<td>{{ item.relatedOrderId || '-' }}</td>
|
||||
<td>{{ formatAdminDateTime(item.createdAt) }}</td>
|
||||
<td>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
:loading="actionLoadingId === item.eventId"
|
||||
link
|
||||
type="primary"
|
||||
@click="submitReplay(item)"
|
||||
>
|
||||
重放
|
||||
</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<AdminPaginationBar
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:loading="loading"
|
||||
@change="loadEvents"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
color: #1d3555;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.error-copy {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.error-inline {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user