init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
Vendored
+78
@@ -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')
|
||||
}
|
||||
Vendored
+29
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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`, {})
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { formatDateTime as formatAdminDateTime } from '@/utils/date-time'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user