This commit is contained in:
yml
2026-04-08 16:30:42 +08:00
commit 313c036845
131 changed files with 22393 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_TARGET=http://127.0.0.1:3000
+1
View File
@@ -0,0 +1 @@
VITE_API_TARGET=http://127.0.0.1:3000
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.ace-tool/
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+64
View File
@@ -0,0 +1,64 @@
# 腾讯浏览器兑换前端
这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责。
## 功能范围
- 提供 `/tx/browser` 单页入口
- 支持 QQ / 微信扫码登录切换
- 支持浏览器会话创建、状态轮询、角色确认、兑换提交
- 支持结果信息展示,以及在后端产物存在时预览截图
## 项目原则
- 保持页面职责单一,只服务腾讯浏览器兑换链路
- 与本地后端接口保持一致,优先保证稳定性和可维护性
- 页面层尽量薄,状态编排、轮询、展示映射分别收口
## 本地开发
安装依赖并启动开发环境:
```bash
cd /Users/yml/codes/order-site-rewrite
npm install
npm run dev
```
构建生产包:
```bash
npm run build
```
开发环境默认代理到本地后端:
```bash
http://localhost:3000
```
如需切换后端地址,启动前设置:
```bash
VITE_API_TARGET=http://你的后端地址 npm run dev
```
## 结构说明
```text
src/
lib/ HTTP 基础设施
router/ 路由
services/ 腾讯接口调用
types/ 腾讯会话 / 活动 / 兑换类型
composables/ 页面编排、轮询、展示映射
components/ 腾讯页面子组件
views/ 页面壳
styles/ 全局样式
```
## 当前状态
- 主页面已经拆成扫码卡片、兑换面板、结果面板三个组件
- 会话轮询、错误提示、展示文案已分别从页面模板中抽离
- 前端当前没有全局 store,所有状态都围绕单页流程局部管理
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>自动兑换前端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2170
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "order-site-rewrite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"axios": "^1.9.0",
"element-plus": "^2.10.2",
"vue": "^3.5.30",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"typescript": "~5.9.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.7",
"vue-tsc": "^3.2.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+78
View File
@@ -0,0 +1,78 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminPaginationBar: typeof import('./components/admin/AdminPaginationBar.vue')['default']
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TencentAuthCard: typeof import('./components/tencent/TencentAuthCard.vue')['default']
TencentRedeemPanel: typeof import('./components/tencent/TencentRedeemPanel.vue')['default']
TencentResultPanel: typeof import('./components/tencent/TencentResultPanel.vue')['default']
}
}
@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
page: number
pageSize: number
total: number
loading?: boolean
}>()
const emit = defineEmits<{
(event: 'change', page: number): void
}>()
function goPrev() {
if (props.page <= 1 || props.loading) {
return
}
emit('change', props.page - 1)
}
function goNext() {
if (props.loading || props.page * props.pageSize >= props.total) {
return
}
emit('change', props.page + 1)
}
</script>
<template>
<footer class="pagination-bar">
<span> {{ page }} {{ Math.max(1, Math.ceil(total / pageSize)) }} 合计 {{ total }} </span>
<div class="actions">
<el-button :disabled="page <= 1 || loading" round @click="goPrev">上一页</el-button>
<el-button :disabled="page * pageSize >= total || loading" round @click="goNext">下一页</el-button>
</div>
</footer>
</template>
<style scoped>
.pagination-bar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 14px;
color: #64748b;
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
@media (max-width: 780px) {
.pagination-bar {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { formatStatusWithRaw } from '@/utils/admin-display'
const props = defineProps<{
status: string
}>()
function resolveTone(status: string) {
const normalized = String(status || '').trim().toLowerCase()
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
return 'success'
}
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
return 'primary'
}
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started'].includes(normalized)) {
return 'warning'
}
if (['closed', 'failed', 'unpaid', 'revoked', 'disabled'].includes(normalized)) {
return 'danger'
}
return 'default'
}
function resolveLabel(status: string) {
const normalized = String(status || '').trim()
if (!normalized) {
return '-'
}
const labelMap: Record<string, string> = {
created: '已创建',
paid: '已支付',
unpaid: '未支付',
failed: '失败',
refunded: '已退款',
refunding: '退款中',
closed: '已关闭',
pending_payment: '待支付',
waiting_inventory: '待库存',
cdk_reserved: '已预占',
link_generated: '已生成链接',
claimed: '已创建会话',
role_confirmed: '已确认角色',
redeeming: '兑换中',
redeemed: '已兑换',
retry_pending: '待重试',
manual_review: '人工处理',
expired: '已过期',
available: '可用',
reserved: '已预占',
delivered: '已发放',
invalid: '已作废',
active: '生效中',
admin: '管理员',
operator: '普通运营',
revoked: '已撤销',
disabled: '已停用',
success: '成功',
processing: '处理中',
system_bound: '系统已绑定',
pending_binding: '待系统绑定',
not_started: '未开始',
waiting_user_claim: '待用户打开链接',
link_opened: '已打开链接',
binding_confirmed: '已提交绑定',
binding_in_progress: '绑定处理中',
binding_completed: '完整绑定成功',
binding_exception: '绑定异常',
user_binding: '用户绑定中',
}
return formatStatusWithRaw(normalized, labelMap)
}
</script>
<template>
<span class="status-tag" :data-tone="resolveTone(props.status)">
{{ resolveLabel(props.status) }}
</span>
</template>
<style scoped>
.status-tag {
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;
}
.status-tag[data-tone='success'] {
color: #0f766e;
background: #ecfdf3;
border-color: #a6f4c5;
}
.status-tag[data-tone='primary'] {
color: #175cd3;
background: #eff8ff;
border-color: #b2ddff;
}
.status-tag[data-tone='warning'] {
color: #b54708;
background: #fffaeb;
border-color: #fedf89;
}
.status-tag[data-tone='danger'] {
color: #b42318;
background: #fef3f2;
border-color: #fecdca;
}
.status-tag[data-tone='default'] {
color: #475467;
background: #f2f4f7;
border-color: #d0d5dd;
}
</style>
@@ -0,0 +1,389 @@
<script setup lang="ts">
import type { TencentLoginType } from '@/types/tencent/session'
type LoginTab = {
value: TencentLoginType
label: string
}
const props = defineProps<{
currentLoginTabLabel: string
hasSession: boolean
initButtonLabel: string
loginTabs: LoginTab[]
loginType: TencentLoginType
loginTypeLabel: string
qrFigureStyle: { width: string; maxWidth: string }
qrImage: string
scanInstruction: string
sessionStatus?: string
sessionId: string
sessionLoading: boolean
sessionNotice: string
}>()
const emit = defineEmits<{
createSession: [loginType: TencentLoginType]
openQrPreview: []
qrImageLoad: [event: Event]
reloadSession: []
switchLoginType: [loginType: TencentLoginType]
}>()
</script>
<template>
<section class="auth-card">
<div class="login-tabs">
<button
v-for="tab in props.loginTabs"
:key="tab.value"
:class="['login-tab', { active: props.loginType === tab.value }]"
type="button"
@click="emit('switchLoginType', tab.value)"
>
{{ tab.label }}
</button>
</div>
<div class="qr-stage">
<button
v-if="props.qrImage && props.sessionStatus === 'waiting_scan'"
class="qr-figure"
:style="props.qrFigureStyle"
type="button"
@click="emit('openQrPreview')"
>
<img
:src="props.qrImage"
:alt="`Tencent ${props.loginTypeLabel} Login QR`"
decoding="async"
@load="emit('qrImageLoad', $event)"
/>
</button>
<div v-else-if="props.sessionStatus === 'scanned'" class="status-state confirm-state">
<strong>确认中</strong>
<p>扫码已完成请在手机上点击确认登录确认后页面会继续同步角色信息</p>
</div>
<div
v-else-if="['logged_in', 'ready_to_redeem', 'redeeming', 'redeemed'].includes(String(props.sessionStatus || ''))"
class="status-state success-state"
>
<strong>已完成登录</strong>
<p>二维码已自动收起当前会话已登录正在同步角色或等待后续兑换操作</p>
</div>
<div v-else class="qr-empty">
<strong>{{ props.hasSession ? '二维码生成中' : props.currentLoginTabLabel }}</strong>
<p>
{{
props.hasSession
? '浏览器会话已启动,等待二维码加载。'
: `先选择${props.loginTypeLabel},再点击下方按钮开始初始化。`
}}
</p>
</div>
</div>
<div class="auth-copy">
<p class="scan-hint">
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
</p>
<p class="scan-note">
{{
props.sessionNotice ||
(props.hasSession
? `请使用${props.loginTypeLabel}扫码登录`
: `点击“${props.initButtonLabel}”后,页面才会真正创建浏览器会话`)
}}
</p>
</div>
<div class="auth-footer">
<div class="session-strip">
<span>当前会话</span>
<strong>{{ props.sessionId || '尚未创建' }}</strong>
</div>
<div class="action-row">
<el-button
:loading="props.sessionLoading"
class="primary-action"
round
size="large"
type="primary"
@click="emit('createSession', props.loginType)"
>
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
</el-button>
<el-button
:disabled="!props.hasSession"
:loading="props.sessionLoading"
class="secondary-action"
round
size="large"
@click="emit('reloadSession')"
>
刷新后端页面
</el-button>
</div>
</div>
</section>
</template>
<style scoped>
.auth-card {
display: flex;
flex-direction: column;
height: 100%;
padding: 22px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.login-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding: 0;
border-radius: 16px;
overflow: hidden;
border: 1px solid #dbe4ee;
background: #f8fafc;
}
.login-tab {
min-height: 60px;
padding: 0 16px;
border: none;
background: transparent;
color: #606f84;
cursor: pointer;
font-size: 17px;
font-weight: 700;
transition:
background-color 180ms ease,
color 180ms ease,
box-shadow 180ms ease;
}
.login-tab.active {
color: #ffffff;
background: linear-gradient(180deg, #6ba5f7 0%, #5b8ff0 100%);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.12);
}
.qr-stage {
margin-top: 18px;
padding: 18px 16px;
border-radius: 20px;
border: 1px solid rgba(86, 108, 138, 0.08);
background:
linear-gradient(180deg, rgba(245, 248, 252, 0.98), rgba(252, 253, 255, 0.96)),
radial-gradient(circle at top, rgba(101, 146, 233, 0.08), transparent 48%);
display: grid;
place-items: center;
}
.qr-figure {
display: block;
width: min(220px, 100%);
padding: 0;
border: none;
background: transparent;
cursor: zoom-in;
}
.qr-figure img {
display: block;
width: 100%;
height: auto;
border-radius: 16px;
border: 1px solid #d9e2ef;
box-shadow: 0 10px 24px rgba(31, 50, 79, 0.08);
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.qr-empty {
width: min(220px, 100%);
min-height: 220px;
padding: 18px;
border-radius: 16px;
border: 1px dashed #d4ddeb;
background: rgba(247, 250, 254, 0.92);
display: grid;
place-items: center;
text-align: center;
}
.qr-empty strong {
font-size: 18px;
color: #27384f;
}
.qr-empty p {
margin: 10px 0 0;
color: #67778d;
font-size: 14px;
line-height: 1.6;
}
.status-state {
width: min(220px, 100%);
min-height: 220px;
padding: 18px;
border-radius: 16px;
display: grid;
place-items: center;
text-align: center;
}
.status-state strong {
font-size: 20px;
}
.status-state p {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.7;
}
.confirm-state {
border: 1px solid #f9c74f;
background: linear-gradient(180deg, rgba(255, 247, 214, 0.95), rgba(255, 251, 235, 0.98));
}
.confirm-state strong {
color: #9a3412;
}
.confirm-state p {
color: #b45309;
}
.success-state {
border: 1px solid #86efac;
background: linear-gradient(180deg, rgba(220, 252, 231, 0.96), rgba(240, 253, 244, 0.98));
}
.success-state strong {
color: #166534;
}
.success-state p {
color: #15803d;
}
.auth-copy {
margin-top: 18px;
}
.scan-hint {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #243852;
}
.scan-note {
margin: 8px 0 0;
color: #64748b;
line-height: 1.7;
}
.auth-footer {
margin-top: auto;
padding-top: 20px;
}
.session-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 46px;
padding: 0 16px;
border-radius: 16px;
background: #f6f9fd;
border: 1px solid #e2e9f2;
}
.session-strip span {
color: #64748b;
font-size: 13px;
}
.session-strip strong {
display: block;
min-width: 0;
flex: 1;
text-align: right;
color: #22354e;
font-size: 13px;
font-family: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-row {
margin-top: 16px;
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(132px, 0.95fr);
gap: 10px;
}
.primary-action :deep(span) {
font-weight: 700;
}
.action-row :deep(.el-button) {
width: 100%;
min-width: 0;
margin: 0;
}
.action-row :deep(.el-button > span) {
white-space: nowrap;
}
.primary-action,
.secondary-action {
min-width: 0;
}
@media (max-width: 760px) {
.auth-card {
padding: 18px;
}
.login-tab {
font-size: 15px;
min-height: 54px;
}
.scan-hint {
font-size: 15px;
}
.session-strip,
.action-row {
flex-direction: column;
align-items: flex-start;
}
.action-row {
grid-template-columns: 1fr;
}
.action-row :deep(.el-button) {
width: 100%;
}
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
type FactItem = {
label: string
value: string
accent?: boolean
}
const props = defineProps<{
canRedeem: boolean
canConfirmRole?: boolean
confirmActionLabel?: string
confirmActionLoading?: boolean
confirmActionVisible?: boolean
confirmReadonly?: boolean
hideRedeemForm?: boolean
loginTypeLabel: string
maxAttempts: number
redeemBlockedReason: string
redeemButtonLabel: string
redeemCode: string
redeemLoading: boolean
roleConfirmed: boolean
roleFacts: FactItem[]
roleReady: boolean
statusLabel: string
}>()
const emit = defineEmits<{
confirmRole: []
redeem: []
'update:maxAttempts': [value: number]
'update:redeemCode': [value: string]
'update:roleConfirmed': [value: boolean]
}>()
</script>
<template>
<section class="panel-card">
<div class="section-head">
<strong>角色与兑换</strong>
</div>
<div class="inline-summary">
<div class="inline-summary-item">
<span>会话状态</span>
<strong>{{ props.statusLabel }}</strong>
</div>
<div class="inline-summary-item">
<span>登录方式</span>
<strong>{{ props.loginTypeLabel }}</strong>
</div>
</div>
<div class="fact-grid role-grid">
<article
v-for="fact in props.roleFacts"
:key="fact.label"
:class="['fact-card', { accent: fact.accent }]"
>
<span>{{ fact.label }}</span>
<strong>{{ fact.value }}</strong>
</article>
</div>
<div v-if="props.confirmActionVisible" class="warning-action-box">
<div class="warning-copy">
<strong>请先确认当前角色与大区准确无误</strong>
<p>确认后将按当前识别到的角色继续兑换选错角色可能导致发放错误</p>
</div>
<el-button
:disabled="!props.canConfirmRole"
:loading="props.confirmActionLoading"
class="warning-action-button"
round
size="large"
type="danger"
@click="emit('confirmRole')"
>
{{ props.confirmActionLabel || '确认当前角色' }}
</el-button>
</div>
<div v-else class="confirm-box">
<el-checkbox
:model-value="props.roleConfirmed"
:disabled="props.confirmReadonly || !props.roleReady"
@update:model-value="emit('update:roleConfirmed', Boolean($event))"
>
我已确认当前角色与大区无误
</el-checkbox>
</div>
<el-form v-if="!props.hideRedeemForm" label-position="top" class="redeem-form">
<el-form-item label="兑换码">
<el-input
:model-value="props.redeemCode"
placeholder="请输入兑换码"
@update:model-value="emit('update:redeemCode', String($event))"
/>
</el-form-item>
<div class="field-row compact">
<el-form-item label="验证码重试次数">
<el-input-number
:max="12"
:min="1"
:model-value="props.maxAttempts"
@update:model-value="emit('update:maxAttempts', Number($event || 1))"
/>
</el-form-item>
</div>
</el-form>
<div class="redeem-actions">
<el-button
:disabled="!props.canRedeem"
:loading="props.redeemLoading"
class="redeem-button"
round
size="large"
type="success"
@click="emit('redeem')"
>
{{ props.redeemButtonLabel }}
</el-button>
</div>
<p class="helper-copy">
{{ props.redeemBlockedReason || '当前状态允许直接发起兑换' }}
</p>
</section>
</template>
<style scoped>
.panel-card {
height: 100%;
padding: 22px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head strong {
display: block;
font-size: 16px;
color: #22354e;
}
.inline-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.inline-summary-item {
padding: 12px 14px;
border-radius: 14px;
background: #f7f9fc;
}
.inline-summary-item span,
.fact-card span {
display: block;
font-size: 12px;
color: #708096;
letter-spacing: 0.02em;
}
.inline-summary-item strong,
.fact-card strong {
display: block;
margin-top: 6px;
color: #1f324a;
line-height: 1.5;
word-break: break-word;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.role-grid {
margin-top: 12px;
}
.fact-card {
padding: 14px;
border-radius: 14px;
background: #f7f9fc;
}
.fact-card.accent {
background: linear-gradient(180deg, rgba(86, 143, 244, 0.14), rgba(86, 143, 244, 0.08));
}
.confirm-box {
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(95, 150, 245, 0.08);
border: 1px solid rgba(95, 150, 245, 0.14);
}
.warning-action-box {
margin-top: 14px;
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(254, 226, 226, 0.92), rgba(254, 242, 242, 0.96));
border: 1px solid rgba(248, 113, 113, 0.3);
display: grid;
gap: 12px;
}
.warning-copy strong {
display: block;
color: #991b1b;
font-size: 15px;
}
.warning-copy p {
margin: 6px 0 0;
color: #b91c1c;
line-height: 1.7;
font-size: 13px;
}
.warning-action-button {
min-width: 220px;
justify-self: start;
}
.redeem-form {
margin-top: 14px;
}
.field-row.compact {
display: grid;
grid-template-columns: 220px;
gap: 12px;
}
.redeem-actions {
margin-top: 16px;
display: grid;
gap: 10px;
}
.redeem-button {
min-width: 220px;
}
.helper-copy {
margin: 12px 0 0;
color: #64748b;
line-height: 1.7;
}
@media (max-width: 1140px) {
.inline-summary,
.fact-grid,
.role-grid,
.field-row.compact {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.panel-card {
padding: 18px;
}
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
type FactItem = {
label: string
value: string
}
const props = defineProps<{
resultFacts: FactItem[]
screenshotEmptyMessage: string
screenshotEmptyTitle: string
screenshotUrl: string
showScreenshot: boolean
}>()
const emit = defineEmits<{
openScreenshotPreview: []
}>()
</script>
<template>
<section class="result-card">
<div class="section-head">
<strong>兑换结果</strong>
</div>
<div class="result-layout">
<div class="fact-grid result-grid">
<article v-for="fact in props.resultFacts" :key="fact.label" class="fact-card">
<span>{{ fact.label }}</span>
<strong>{{ fact.value }}</strong>
</article>
</div>
<button
v-if="props.showScreenshot"
class="screenshot-figure"
type="button"
@click="emit('openScreenshotPreview')"
>
<img :src="props.screenshotUrl" alt="Tencent redeem screenshot" />
</button>
<div v-else class="empty-block">
<strong>{{ props.screenshotEmptyTitle }}</strong>
<p>{{ props.screenshotEmptyMessage }}</p>
</div>
</div>
</section>
</template>
<style scoped>
.result-card {
padding: 22px 22px 20px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head strong {
display: block;
font-size: 16px;
color: #22354e;
}
.result-layout {
display: grid;
gap: 14px;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.fact-card {
padding: 14px;
border-radius: 14px;
background: #f7f9fc;
}
.fact-card span {
display: block;
font-size: 12px;
color: #708096;
letter-spacing: 0.02em;
}
.fact-card strong {
display: block;
margin-top: 6px;
color: #1f324a;
line-height: 1.5;
word-break: break-word;
}
.screenshot-figure {
display: block;
width: 100%;
padding: 0;
border: 1px solid #e2e9f2;
border-radius: 22px;
overflow: hidden;
background: #f4f8fc;
cursor: zoom-in;
}
.screenshot-figure img {
display: block;
width: 100%;
height: auto;
}
.empty-block {
margin-top: 14px;
min-height: 180px;
border-radius: 18px;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
background: #f8fbff;
border: 1px dashed #d5dfec;
}
.empty-block strong {
color: #2c4058;
font-size: 16px;
}
.empty-block p {
margin: 8px 0 0;
color: #6b7a91;
line-height: 1.7;
}
@media (max-width: 1140px) {
.fact-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.result-card {
padding: 18px;
}
}
</style>
@@ -0,0 +1,48 @@
import { ElMessage } from 'element-plus'
export type TencentActionError = Error & {
errorCode?: string
status?: number
}
export function notifyTencentActionError(error: unknown, fallbackMessage: string) {
const message = resolveTencentActionMessage(error, fallbackMessage)
console.error(error)
ElMessage.error(message)
}
export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) {
const normalizedError = error as TencentActionError
const errorCode = String(normalizedError?.errorCode || '').trim()
const message = normalizedError instanceof Error ? normalizedError.message.trim() : ''
if (errorCode === 'missing_redeem_code') {
return '请输入兑换码'
}
if (errorCode === 'session_not_ready') {
return '当前会话还不能兑换,请先完成扫码并确认角色信息'
}
if (errorCode === 'session_not_found' || errorCode === 'not_found') {
return '当前会话不存在或已过期,请重新生成二维码'
}
if (errorCode === 'session_closed' || errorCode === 'gone') {
return '当前会话已关闭,请重新生成二维码'
}
if (errorCode === 'dependency_unavailable') {
return '后端依赖暂不可用,请检查浏览器或 OCR 服务是否已就绪'
}
if (errorCode === 'invalid_request') {
return '请求参数不完整,请检查输入后重试'
}
if (errorCode === 'conflict') {
return '当前会话状态已变化,请刷新后重试'
}
return message || fallbackMessage
}
@@ -0,0 +1,196 @@
import { computed, ref, type Ref } from 'vue'
import {
fetchTencentBrowserSession,
fetchTencentBrowserSessionSummary,
} from '@/services/tencent/session'
import type {
TencentBrowserSessionData,
TencentBrowserSessionStatus,
TencentBrowserSessionSummaryData,
} from '@/types/tencent/session'
import { resolveTencentActionMessage } from './session-errors'
const POLL_INTERVAL_MS = 2_500
const POLL_FAILURE_LIMIT = 3
export const ACTIVE_TENCENT_SESSION_STATUSES = new Set([
'waiting_scan',
'scanned',
'logged_in',
'ready_to_redeem',
'redeeming',
])
export function useTencentBrowserSessionPolling(options: {
session: Ref<TencentBrowserSessionData | null>
sessionLoading: Ref<boolean>
notifyActionError: (error: unknown, fallbackMessage: string) => void
}) {
const { session, sessionLoading, notifyActionError } = options
const pollFailureCount = ref(0)
const pollWarningMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollToken = 0
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || '')
async function refreshSession({ silent = false } = {}) {
if (!session.value?.sessionId) {
return false
}
if (!silent) {
sessionLoading.value = true
resetPollingWarning()
}
try {
const currentSession = session.value
const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId)
if (response.code !== 0) {
throw new Error(response.msg || '获取浏览器会话状态失败')
}
const nextSession = mergeSessionData(currentSession, response.data)
session.value = nextSession
if (shouldRefreshQrImage(currentSession, response.data)) {
const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId)
if (fullResponse.code !== 0) {
throw new Error(fullResponse.msg || '获取浏览器会话二维码失败')
}
session.value = mergeSessionData(nextSession, fullResponse.data)
}
if (silent) {
resetPollingWarning()
}
return true
} catch (error) {
if (silent) {
handleSilentPollingError(error)
} else {
notifyActionError(error, '获取浏览器会话状态失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
function startPolling() {
const token = ++pollToken
const loop = async () => {
if (token !== pollToken || !session.value?.sessionId) {
return
}
await refreshSession({ silent: true })
if (token !== pollToken || !isActiveTencentSessionStatus(session.value?.status)) {
return
}
pollTimer = setTimeout(() => {
void loop()
}, POLL_INTERVAL_MS)
}
pollTimer = setTimeout(() => {
void loop()
}, POLL_INTERVAL_MS)
}
function resetPolling() {
pollToken += 1
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function resetPollingWarning() {
pollFailureCount.value = 0
pollWarningMessage.value = ''
}
function restartPollingIfActive(nextSession = session.value) {
if (isActiveTencentSessionStatus(nextSession?.status)) {
startPolling()
}
}
function handleSilentPollingError(error: unknown) {
console.error(error)
pollFailureCount.value += 1
if (pollFailureCount.value < POLL_FAILURE_LIMIT) {
return
}
pollWarningMessage.value = `${resolveTencentActionMessage(error, '会话状态刷新失败')},已暂停自动轮询,请手动刷新或重新生成二维码。`
resetPolling()
}
return {
refreshSession,
resetPolling,
resetPollingWarning,
restartPollingIfActive,
sessionNotice,
startPolling,
}
}
export function isActiveTencentSessionStatus(status: TencentBrowserSessionStatus | null | undefined) {
return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status))
}
function mergeSessionData(
current: TencentBrowserSessionData | null,
next: TencentBrowserSessionSummaryData | TencentBrowserSessionData,
) {
if (!current) {
return {
...next,
qrImageBase64: next.qrImageBase64 || '',
}
}
return {
...current,
...next,
qrImageBase64:
typeof next.qrImageBase64 === 'string' ? next.qrImageBase64 : current.qrImageBase64,
activityInfo: next.activityInfo ?? current.activityInfo ?? null,
redeem: next.redeem ?? current.redeem ?? null,
artifacts: next.artifacts || current.artifacts,
}
}
function shouldRefreshQrImage(
current: TencentBrowserSessionData,
next: TencentBrowserSessionSummaryData,
) {
if (!next.artifacts?.hasQrImage) {
return false
}
if (!current.qrImageBase64) {
return true
}
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
}
@@ -0,0 +1,208 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import { buildTencentBrowserSessionScreenshotUrl } from '@/services/tencent/artifacts'
import type { TencentBrowserActivityInfo } from '@/types/tencent/activity'
import type { TencentBrowserSessionData } from '@/types/tencent/session'
const REDEEM_ALLOWED_STATUSES = new Set(['logged_in', 'ready_to_redeem', 'redeemed'])
export function useTencentBrowserSessionPresentation(options: {
activityInfo: ComputedRef<TencentBrowserActivityInfo | null>
hasSession: ComputedRef<boolean>
loginTypeLabel: ComputedRef<string>
redeemLoading: Ref<boolean>
roleConfirmed: Ref<boolean>
roleReady: ComputedRef<boolean>
session: Ref<TencentBrowserSessionData | null>
sessionLoading: Ref<boolean>
sessionNotice: ComputedRef<string>
}) {
const {
activityInfo,
hasSession,
loginTypeLabel,
redeemLoading,
roleConfirmed,
roleReady,
session,
sessionLoading,
sessionNotice,
} = options
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`)
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
)
const screenshotUrl = computed(() => {
if (!session.value?.sessionId || !session.value?.artifacts?.hasScreenshot) {
return ''
}
return buildTencentBrowserSessionScreenshotUrl(session.value.sessionId, session.value.updatedAt)
})
const statusLabel = computed(() => {
if (!hasSession.value) {
return '待初始化'
}
switch (session.value?.status) {
case 'waiting_scan':
return '等待扫码'
case 'scanned':
return '已扫码待确认'
case 'logged_in':
return '页面已登录'
case 'ready_to_redeem':
return '可以兑换'
case 'redeeming':
return '兑换中'
case 'redeemed':
return '已完成'
case 'failed':
return '会话异常'
default:
return '初始化中'
}
})
const canRedeem = computed(() => {
if (redeemLoading.value || sessionLoading.value || !session.value?.sessionId) {
return false
}
return (
REDEEM_ALLOWED_STATUSES.has(String(session.value.status || '')) &&
roleReady.value &&
roleConfirmed.value
)
})
const redeemBlockedReason = computed(() => {
if (!session.value?.sessionId) {
return `请先选择${loginTypeLabel.value}登录,并手动开始初始化浏览器会话`
}
if (redeemLoading.value) {
return '兑换任务正在执行中'
}
if (sessionLoading.value) {
return '正在刷新浏览器会话状态'
}
if (!roleReady.value) {
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
}
if (!roleConfirmed.value) {
return '请先确认当前角色与大区无误,再开始兑换'
}
if (REDEEM_ALLOWED_STATUSES.has(String(session.value.status || ''))) {
return ''
}
switch (session.value.status) {
case 'waiting_scan':
return `请先使用${loginTypeLabel.value}扫码`
case 'scanned':
return '请在手机上确认登录后再兑换'
case 'failed':
return sessionNotice.value || '浏览器会话异常,请重新生成二维码'
default:
return sessionNotice.value || '当前状态还不能开始兑换'
}
})
const finalRedeemMessage = computed(() => {
const result = session.value?.redeem?.final?.redeem
if (!result || typeof result !== 'object') {
return ''
}
const record = result as Record<string, unknown>
return String(record.sMsg || record.msg || '')
})
const finalRetCode = computed(() => {
const result = session.value?.redeem?.final?.redeem
if (!result || typeof result !== 'object') {
return ''
}
const record = result as Record<string, unknown>
return String(record.iRet ?? '')
})
const roleFacts = computed(() => [
{
label: '登录昵称',
value: activityInfo.value?.nickname || '等待扫码登录',
},
{
label: '当前角色',
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
accent: true,
},
{
label: '角色 ID',
value: activityInfo.value?.role?.roleId || '未识别',
},
{
label: '验证码',
value:
activityInfo.value?.form?.verifyValue ||
(activityInfo.value?.verify?.visible ? '等待识别' : '等待显示'),
},
])
const resultFacts = computed(() => [
{
label: '业务返回码',
value: finalRetCode.value || '-',
},
{
label: '业务消息',
value: finalRedeemMessage.value || '尚未兑换',
},
{
label: 'OCR 尝试次数',
value: String(session.value?.redeem?.attempts.length || 0),
},
{
label: '证明截图',
value: session.value?.artifacts?.hasScreenshot ? '已生成' : '未生成',
},
])
const redeemButtonLabel = computed(() => '开始兑换')
const screenshotEmptyTitle = computed(() =>
session.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
)
const screenshotEmptyMessage = computed(() =>
session.value?.status === 'redeemed'
? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。'
: '如本次兑换生成了结果截图,这里会展示。'
)
return {
canRedeem,
initButtonLabel,
qrImage,
redeemBlockedReason,
redeemButtonLabel,
resultFacts,
roleFacts,
scanInstruction,
screenshotEmptyMessage,
screenshotEmptyTitle,
screenshotUrl,
statusLabel,
}
}
@@ -0,0 +1,525 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
confirmClaimRole,
createClaimSession,
fetchClaimDetail,
fetchClaimSessionSummary,
redeemClaim,
} from '@/services/claim'
import type { ClaimDetailData, ClaimTaskStatus } from '@/types/claim'
import type { TencentLoginType } from '@/types/tencent/session'
import { notifyTencentActionError } from './tencent/session-errors'
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
const POLL_INTERVAL_MS = 2500
const POLL_FAILURE_LIMIT = 3
const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>([
'claimed',
'role_confirmed',
'redeeming',
])
export function useClaimPage(token: string) {
const detailLoading = ref(true)
const sessionLoading = ref(false)
const redeemLoading = ref(false)
const roleConfirmLoading = ref(false)
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
const detail = ref<ClaimDetailData | null>(null)
const roleConfirmed = ref(false)
const pollWarningMessage = ref('')
const qrImageNaturalWidth = ref(0)
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollToken = 0
let pollFailureCount = 0
const session = computed(() => detail.value?.session || null)
const task = computed(() => detail.value?.task || null)
const order = computed(() => detail.value?.order || null)
const orderItem = computed(() => detail.value?.orderItem || null)
const result = computed(() => detail.value?.result || null)
const tokenStatus = computed(() => detail.value?.tokenStatus || 'active')
const activityInfo = computed(() => session.value?.activityInfo || null)
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '')
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
)
const screenshotUrl = computed(() => result.value?.screenshotUrl || '')
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
const loginTabs = [
{ value: 'qq' as const, label: 'QQ账号登录' },
{ value: 'wx' as const, label: '微信账号登录' },
]
const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status))
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`)
const roleFacts = computed(() => [
{
label: '登录昵称',
value: activityInfo.value?.nickname || '等待扫码登录',
},
{
label: '当前角色',
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
accent: true,
},
{
label: '角色 ID',
value: activityInfo.value?.role?.roleId || '未识别',
},
{
label: '任务状态',
value: statusLabel.value,
},
])
const resultFacts = computed(() => [
{
label: '订单号',
value: order.value?.platformOrderId || '-',
},
{
label: '商品',
value: orderItem.value?.skuName || '-',
},
{
label: '业务返回码',
value: result.value?.resultCode || '-',
},
{
label: '业务消息',
value: result.value?.resultMessage || '尚未兑换',
},
])
const canConfirmRole = computed(() =>
Boolean(task.value && hasSession.value && roleReady.value && task.value.status === 'claimed'),
)
const canRedeem = computed(() =>
Boolean(task.value && hasSession.value && roleConfirmed.value && !redeemLoading.value && (
task.value.status === 'role_confirmed' || task.value.status === 'redeeming'
)),
)
const redeemBlockedReason = computed(() => {
if (!detail.value) {
return '正在加载领取信息'
}
if (tokenStatus.value !== 'active') {
return '当前领取链接不可用'
}
if (!hasSession.value) {
return `请先选择${loginTypeLabel.value}并初始化登录会话`
}
if (redeemLoading.value) {
return '兑换任务正在执行中'
}
if (!roleReady.value) {
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
}
if (!roleConfirmed.value) {
return '请先确认当前角色与大区无误,再开始兑换'
}
return ''
})
const redeemButtonLabel = computed(() => '开始兑换')
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成截图,或截图产物还未同步完成。'
: '领取完成后,如本次生成了结果截图,这里会展示。'
)
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
loginType.value = syncLoginTypeFromDetail(nextDetail)
}
watch(
() =>
[
task.value?.taskId || '',
activityInfo.value?.nickname || '',
activityInfo.value?.role?.roleId || '',
activityInfo.value?.role?.roleName || '',
activityInfo.value?.role?.area || '',
activityInfo.value?.role?.partition || '',
].join('|'),
() => {
roleConfirmed.value = Boolean(task.value?.status === 'role_confirmed' || task.value?.status === 'redeeming' || task.value?.status === 'redeemed')
},
{ immediate: true },
)
async function loadDetail() {
detailLoading.value = true
try {
const response = await fetchClaimDetail(token)
if (response.code !== 0) {
throw new Error(response.msg || '领取详情加载失败')
}
detail.value = mergeClaimDetailData(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
restartPollingIfNeeded()
} catch (error) {
notifyTencentActionError(error, '领取详情加载失败')
} finally {
detailLoading.value = false
}
}
async function createSessionFlow(nextLoginType = loginType.value) {
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
loginType.value = nextLoginType
const response = await createClaimSession(token, nextLoginType)
if (response.code !== 0) {
throw new Error(response.msg || '创建领取会话失败')
}
detail.value = mergeClaimDetailData(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
startPolling()
} catch (error) {
notifyTencentActionError(error, '创建领取会话失败')
} finally {
sessionLoading.value = false
}
}
async function refreshSessionSummary({ silent = false } = {}) {
if (!hasSession.value) {
return false
}
if (!silent) {
sessionLoading.value = true
resetPollingWarning()
}
try {
const response = await fetchClaimSessionSummary(token)
if (response.code !== 0) {
throw new Error(response.msg || '领取会话状态刷新失败')
}
detail.value = mergeClaimDetailData(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
if (silent) {
resetPollingWarning()
}
return true
} catch (error) {
if (silent) {
handleSilentPollingError(error)
} else {
notifyTencentActionError(error, '领取会话状态刷新失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
async function confirmRoleNow() {
if (!canConfirmRole.value) {
return
}
roleConfirmLoading.value = true
try {
const response = await confirmClaimRole(token)
if (response.code !== 0) {
throw new Error(response.msg || '角色确认失败')
}
detail.value = mergeClaimDetailData(detail.value, response.data)
roleConfirmed.value = true
ElMessage.success(response.msg || '角色已确认')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '角色确认失败')
await refreshSessionSummary({ silent: true })
} finally {
roleConfirmLoading.value = false
}
}
async function redeemNow() {
if (!canRedeem.value) {
return
}
redeemLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await redeemClaim(token)
if (response.code !== 0) {
throw new Error(response.msg || '领取兑换失败')
}
detail.value = mergeClaimDetailData(detail.value, response.data)
ElMessage.success(response.msg || '兑换完成')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '领取兑换失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
} finally {
redeemLoading.value = false
}
}
async function switchLoginType(nextLoginType: TencentLoginType) {
if (nextLoginType === loginType.value) {
return
}
loginType.value = nextLoginType
if (hasSession.value) {
await createSessionFlow(nextLoginType)
}
}
function startPolling() {
const tokenId = ++pollToken
const loop = async () => {
if (tokenId !== pollToken || !hasSession.value) {
return
}
await refreshSessionSummary({ silent: true })
if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) {
return
}
pollTimer = setTimeout(() => {
void loop()
}, POLL_INTERVAL_MS)
}
pollTimer = setTimeout(() => {
void loop()
}, POLL_INTERVAL_MS)
}
function restartPollingIfNeeded(nextDetail = detail.value) {
resetPolling()
if (shouldKeepPolling(nextDetail)) {
startPolling()
}
}
function resetPolling() {
pollToken += 1
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function resetPollingWarning() {
pollFailureCount = 0
pollWarningMessage.value = ''
}
function handleSilentPollingError(error: unknown) {
console.error(error)
pollFailureCount += 1
if (pollFailureCount < POLL_FAILURE_LIMIT) {
return
}
pollWarningMessage.value = '领取状态刷新失败,已暂停自动轮询,请手动刷新页面。'
resetPolling()
}
function handleQrImageLoad(event: Event) {
const target = event.target
if (!(target instanceof HTMLImageElement)) {
return
}
qrImageNaturalWidth.value = target.naturalWidth || 0
}
const qrDisplayWidth = computed(() => {
const naturalWidth = qrImageNaturalWidth.value
if (!naturalWidth) {
return 220
}
if (naturalWidth < 160) {
return Math.min(naturalWidth * 2, 220)
}
return Math.min(naturalWidth, 240)
})
const qrFigureStyle = computed(() => ({
width: `${qrDisplayWidth.value}px`,
maxWidth: '100%',
}))
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
loadDetail()
onBeforeUnmount(() => {
resetPolling()
})
return {
detailLoading,
sessionLoading,
redeemLoading,
roleConfirmLoading,
loginType,
loginTypeLabel,
loginTabs,
detail,
task,
order,
orderItem,
activityInfo,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
refreshSessionSummary,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
}
}
function syncLoginTypeFromDetail(detail: ClaimDetailData) {
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
return nextLoginType === 'wx' ? 'wx' : 'qq'
}
function shouldKeepPolling(detail: ClaimDetailData | null) {
if (!detail?.session?.sessionId) {
return false
}
return ACTIVE_TASK_STATUSES.has(detail.task.status)
}
function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) {
switch (taskStatus) {
case 'link_generated':
return '等待开始领取'
case 'claimed':
if (sessionStatus === 'scanned') {
return '确认中'
}
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
return '已登录'
}
return '领取中'
case 'role_confirmed':
return '角色已确认'
case 'redeeming':
return '正在兑换'
case 'redeemed':
return '兑换成功'
case 'waiting_inventory':
return '等待库存'
case 'retry_pending':
return '等待重试'
case 'manual_review':
return '等待人工处理'
case 'expired':
return '链接已过期'
case 'closed':
return '任务已关闭'
default:
return sessionStatus || taskStatus || '等待中'
}
}
function mergeClaimDetailData(current: ClaimDetailData | null, next: ClaimDetailData) {
if (!current?.session) {
return next
}
if (!next.session) {
return {
...next,
session: current.session,
}
}
return {
...next,
session: {
...current.session,
...next.session,
qrImageBase64:
typeof next.session.qrImageBase64 === 'string'
? next.session.qrImageBase64
: current.session.qrImageBase64,
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
redeem: next.session.redeem ?? current.session.redeem ?? null,
artifacts: next.session.artifacts || current.session.artifacts,
},
}
}
@@ -0,0 +1,240 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
createTencentBrowserSession,
refreshTencentBrowserSessionPage,
removeTencentBrowserSession,
} from '@/services/tencent/session'
import { redeemTencentBrowserSession } from '@/services/tencent/redeem'
import type { TencentBrowserSessionData, TencentLoginType } from '@/types/tencent/session'
import { notifyTencentActionError } from './tencent/session-errors'
import { useTencentBrowserSessionPolling } from './tencent/useTencentBrowserSessionPolling'
import { useTencentBrowserSessionPresentation } from './tencent/useTencentBrowserSessionPresentation'
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
export function useTencentBrowserSessionPage() {
const sessionLoading = ref(false)
const redeemLoading = ref(false)
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
const session = ref<TencentBrowserSessionData | null>(null)
const redeemCode = ref('')
const maxAttempts = ref(6)
const roleConfirmed = ref(false)
const activityInfo = computed(() => session.value?.activityInfo || null)
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const loginTabs = [
{ value: 'qq' as const, label: 'QQ账号登录' },
{ value: 'wx' as const, label: '微信账号登录' },
]
const {
refreshSession,
resetPolling,
resetPollingWarning,
restartPollingIfActive,
sessionNotice,
startPolling,
} = useTencentBrowserSessionPolling({
session,
sessionLoading,
notifyActionError: notifyTencentActionError,
})
const {
canRedeem,
initButtonLabel,
qrImage,
redeemBlockedReason,
redeemButtonLabel,
resultFacts,
roleFacts,
scanInstruction,
screenshotEmptyMessage,
screenshotEmptyTitle,
screenshotUrl,
statusLabel,
} = useTencentBrowserSessionPresentation({
activityInfo,
hasSession,
loginTypeLabel,
redeemLoading,
roleConfirmed,
roleReady,
session,
sessionLoading,
sessionNotice,
})
watch(
() =>
[
session.value?.sessionId || '',
activityInfo.value?.nickname || '',
activityInfo.value?.role?.roleId || '',
activityInfo.value?.role?.roleName || '',
activityInfo.value?.role?.area || '',
activityInfo.value?.role?.partition || '',
].join('|'),
() => {
roleConfirmed.value = false
},
)
async function createSessionFlow(nextLoginType = loginType.value) {
resetPolling()
resetPollingWarning()
sessionLoading.value = true
try {
loginType.value = nextLoginType
await teardownSession()
const response = await createTencentBrowserSession({
loginType: nextLoginType,
})
if (response.code !== 0) {
throw new Error(response.msg || '创建浏览器会话失败')
}
session.value = response.data
startPolling()
} catch (error) {
notifyTencentActionError(error, '创建浏览器会话失败')
} finally {
sessionLoading.value = false
}
}
async function reloadSessionPage() {
if (!session.value?.sessionId) {
return
}
resetPolling()
resetPollingWarning()
sessionLoading.value = true
try {
const response = await refreshTencentBrowserSessionPage(session.value.sessionId)
if (response.code !== 0) {
throw new Error(response.msg || '刷新后端页面失败')
}
session.value = response.data
restartPollingIfActive(response.data)
} catch (error) {
notifyTencentActionError(error, '刷新后端页面失败')
await refreshSession({ silent: true })
restartPollingIfActive()
} finally {
sessionLoading.value = false
}
}
async function switchLoginType(nextLoginType: TencentLoginType) {
if (nextLoginType === loginType.value) {
return
}
loginType.value = nextLoginType
if (session.value?.sessionId) {
await createSessionFlow(nextLoginType)
}
}
async function redeemNow() {
if (!session.value?.sessionId) {
ElMessage.error('请先创建浏览器会话')
return
}
if (!redeemCode.value.trim()) {
ElMessage.error('请输入兑换码')
return
}
redeemLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await redeemTencentBrowserSession(session.value.sessionId, {
code: redeemCode.value.trim(),
maxAttempts: maxAttempts.value,
})
if (response.code !== 0) {
throw new Error(response.msg || '浏览器兑换失败')
}
session.value = response.data
ElMessage.success(response.msg || '兑换完成')
} catch (error) {
notifyTencentActionError(error, '浏览器兑换失败')
await refreshSession({ silent: true })
} finally {
redeemLoading.value = false
}
}
async function teardownSession() {
if (!session.value?.sessionId) {
return
}
const sessionId = session.value.sessionId
session.value = null
try {
await removeTencentBrowserSession(sessionId)
} catch {
// ignore close failures on local teardown
}
}
onBeforeUnmount(() => {
resetPolling()
void teardownSession()
})
return {
activityInfo,
canRedeem,
createSessionFlow,
hasSession,
initButtonLabel,
loginTabs,
loginType,
loginTypeLabel,
maxAttempts,
qrImage,
redeemBlockedReason,
redeemButtonLabel,
redeemCode,
redeemLoading,
redeemNow,
reloadSessionPage,
resultFacts,
roleConfirmed,
roleFacts,
scanInstruction,
screenshotEmptyMessage,
screenshotEmptyTitle,
screenshotUrl,
session,
sessionLoading,
sessionNotice,
statusLabel,
switchLoginType,
}
}
+101
View File
@@ -0,0 +1,101 @@
import axios from 'axios'
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
export interface ApiEnvelope<T> {
code: number
msg: string
data: T
errorCode?: string
}
const http = axios.create({
baseURL: '/',
timeout: 50_000,
headers: {
'Content-Type': 'application/json',
},
})
http.interceptors.response.use(
(response) => response.data,
(error) => {
const responseMessage =
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
const fallbackMessage = resolveFallbackHttpMessage(error)
const normalizedError = new Error(responseMessage || fallbackMessage) as Error & {
errorCode?: string
status?: number
}
if (error?.response?.data?.errorCode) {
normalizedError.errorCode = String(error.response.data.errorCode)
}
if (typeof error?.response?.status === 'number') {
normalizedError.status = error.response.status
}
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) {
const status = Number(error?.response?.status || 0)
if (status === 401) {
clearAdminSession()
if (window.location.hash.startsWith('#/admin') && !window.location.hash.startsWith('#/admin/login')) {
window.location.hash = '#/admin/login'
}
}
}
return Promise.reject(normalizedError)
},
)
http.interceptors.request.use((config) => {
if (String(config.url || '').startsWith('/api/v1/admin')) {
const token = getAdminToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
function resolveFallbackHttpMessage(error: unknown) {
const message = String((error as { message?: string })?.message ?? '')
if (message.includes('timeout')) {
return '网络超时'
}
if (message === 'Network Error') {
return '网络连接错误'
}
if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') {
return String((error as { response: { statusText: string } }).response.statusText).trim()
}
return '接口请求失败'
}
export function apiGet<T>(url: string, params?: Record<string, unknown>) {
return http.get<ApiEnvelope<T>>(url, { params }) as unknown as Promise<ApiEnvelope<T>>
}
export function apiGetBlob(url: string, params?: Record<string, unknown>) {
return http.get<Blob>(url, {
params,
responseType: 'blob',
}) as unknown as Promise<Blob>
}
export function apiPost<T>(url: string, data?: Record<string, unknown>) {
return http.post<ApiEnvelope<T>>(url, data) as unknown as Promise<ApiEnvelope<T>>
}
export function apiDelete<T>(url: string) {
return http.delete<ApiEnvelope<T>>(url) as unknown as Promise<ApiEnvelope<T>>
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/main.css'
const app = createApp(App)
app.use(router)
app.mount('#app')
+108
View File
@@ -0,0 +1,108 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { hasAdminSession } from '@/utils/admin-auth'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/tx/browser',
},
{
path: '/tx',
redirect: '/tx/browser',
},
{
path: '/tx/browser',
component: () => import('@/views/tx/TencentBrowserView.vue'),
},
{
path: '/claim/:token',
component: () => import('@/views/claim/ClaimView.vue'),
},
{
path: '/admin',
redirect: '/admin/dashboard',
},
{
path: '/admin/login',
component: () => import('@/views/admin/AdminLoginView.vue'),
},
{
path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'),
children: [
{
path: '',
redirect: '/admin/dashboard',
},
{
path: 'dashboard',
component: () => import('@/views/admin/AdminDashboardView.vue'),
},
{
path: 'users',
component: () => import('@/views/admin/AdminUsersView.vue'),
},
{
path: 'orders',
component: () => import('@/views/admin/AdminOrdersView.vue'),
},
{
path: 'orders/:orderId',
component: () => import('@/views/admin/AdminOrderDetailView.vue'),
},
{
path: 'tasks',
component: () => import('@/views/admin/AdminTasksView.vue'),
},
{
path: 'tasks/:taskId',
component: () => import('@/views/admin/AdminTaskDetailView.vue'),
},
{
path: 'cdks',
component: () => import('@/views/admin/AdminCdksView.vue'),
},
{
path: 'webhook-events',
component: () => import('@/views/admin/AdminWebhookEventsView.vue'),
},
{
path: 'webhook-events/:eventId',
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
},
{
path: 'audit-logs',
component: () => import('@/views/admin/AdminAuditLogsView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
},
],
})
router.beforeEach((to) => {
if (!to.path.startsWith('/admin')) {
return true
}
if (to.path === '/admin/login') {
if (hasAdminSession()) {
return '/admin/dashboard'
}
return true
}
if (!hasAdminSession()) {
return '/admin/login'
}
return true
})
export default router
+136
View File
@@ -0,0 +1,136 @@
import { apiGet, apiGetBlob, apiPost } from '@/lib/http'
import type {
AdminAuditLogItem,
AdminTaskActionResponse,
AdminCdkListItem,
AdminDashboardSummary,
AdminLoginResponse,
AdminOrderDetail,
AdminOrderListItem,
AdminPagination,
AdminSessionSummary,
AdminTaskDetail,
AdminTaskListItem,
AdminUserListItem,
AdminWebhookEventDetail,
AdminWebhookEventListItem,
AdminWebhookReplayResponse,
} from '@/types/admin'
export function loginAdmin(payload: { username: string; password: string }) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
}
export function fetchAdminSession() {
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
}
export function logoutAdmin() {
return apiPost<{ success: boolean }>('/api/v1/admin/auth/logout', {})
}
export function fetchAdminDashboardSummary() {
return apiGet<AdminDashboardSummary>('/api/v1/admin/dashboard/summary')
}
export function fetchAdminUsers(params?: Record<string, unknown>) {
return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>('/api/v1/admin/users', params)
}
export function createAdminUser(payload: { username: string; password: string; role: string; status?: string }) {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
export function updateAdminUserRole(userId: number | string, payload: { role: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload)
}
export function updateAdminUserStatus(userId: number | string, payload: { status: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload)
}
export function resetAdminUserPassword(userId: number | string, payload: { password: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload)
}
export function fetchAdminAuditLogs(params?: Record<string, unknown>) {
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>('/api/v1/admin/audit-logs', params)
}
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
}
export function fetchAdminOrderDetail(orderId: number | string) {
return apiGet<AdminOrderDetail>(`/api/v1/admin/orders/${orderId}`)
}
export function fetchAdminTasks(params?: Record<string, unknown>) {
return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>('/api/v1/admin/tasks', params)
}
export function fetchAdminTaskDetail(taskId: number | string) {
return apiGet<AdminTaskDetail>(`/api/v1/admin/tasks/${taskId}`)
}
export function fetchAdminTaskScreenshot(taskId: number | string) {
return apiGetBlob(`/api/v1/admin/tasks/${taskId}/screenshot`)
}
export function retryAdminTask(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
}
export function releaseAdminTaskCdk(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-cdk`, {})
}
export function regenerateAdminTaskClaimLink(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`,
{},
)
}
export function closeAdminTask(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/close`, {})
}
export function markAdminTaskManualReview(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {})
}
export function fetchAdminCdks(params?: Record<string, unknown>) {
return apiGet<{ items: AdminCdkListItem[]; pagination: AdminPagination }>('/api/v1/admin/cdks', params)
}
export function importAdminCdks(payload: Record<string, unknown>) {
return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/cdks/import', payload)
}
export function createAdminCdk(payload: Record<string, unknown>) {
return apiPost<{ cdk: AdminCdkListItem | null }>('/api/v1/admin/cdks', payload)
}
export function releaseAdminInventoryCdk(cdkId: number | string) {
return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/release`, {})
}
export function invalidateAdminCdk(cdkId: number | string, payload?: Record<string, unknown>) {
return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/invalidate`, payload || {})
}
export function fetchAdminWebhookEvents(params?: Record<string, unknown>) {
return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/webhook-events',
params,
)
}
export function fetchAdminWebhookEventDetail(eventId: number | string) {
return apiGet<AdminWebhookEventDetail>(`/api/v1/admin/webhook-events/${eventId}`)
}
export function replayAdminWebhookEvent(eventId: number | string) {
return apiPost<AdminWebhookReplayResponse>(`/api/v1/admin/webhook-events/${eventId}/replay`, {})
}
+23
View File
@@ -0,0 +1,23 @@
import { apiGet, apiPost } from '@/lib/http'
import type { ClaimDetailData } from '@/types/claim'
import type { TencentLoginType } from '@/types/tencent/session'
export function fetchClaimDetail(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}`)
}
export function createClaimSession(token: string, loginType: TencentLoginType) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session`, { loginType })
}
export function fetchClaimSessionSummary(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}/session/summary`)
}
export function confirmClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/confirm-role`, { confirm: true })
}
export function redeemClaim(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/redeem`, {})
}
@@ -0,0 +1,4 @@
export function buildTencentBrowserSessionScreenshotUrl(sessionId: string, cacheKey = '') {
const suffix = cacheKey ? `?t=${encodeURIComponent(cacheKey)}` : ''
return `/api/v1/tencent/browser/session/${sessionId}/screenshot${suffix}`
}
@@ -0,0 +1,17 @@
import { apiPost } from '@/lib/http'
import type { TencentBrowserSessionData } from '@/types/tencent/session'
export interface TencentBrowserRedeemPayload {
code: string
maxAttempts?: number
}
export function redeemTencentBrowserSession(
sessionId: string,
payload: TencentBrowserRedeemPayload,
) {
return apiPost<TencentBrowserSessionData>(
`/api/v1/tencent/browser/session/${sessionId}/redeem`,
payload as unknown as Record<string, unknown>,
)
}
@@ -0,0 +1,33 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
import type {
TencentBrowserSessionData,
TencentBrowserSessionSummaryData,
TencentLoginType,
} from '@/types/tencent/session'
export interface CreateTencentBrowserSessionPayload {
loginType?: TencentLoginType
}
export function createTencentBrowserSession(payload?: CreateTencentBrowserSessionPayload) {
return apiPost<TencentBrowserSessionData>(
'/api/v1/tencent/browser/session',
payload as Record<string, unknown> | undefined,
)
}
export function fetchTencentBrowserSession(sessionId: string) {
return apiGet<TencentBrowserSessionData>(`/api/v1/tencent/browser/session/${sessionId}`)
}
export function fetchTencentBrowserSessionSummary(sessionId: string) {
return apiGet<TencentBrowserSessionSummaryData>(`/api/v1/tencent/browser/session/${sessionId}/summary`)
}
export function refreshTencentBrowserSessionPage(sessionId: string) {
return apiPost<TencentBrowserSessionData>(`/api/v1/tencent/browser/session/${sessionId}/refresh`)
}
export function removeTencentBrowserSession(sessionId: string) {
return apiDelete<Record<string, unknown>>(`/api/v1/tencent/browser/session/${sessionId}`)
}
+67
View File
@@ -0,0 +1,67 @@
:root {
--color-bg: #eef4ff;
--color-bg-accent: #dfe9ff;
--color-surface: rgba(255, 255, 255, 0.92);
--color-panel-soft: rgba(15, 93, 216, 0.06);
--color-text: #152238;
--color-muted: #5f708a;
--color-accent: #0f5dd8;
--color-border: rgba(20, 47, 95, 0.08);
--font-sans: 'PingFang SC', 'SF Pro Display', 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
color: var(--color-text);
background:
radial-gradient(circle at top left, rgba(44, 133, 247, 0.22), transparent 32%),
radial-gradient(circle at top right, rgba(114, 184, 255, 0.18), transparent 26%),
linear-gradient(180deg, var(--color-bg) 0%, #f7fbff 100%);
font-family: var(--font-sans);
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(20, 47, 95, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(20, 47, 95, 0.02) 1px, transparent 1px);
background-size: 24px 24px;
mask-image: radial-gradient(circle at center, black, transparent 75%);
}
img {
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
}
+259
View File
@@ -0,0 +1,259 @@
export interface AdminPagination {
page: number
pageSize: number
total: number
}
export type AdminRole = 'admin' | 'operator'
export type AdminUserStatus = 'active' | 'disabled'
export interface AdminLoginResponse {
token: string
expiresAt: string
user: {
userId: number
username: string
role: AdminRole
}
}
export interface AdminSessionSummary {
authenticated: boolean
expiresAt: string
user: {
userId: number
username: string
role: AdminRole
}
}
export interface AdminUserListItem {
userId: number
username: string
role: AdminRole
status: AdminUserStatus
createdAt: string
updatedAt: string
}
export interface AdminAuditLogItem {
logId: number
actorUserId: number
actorUsername: string
actorRole: AdminRole
action: string
targetType: string
targetId: string
payload: Record<string, unknown>
createdAt: string
}
export interface AdminDashboardSummary {
todayOrders: number
paidPendingClaim: number
claimingTasks: number
redeemedToday: number
abnormalTasks: number
skuWithInventory: number
}
export interface AdminOrderListItem {
orderId: number
platform: string
platformOrderId: string
orderStatus: string
payStatus: string
buyerName: string
totalAmount: number
currency: string
createdAt: string
updatedAt: string
taskCount: number
systemBindingStatus: string
userBindingStatus: string
systemBoundTaskCount: number
completedBindingTaskCount: number
}
export interface AdminTaskListItem {
taskId: number
taskNo: string
platformOrderId: string
skuCode: string
skuName: string
status: string
systemBindingStatus: string
userBindingStatus: string
loginType: string
roleName: string
roleId: string
browserSessionId: string
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
retryCount: number
lastError: string
createdAt: string
updatedAt: string
reservedCdkCodeMasked: string
claimToken: string
screenshotPath: string
}
export interface AdminTaskOperations {
canRetry: boolean
canReleaseCdk: boolean
canRegenerateClaimLink: boolean
canClose: boolean
canMarkManualReview: boolean
}
export interface AdminTaskActionResponse {
task: Record<string, unknown>
claimUrl?: string
token?: string
}
export interface AdminOrderDetail {
order: {
orderId: number
platform: string
platformOrderId: string
orderStatus: string
payStatus: string
buyerId: string
buyerName: string
receiverContact: string
totalAmount: number
currency: string
paidAt: string | null
createdAt: string
updatedAt: string
rawPayload: Record<string, unknown>
bindingSummary: {
totalTaskCount: number
systemBoundTaskCount: number
completedBindingTaskCount: number
systemBindingStatus: string
userBindingStatus: string
}
}
items: Array<{
orderItemId: number
skuCode: string
skuName: string
quantity: number
deliveryMode: string
spec: Record<string, unknown>
}>
tasks: Array<{
taskId: number
taskNo: string
status: string
systemBindingStatus: string
userBindingStatus: string
loginType: string
browserSessionId: string
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
lastError: string
retryCount: number
createdAt: string
updatedAt: string
}>
webhookEvents: Array<{
eventId: number
platform: string
eventType: string
eventKey: string
signatureValid: boolean
processed: boolean
processError: string
createdAt: string
}>
}
export interface AdminTaskDetail {
task: AdminTaskListItem
order: null | {
orderId: number
platformOrderId: string
payStatus: string
orderStatus: string
}
orderItem: null | {
orderItemId: number
skuCode: string
skuName: string
quantity: number
}
claimToken: null | {
claimTokenId: number
token: string
status: string
expiredAt: string
}
cdk: null | {
cdkId: number
skuCode: string
batchNo: string
cdkCode: string
status: string
}
artifacts: Record<string, unknown>
screenshotUrl: string
operations: AdminTaskOperations
}
export interface AdminCdkListItem {
cdkId: number
skuCode: string
batchNo: string
cdkCode: string
status: string
reservedByTaskId: number | null
reservedByTaskNo: string
platformOrderId: string
systemBindingStatus: string
userBindingStatus: string
invalidReason: string
deliveredAt: string | null
createdAt: string
updatedAt: string
}
export interface AdminWebhookEventListItem {
eventId: number
platform: string
eventType: string
eventKey: string
signatureValid: boolean
processed: boolean
processError: string
relatedOrderId: number | null
createdAt: string
}
export interface AdminWebhookEventDetail extends AdminWebhookEventListItem {
headers: Record<string, unknown>
query: Record<string, unknown>
body: Record<string, unknown>
}
export interface AdminWebhookReplayResponse {
eventId: number
replayed: boolean
result: {
accepted: boolean
eventType: string
platformOrderId: string
orderId: number
taskCount: number
tasks: Array<{
taskId: number
taskNo: string
status: string
}>
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session'
export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {})
export type ClaimTaskStatus =
| 'pending_payment'
| 'paid'
| 'waiting_inventory'
| 'cdk_reserved'
| 'link_generated'
| 'claimed'
| 'role_confirmed'
| 'redeeming'
| 'redeemed'
| 'retry_pending'
| 'manual_review'
| 'expired'
| 'closed'
| (string & {})
export interface ClaimTaskInfo {
taskId: number
taskNo: string
status: ClaimTaskStatus
expiresAt: string | null
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
loginType: string
lastError: string
browserSessionId: string
}
export interface ClaimOrderInfo {
orderId: number
platform: string
platformOrderId: string
payStatus: string
orderStatus: string
totalAmount: number
currency: string
}
export interface ClaimOrderItemInfo {
orderItemId: number
skuCode: string
skuName: string
quantity: number
}
export interface ClaimResultInfo {
resultCode: string
resultMessage: string
screenshotReady: boolean
screenshotUrl: string
}
export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus
claimUrl: string
task: ClaimTaskInfo
order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo
session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null
result: ClaimResultInfo | null
}
@@ -0,0 +1,45 @@
export interface TencentBrowserSessionArtifacts {
hasQrImage: boolean
hasScreenshot: boolean
}
export interface TencentBrowserRoleInfo {
ready: boolean
roleName: string
roleId: string
area: string
partition: string
platId: string
md5str: string
checkparam: string
}
export interface TencentBrowserFormInfo {
cdkeyInputId: string
cdkeyValue: string
verifyInputId: string
verifyValue: string
verifyImgId: string
submitId: string
}
export interface TencentBrowserVerifyInfo {
visible: boolean
src: string
naturalWidth: number
naturalHeight: number
}
export interface TencentBrowserPopupInfo {
visible: boolean
text: string
detail: string
}
export interface TencentBrowserActivityInfo {
nickname: string
role: TencentBrowserRoleInfo
form: TencentBrowserFormInfo
verify: TencentBrowserVerifyInfo
popup: TencentBrowserPopupInfo
}
+12
View File
@@ -0,0 +1,12 @@
export interface TencentBrowserRedeemAttempt {
attempt: number
redeem?: Record<string, unknown> | null
}
export interface TencentBrowserRedeemResult {
code: string
area: string
attempts: TencentBrowserRedeemAttempt[]
final?: TencentBrowserRedeemAttempt | null
finishedAt: string
}
@@ -0,0 +1,36 @@
import type { TencentBrowserActivityInfo, TencentBrowserSessionArtifacts } from './activity'
import type { TencentBrowserRedeemResult } from './redeem'
export type TencentLoginType = 'qq' | 'wx'
export type TencentBrowserSessionStatus =
| 'waiting_scan'
| 'scanned'
| 'logged_in'
| 'ready_to_redeem'
| 'redeeming'
| 'redeemed'
| 'failed'
| (string & {})
export interface TencentBrowserSessionData {
sessionId: string
loginType: TencentLoginType | string
activityUrl: string
status: TencentBrowserSessionStatus
notice: string
createdAt: string
updatedAt: string
expiresAt: string
qrImageBase64: string
qrUpdatedAt: string
credentialReady: boolean
activityInfo?: TencentBrowserActivityInfo | null
redeem?: TencentBrowserRedeemResult | null
artifacts: TencentBrowserSessionArtifacts
}
export interface TencentBrowserSessionSummaryData
extends Omit<TencentBrowserSessionData, 'qrImageBase64'> {
qrImageBase64?: string
}
+68
View File
@@ -0,0 +1,68 @@
const ADMIN_TOKEN_KEY = 'order-site-admin-token'
const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at'
const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
const ADMIN_ROLE_KEY = 'order-site-admin-role'
export function getAdminToken() {
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''
}
export function getAdminTokenExpiresAt() {
return localStorage.getItem(ADMIN_EXPIRES_AT_KEY) || ''
}
export function getAdminUserId() {
const value = Number(localStorage.getItem(ADMIN_USER_ID_KEY) || 0)
return Number.isFinite(value) && value > 0 ? value : 0
}
export function getAdminUsername() {
return localStorage.getItem(ADMIN_USERNAME_KEY) || ''
}
export function getAdminRole() {
const role = localStorage.getItem(ADMIN_ROLE_KEY)
return role === 'admin' ? 'admin' : 'operator'
}
export function hasAdminRole(role: 'admin' | 'operator') {
if (role === 'operator') {
return Boolean(getAdminToken())
}
return getAdminRole() === 'admin'
}
export function setAdminSession(
token: string,
expiresAt: string,
user?: { userId: number; username: string; role: 'admin' | 'operator' },
) {
localStorage.setItem(ADMIN_TOKEN_KEY, token)
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt)
if (user?.userId) {
localStorage.setItem(ADMIN_USER_ID_KEY, String(user.userId))
}
if (user?.username) {
localStorage.setItem(ADMIN_USERNAME_KEY, user.username)
}
if (user?.role) {
localStorage.setItem(ADMIN_ROLE_KEY, user.role)
}
}
export function clearAdminSession() {
localStorage.removeItem(ADMIN_TOKEN_KEY)
localStorage.removeItem(ADMIN_EXPIRES_AT_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem(ADMIN_USERNAME_KEY)
localStorage.removeItem(ADMIN_ROLE_KEY)
}
export function hasAdminSession() {
return Boolean(getAdminToken())
}
+45
View File
@@ -0,0 +1,45 @@
export function formatStatusWithRaw(status: string, labelMap: Record<string, string> = {}) {
const normalized = String(status || '').trim()
if (!normalized) {
return '-'
}
const key = normalized.toLowerCase()
const label = labelMap[key]
if (!label || label === normalized) {
return normalized
}
return `${label} (${normalized})`
}
export function formatAuditAction(action: string) {
const labelMap: Record<string, string> = {
admin_user_created: '创建后台用户',
admin_user_role_updated: '修改用户角色',
admin_user_status_updated: '修改用户状态',
admin_user_password_reset: '重置用户密码',
task_release_cdk: '释放任务 CDK',
task_regenerate_claim_link: '重发领取链接',
task_closed: '关闭任务',
task_mark_manual_review: '转人工处理',
inventory_cdk_released: '释放库存 CDK',
inventory_cdk_invalidated: '作废库存 CDK',
webhook_replayed: '重放 Webhook',
}
return formatStatusWithRaw(action, labelMap)
}
export function formatAuditTargetType(targetType: string) {
const labelMap: Record<string, string> = {
admin_user: '后台用户',
task: '交付任务',
cdk: 'CDK 库存',
webhook_event: 'Webhook 事件',
}
return formatStatusWithRaw(targetType, labelMap)
}
+73
View File
@@ -0,0 +1,73 @@
export const adminPayStatusOptions = [
{ label: '全部', value: '' },
{ label: '未支付', value: 'unpaid' },
{ label: '已支付', value: 'paid' },
{ label: '失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
]
export const adminTaskStatusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'pending_payment' },
{ label: '已支付', value: 'paid' },
{ label: '待库存', value: 'waiting_inventory' },
{ label: '已预占', value: 'cdk_reserved' },
{ label: '已生成链接', value: 'link_generated' },
{ label: '已创建会话', value: 'claimed' },
{ label: '已确认角色', value: 'role_confirmed' },
{ label: '兑换中', value: 'redeeming' },
{ label: '已兑换', value: 'redeemed' },
{ label: '待重试', value: 'retry_pending' },
{ label: '人工处理', value: 'manual_review' },
{ label: '已过期', value: 'expired' },
{ label: '已关闭', value: 'closed' },
]
export const adminCdkStatusOptions = [
{ label: '全部', value: '' },
{ label: '可用', value: 'available' },
{ label: '已预占', value: 'reserved' },
{ label: '已发放', value: 'delivered' },
{ label: '已作废', value: 'invalid' },
]
export const adminWebhookProcessedOptions = [
{ label: '全部', value: '' },
{ label: '处理成功', value: '1' },
{ label: '处理失败', value: '0' },
]
export const adminUserRoleOptions = [
{ label: '全部', value: '' },
{ label: '管理员', value: 'admin' },
{ label: '普通运营', value: 'operator' },
]
export const adminUserStatusOptions = [
{ label: '全部', value: '' },
{ label: '生效中', value: 'active' },
{ label: '已停用', value: 'disabled' },
]
export const adminAuditActionOptions = [
{ label: '全部', value: '' },
{ label: '创建后台用户', value: 'admin_user_created' },
{ label: '修改用户角色', value: 'admin_user_role_updated' },
{ label: '修改用户状态', value: 'admin_user_status_updated' },
{ label: '重置用户密码', value: 'admin_user_password_reset' },
{ label: '释放任务 CDK', value: 'task_release_cdk' },
{ label: '重发领取链接', value: 'task_regenerate_claim_link' },
{ label: '关闭任务', value: 'task_closed' },
{ label: '转人工处理', value: 'task_mark_manual_review' },
{ label: '释放库存 CDK', value: 'inventory_cdk_released' },
{ label: '作废库存 CDK', value: 'inventory_cdk_invalidated' },
{ label: '重放 Webhook', value: 'webhook_replayed' },
]
export const adminAuditTargetTypeOptions = [
{ label: '全部', value: '' },
{ label: '后台用户', value: 'admin_user' },
{ label: '交付任务', value: 'task' },
{ label: 'CDK 库存', value: 'cdk' },
{ label: 'Webhook 事件', value: 'webhook_event' },
]
+1
View File
@@ -0,0 +1 @@
export { formatDateTime as formatAdminDateTime } from '@/utils/date-time'
+22
View File
@@ -0,0 +1,22 @@
import { formatStatusWithRaw } from '@/utils/admin-display'
export function formatAdminWebhookEventType(eventType: string) {
const normalized = String(eventType || '').trim()
if (!normalized) {
return '-'
}
const labelMap: Record<string, string> = {
payment_success: '支付成功',
trade_create: '订单创建',
trade_closed: '订单关闭',
buyer_confirm_goods: '确认收货',
auto_delivery_done: '自动发货完成',
auto_delivery_confirmed: '自动发货确认完成',
refund_apply: '买家申请退款',
trade_event: '交易事件',
}
return formatStatusWithRaw(normalized, labelMap)
}
+51
View File
@@ -0,0 +1,51 @@
const ISO_DATE_TIME_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/
export function isIsoDateTimeString(value: unknown): value is string {
return typeof value === 'string' && ISO_DATE_TIME_PATTERN.test(value.trim())
}
export function formatDateTime(value: string | null | undefined, fallback = '-') {
const normalized = String(value || '').trim()
if (!normalized) {
return fallback
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized.replace('T', ' ').replace('Z', '')
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
export function normalizeDateTimeValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => normalizeDateTimeValue(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [key, normalizeDateTimeValue(currentValue)]),
)
}
if (isIsoDateTimeString(value)) {
return formatDateTime(value, '')
}
return value
}
export function stringifyDisplayJson(value: unknown, space = 2) {
return JSON.stringify(normalizeDateTimeValue(value), null, space)
}
+48
View File
@@ -0,0 +1,48 @@
<template>
<main class="not-found">
<div class="card">
<p class="code">404</p>
<h1>页面不存在</h1>
<p>当前前端只保留腾讯浏览器兑换页这个地址没有对应页面</p>
<RouterLink to="/tx">前往兑换入口</RouterLink>
</div>
</main>
</template>
<style scoped>
.not-found {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: min(560px, 100%);
padding: 40px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 64px rgba(17, 38, 78, 0.12);
text-align: center;
}
.code {
margin: 0;
color: var(--color-accent);
font-family: var(--font-mono);
letter-spacing: 0.2em;
}
h1 {
margin: 8px 0 16px;
font-size: clamp(30px, 5vw, 46px);
}
a {
display: inline-flex;
margin-top: 20px;
color: var(--color-accent);
text-decoration: none;
font-weight: 700;
}
</style>
@@ -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>
+368
View File
@@ -0,0 +1,368 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useClaimPage } from '@/composables/useClaimPage'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const {
detailLoading,
roleConfirmLoading,
sessionLoading,
redeemLoading,
loginType,
loginTypeLabel,
loginTabs,
task,
order,
orderItem,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
refreshSessionSummary,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
} = useClaimPage(token.value)
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
const helperText = computed(() => {
if (!task.value) {
return '正在加载领取任务'
}
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
})
</script>
<template>
<main class="claim-page">
<section class="workspace">
<div class="page-toolbar">
<div>
<div class="brand-chip">订单领取</div>
<p class="helper-copy">{{ helperText }}</p>
</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
</div>
</div>
<section class="order-card">
<div class="order-meta">
<article>
<span>订单号</span>
<strong>{{ order?.platformOrderId || '-' }}</strong>
</article>
<article>
<span>商品</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</article>
<article>
<span>数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</article>
<article>
<span>任务号</span>
<strong>{{ task?.taskNo || '-' }}</strong>
</article>
</div>
</section>
<div v-if="detailLoading" class="empty-block loading-block">
<strong>领取信息加载中</strong>
<p>正在校验链接并同步任务状态</p>
</div>
<template v-else>
<div class="layout-grid">
<TencentAuthCard
:current-login-tab-label="currentLoginTabLabel"
:has-session="hasSession"
:init-button-label="initButtonLabel"
:login-tabs="loginTabs"
:login-type="loginType"
:login-type-label="loginTypeLabel"
:qr-figure-style="qrFigureStyle"
:qr-image="qrImage"
:scan-instruction="scanInstruction"
:session-status="session?.status || ''"
:session-id="session?.sessionId || ''"
:session-loading="sessionLoading"
:session-notice="sessionNotice"
@create-session="createSessionFlow"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="refreshSessionSummary()"
@switch-login-type="switchLoginType"
/>
<div class="control-column">
<TencentRedeemPanel
:can-redeem="canRedeem"
:can-confirm-role="canConfirmRole"
confirm-action-label="确认当前角色并继续"
:confirm-action-loading="roleConfirmLoading"
:confirm-action-visible="!roleConfirmed"
:confirm-readonly="true"
:hide-redeem-form="true"
:login-type-label="loginTypeLabel"
:max-attempts="6"
:redeem-blocked-reason="redeemBlockedReason"
:redeem-button-label="redeemButtonLabel"
:redeem-code="''"
:redeem-loading="redeemLoading"
:role-confirmed="roleConfirmed"
:role-facts="roleFacts"
:role-ready="roleReady"
:status-label="statusLabel"
@confirm-role="confirmRoleNow"
@redeem="redeemNow"
@update:max-attempts="() => undefined"
@update:redeem-code="() => undefined"
@update:role-confirmed="() => undefined"
/>
</div>
</div>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</template>
</section>
<el-dialog
v-model="qrPreviewVisible"
align-center
class="preview-dialog"
:width="qrPreviewWidth"
>
<div class="qr-preview-body">
<img
class="preview-image qr-preview-image"
:src="qrImage"
:alt="`Claim ${loginTypeLabel} QR Preview`"
/>
</div>
</el-dialog>
<el-dialog
v-model="screenshotPreviewVisible"
align-center
class="preview-dialog"
width="1080px"
>
<img class="preview-image" :src="screenshotUrl" alt="Claim Screenshot Preview" />
</el-dialog>
</main>
</template>
<style scoped>
.claim-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
}
.workspace {
max-width: 1240px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.page-toolbar,
.toolbar-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand-chip,
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #27405e;
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
}
.brand-chip {
font-weight: 800;
}
.status-badge {
font-weight: 700;
}
.back-link {
text-decoration: none;
}
.helper-copy {
margin: 8px 0 0;
color: #64748b;
}
.order-card {
padding: 18px 22px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
}
.order-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.order-meta article {
padding: 14px;
border-radius: 16px;
background: #f7f9fc;
}
.order-meta span {
display: block;
font-size: 12px;
color: #708096;
}
.order-meta strong {
display: block;
margin-top: 6px;
color: #1f324a;
word-break: break-word;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
gap: 18px;
align-items: stretch;
}
.control-column {
display: grid;
gap: 14px;
}
.empty-block {
min-height: 220px;
border-radius: 18px;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px dashed #d5dfec;
}
.loading-block strong {
color: #2c4058;
font-size: 16px;
}
.loading-block p {
margin: 8px 0 0;
color: #6b7a91;
line-height: 1.7;
}
.preview-image {
display: block;
width: 100%;
height: auto;
}
:deep(.preview-dialog .el-dialog) {
border-radius: 28px;
overflow: hidden;
}
:deep(.preview-dialog .el-dialog__body) {
padding: 0;
background: #f5f7fb;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 24px;
}
.qr-preview-image {
width: auto;
max-width: 100%;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
@media (max-width: 1140px) {
.layout-grid,
.order-meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.claim-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useTencentBrowserSessionPage } from '@/composables/useTencentBrowserSessionPage'
const {
activityInfo,
canRedeem,
createSessionFlow,
hasSession,
initButtonLabel,
loginTabs,
loginType,
loginTypeLabel,
maxAttempts,
qrImage,
redeemButtonLabel,
redeemBlockedReason,
redeemCode,
redeemLoading,
redeemNow,
reloadSessionPage,
resultFacts,
roleConfirmed,
roleFacts,
scanInstruction,
screenshotEmptyMessage,
screenshotEmptyTitle,
screenshotUrl,
session,
sessionLoading,
sessionNotice,
statusLabel,
switchLoginType,
} = useTencentBrowserSessionPage()
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const qrImageNaturalWidth = ref(0)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
watch(qrImage, () => {
qrImageNaturalWidth.value = 0
})
function handleQrImageLoad(event: Event) {
const target = event.target
if (!(target instanceof HTMLImageElement)) {
return
}
qrImageNaturalWidth.value = target.naturalWidth || 0
}
const qrDisplayWidth = computed(() => {
const naturalWidth = qrImageNaturalWidth.value
if (!naturalWidth) {
return 220
}
if (naturalWidth < 160) {
return Math.min(naturalWidth * 2, 220)
}
return Math.min(naturalWidth, 240)
})
const qrFigureStyle = computed(() => ({
width: `${qrDisplayWidth.value}px`,
maxWidth: '100%',
}))
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
</script>
<template>
<main class="browser-page">
<section class="workspace">
<div class="page-toolbar">
<div class="brand-chip">腾讯浏览器兑换</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/">返回首页</RouterLink>
</div>
</div>
<div class="layout-grid">
<TencentAuthCard
:current-login-tab-label="currentLoginTabLabel"
:has-session="hasSession"
:init-button-label="initButtonLabel"
:login-tabs="loginTabs"
:login-type="loginType"
:login-type-label="loginTypeLabel"
:qr-figure-style="qrFigureStyle"
:qr-image="qrImage"
:scan-instruction="scanInstruction"
:session-status="session?.status || ''"
:session-id="session?.sessionId || ''"
:session-loading="sessionLoading"
:session-notice="sessionNotice"
@create-session="createSessionFlow"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@switch-login-type="switchLoginType"
/>
<aside class="control-column">
<TencentRedeemPanel
:can-redeem="canRedeem"
:login-type-label="loginTypeLabel"
:max-attempts="maxAttempts"
:redeem-blocked-reason="redeemBlockedReason"
:redeem-button-label="redeemButtonLabel"
:redeem-code="redeemCode"
:redeem-loading="redeemLoading"
:role-confirmed="roleConfirmed"
:role-facts="roleFacts"
:role-ready="Boolean(activityInfo?.role?.ready)"
:status-label="statusLabel"
@redeem="redeemNow"
@update:max-attempts="maxAttempts = $event"
@update:redeem-code="redeemCode = $event"
@update:role-confirmed="roleConfirmed = $event"
/>
</aside>
</div>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</section>
<el-dialog
v-model="qrPreviewVisible"
align-center
class="preview-dialog"
:width="qrPreviewWidth"
>
<div class="qr-preview-body">
<img
class="preview-image qr-preview-image"
:src="qrImage"
:alt="`Tencent ${loginTypeLabel} QR Preview`"
/>
</div>
</el-dialog>
<el-dialog
v-model="screenshotPreviewVisible"
align-center
class="preview-dialog"
width="1080px"
>
<img class="preview-image" :src="screenshotUrl" alt="Tencent Screenshot Preview" />
</el-dialog>
</main>
</template>
<style scoped>
.browser-page {
min-height: 100vh;
padding: 22px 16px 34px;
font-family: 'Avenir Next', 'PingFang SC', 'Helvetica Neue', sans-serif;
background:
radial-gradient(circle at top left, rgba(116, 168, 255, 0.12), transparent 28%),
radial-gradient(circle at bottom right, rgba(248, 186, 103, 0.12), transparent 24%),
linear-gradient(180deg, #f4f7fb 0%, #edf2f8 100%);
}
.workspace {
width: min(1120px, 100%);
margin: 0 auto;
display: grid;
gap: 16px;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.brand-chip {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #2f425d;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(86, 108, 138, 0.14);
background: rgba(255, 255, 255, 0.92);
color: #31445e;
text-decoration: none;
font-size: 13px;
}
.status-badge {
font-weight: 700;
color: #5f6f87;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.control-column {
display: grid;
gap: 0;
}
.preview-image {
display: block;
width: 100%;
height: auto;
}
:deep(.preview-dialog .el-dialog) {
border-radius: 28px;
overflow: hidden;
}
:deep(.preview-dialog .el-dialog__body) {
padding: 0;
background: #f5f7fb;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 24px;
}
.qr-preview-image {
width: auto;
max-width: 100%;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
@media (max-width: 1140px) {
.layout-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.browser-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+44
View File
@@ -0,0 +1,44 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router'],
dts: 'src/auto-imports.d.ts',
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
Components({
dts: 'src/components.d.ts',
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
// 👇 加入这行,允许通过 IP 和网络访问
host: true,
// 👇 加入这行,允许所有外部域名(包括 Cloudflare 分配的域名)访问
allowedHosts: true,
proxy: {
'/api': {
target: env.VITE_API_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
},
},
}
})