清理快手前端无用入口

This commit is contained in:
yml
2026-05-25 20:59:57 +08:00
parent db22ff691b
commit dc099ae6f8
65 changed files with 86 additions and 7279 deletions
+22 -46
View File
@@ -1,64 +1,40 @@
# 腾讯浏览器兑换前端
# 快手轻量前端
这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责
这是快手自动发货拆分后的 Vue 3 前端,只保留快手领取和运营后台相关入口
## 功能范围
- 提供 `/tx/browser` 单页入口
- 支持 QQ / 微信扫码登录切换
- 支持浏览器会话创建、状态轮询、角色确认、兑换提交
- 支持结果信息展示,以及在后端产物存在时预览截图
- `/claim/:token` 快手 Cloud 领取页
- `/admin` 运营后台
- 91 卡券订单补全与任务生成
- 快手小店核销配置
- Cloudtentacles 履约配置与调试
- 内部通知和定时任务配置
## 项目原则
## 已移除内容
- 保持页面职责单一,只服务腾讯浏览器兑换链路
- 与本地后端接口保持一致,优先保证稳定性和可维护性
- 页面层尽量薄,状态编排、轮询、展示映射分别收口
- 腾讯浏览器调试入口 `/tx/browser`
- 腾讯领取页 fallback
- 人工兑换页面
- 旧履约规则配置页
- Agiso 店铺消息配置前端入口
## 本地开发
安装依赖并启动开发环境:
```bash
cd /Users/yml/codes/order-site-rewrite
npm install
npm run dev
```
构建生产包
开发环境默认通过 `VITE_API_TARGET` 代理后端
```bash
VITE_API_TARGET=http://localhost:3000 npm run dev
```
## 验证
```bash
npm run typecheck
npm run build
```
开发环境默认代理到本地后端:
```bash
http://localhost:3000
```
如需切换后端地址,启动前设置:
```bash
VITE_API_TARGET=http://你的后端地址 npm run dev
```
## 结构说明
```text
src/
lib/ HTTP 基础设施
router/ 路由
services/ 腾讯接口调用
types/ 腾讯会话 / 活动 / 兑换类型
composables/ 页面编排、轮询、展示映射
components/ 腾讯页面子组件
views/ 页面壳
styles/ 全局样式
```
## 当前状态
- 主页面已经拆成扫码卡片、兑换面板、结果面板三个组件
- 会话轮询、错误提示、展示文案已分别从页面模板中抽离
- 前端当前没有全局 store,所有状态都围绕单页流程局部管理
-6
View File
@@ -25,7 +25,6 @@ declare module 'vue' {
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -33,7 +32,6 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
@@ -47,7 +45,6 @@ declare module 'vue' {
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
@@ -57,9 +54,6 @@ declare module 'vue' {
ElTooltip: typeof import('element-plus/es')['ElTooltip']
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']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
@@ -1,481 +0,0 @@
<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
qrCopyButtonLabel?: string
qrCopyEnabled?: boolean
qrFigureStyle: { width: string; maxWidth: string }
qrImage: string
scanInstruction: string
sessionStatus?: string
sessionId: string
sessionLoading: boolean
sessionNotice: string
}>()
const emit = defineEmits<{
closeSession: []
copyQr: []
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')"
@dblclick.prevent.stop="emit('copyQr')"
>
<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
v-if="props.qrCopyEnabled && props.qrImage && props.sessionStatus === 'waiting_scan'"
class="scan-tip"
>
双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。
</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"
size="large"
type="primary"
@click="emit('createSession', props.loginType)"
>
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
</el-button>
<el-button
v-if="props.qrCopyEnabled"
:disabled="!props.qrImage || props.sessionStatus !== 'waiting_scan'"
:loading="props.sessionLoading"
class="copy-action"
size="large"
@click="emit('copyQr')"
>
{{ props.qrCopyButtonLabel || '复制二维码' }}
</el-button>
<div class="secondary-actions">
<el-button
:disabled="!props.hasSession"
:loading="props.sessionLoading"
class="secondary-action"
size="large"
@click="emit('reloadSession')"
>
刷新后端页面
</el-button>
<el-button
:disabled="!props.hasSession"
:loading="props.sessionLoading"
class="tertiary-action"
size="large"
@click="emit('closeSession')"
>
关闭当前会话
</el-button>
</div>
</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;
}
.scan-tip {
margin: 8px 0 0;
color: #2859b8;
line-height: 1.6;
font-size: 13px;
}
.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: flex;
flex-direction: column;
gap: 12px;
}
.primary-action :deep(span) {
font-weight: 700;
letter-spacing: 0.01em;
}
.action-row :deep(.el-button) {
width: 100%;
min-width: 0;
margin: 0;
height: 52px;
border-radius: 18px;
}
.action-row :deep(.el-button > span) {
white-space: nowrap;
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.primary-action,
.copy-action,
.secondary-action,
.tertiary-action {
min-width: 0;
}
.action-row :deep(.primary-action.el-button) {
box-shadow: 0 16px 30px rgba(91, 143, 240, 0.24);
}
.action-row :deep(.primary-action.el-button:not(.is-disabled)) {
background: linear-gradient(135deg, #6da9fb 0%, #4f85eb 100%);
border-color: transparent;
}
.action-row :deep(.secondary-action.el-button),
.action-row :deep(.copy-action.el-button),
.action-row :deep(.tertiary-action.el-button) {
background: rgba(248, 251, 255, 0.98);
border: 1px solid #d9e4f2;
color: #4a5e79;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.action-row :deep(.copy-action.el-button:hover),
.action-row :deep(.copy-action.el-button:focus-visible),
.action-row :deep(.secondary-action.el-button:hover),
.action-row :deep(.secondary-action.el-button:focus-visible) {
border-color: #9dc0f6;
color: #2859b8;
background: #f3f8ff;
}
.action-row :deep(.tertiary-action.el-button:hover),
.action-row :deep(.tertiary-action.el-button:focus-visible) {
border-color: #efb4b4;
color: #b43c3c;
background: #fff6f6;
}
.action-row :deep(.copy-action.el-button.is-disabled),
.action-row :deep(.secondary-action.el-button.is-disabled),
.action-row :deep(.tertiary-action.el-button.is-disabled) {
background: #f8fafc;
border-color: #e5ebf3;
color: #9aa8bc;
}
@media (max-width: 760px) {
.auth-card {
padding: 18px;
}
.login-tab {
font-size: 15px;
min-height: 54px;
}
.scan-hint {
font-size: 15px;
}
.secondary-actions {
grid-template-columns: 1fr;
}
.action-row :deep(.el-button) {
width: 100%;
}
}
</style>
@@ -1,288 +0,0 @@
<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-if="props.confirmReadonly && !props.roleConfirmed" class="confirm-box">
当前商品需要客服复核完成扫码登录后请联系人工继续
</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>
@@ -1,158 +0,0 @@
<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>
@@ -1,17 +0,0 @@
export { useSessionPolling } from './useSessionPolling'
export type { UseSessionPollingOptions } from './useSessionPolling'
export { useSessionQrDisplay } from './useSessionQrDisplay'
export {
useSessionRoleFacts,
useSessionResultFacts,
buildRedeemBlockedReason,
resolveTaskStatusLabel,
syncLoginTypeFromDetail,
} from './useSessionPresentation'
export type { TaskStatusLabelContext, FactItem, UseSessionPresentationOptions, UseSessionResultFactsOptions } from './useSessionPresentation'
export { mergeSessionDetail, shouldRefreshQrImage, shouldKeepPolling } from './mergeSessionDetail'
export { DEFAULT_LOGIN_TYPE, POLL_INTERVAL_MS, POLL_FAILURE_LIMIT, ACTIVE_TASK_STATUSES } from './sessionConstants'
@@ -1,82 +0,0 @@
import type { ClaimDetailData } from '@/types/claim'
import type {
TencentBrowserSessionData,
TencentBrowserSessionSummaryData,
} from '@/types/tencent/session'
import type { ClaimTaskStatus } from '@/types/claim'
import { ACTIVE_TASK_STATUSES } from './sessionConstants'
/**
* Generic merge of session detail data, preserving QR image and other
* fields that may be absent in summary responses.
*
* The `extraSessionFields` callback lets callers inject domain-specific
* session merge logic (e.g. `review` for admin, `redeem` for claim).
*/
export function mergeSessionDetail<T extends ClaimDetailData>(
current: T | null,
next: T,
extraSessionFields?: (
currentSession: NonNullable<T['session']>,
nextSession: NonNullable<T['session']>,
) => Partial<NonNullable<T['session']>>,
): T {
if (next.session === null) {
return {
...next,
session: null,
} as T
}
if (!current?.session) {
return next
}
const sessionBase = {
...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,
} as NonNullable<T['session']>
const extras = extraSessionFields
? extraSessionFields(current.session as NonNullable<T['session']>, next.session as NonNullable<T['session']>)
: {}
return {
...next,
session: {
...sessionBase,
...extras,
},
} as T
}
export function shouldRefreshQrImage(
current: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
next: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
): boolean {
if (!current || !next || !next.artifacts?.hasQrImage) {
return false
}
if (!current.qrImageBase64) {
return true
}
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
}
export function shouldKeepPolling<T extends ClaimDetailData>(detail: T | null): boolean {
if (!detail?.session?.sessionId) {
return false
}
return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus)
}
@@ -1,7 +0,0 @@
import type { TencentLoginType } from '@/types/tencent/session'
import type { ClaimTaskStatus } from '@/types/claim'
export const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
export const POLL_INTERVAL_MS = 2_500
export const POLL_FAILURE_LIMIT = 3
export const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
@@ -1,93 +0,0 @@
import { computed, ref, type ComputedRef } from 'vue'
import { POLL_INTERVAL_MS, POLL_FAILURE_LIMIT } from './sessionConstants'
export interface UseSessionPollingOptions {
/** Whether there is an active session used as a guard inside the poll loop. */
hasSession: ComputedRef<boolean>
/** Called on each poll tick (typically refreshSessionSummary). */
onPollTick: () => Promise<unknown>
/** Optional: determines whether polling should continue. Defaults to checking hasSession. */
shouldKeepPolling?: ComputedRef<boolean>
/** Context-specific warning message prefix when polling fails repeatedly. */
pollWarningPrefix: string
}
export function useSessionPolling(options: UseSessionPollingOptions) {
const { hasSession, onPollTick, pollWarningPrefix } = options
const pollWarningMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollToken = 0
let pollFailureCount = 0
const sessionNotice = computed(() => pollWarningMessage.value)
function startPolling() {
const tokenId = ++pollToken
const loop = async () => {
if (tokenId !== pollToken || !hasSession.value) {
return
}
await onPollTick()
if (tokenId !== pollToken) {
return
}
const keepPolling = options.shouldKeepPolling
? options.shouldKeepPolling.value
: hasSession.value
if (!keepPolling) {
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 = 0
pollWarningMessage.value = ''
}
function handleSilentPollingError(error: unknown) {
console.error(error)
pollFailureCount += 1
if (pollFailureCount < POLL_FAILURE_LIMIT) {
return
}
pollWarningMessage.value = `${pollWarningPrefix},已暂停自动轮询,请手动刷新。`
resetPolling()
}
return {
pollWarningMessage,
sessionNotice,
startPolling,
resetPolling,
resetPollingWarning,
handleSilentPollingError,
}
}
@@ -1,246 +0,0 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import type { ClaimDetailData } from '@/types/claim'
import type { TencentLoginType } from '@/types/tencent/session'
import { DEFAULT_LOGIN_TYPE } from './sessionConstants'
type ReadableRef<T> = Pick<Ref<T>, 'value'>
// ---------------------------------------------------------------------------
// syncLoginTypeFromDetail
// ---------------------------------------------------------------------------
export function syncLoginTypeFromDetail<T extends ClaimDetailData>(
detail: T,
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
): TencentLoginType {
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
if (nextLoginType === 'wx') {
return 'wx'
}
if (nextLoginType === 'qq') {
return 'qq'
}
return fallback
}
// ---------------------------------------------------------------------------
// resolveTaskStatusLabel
// ---------------------------------------------------------------------------
export type TaskStatusLabelContext = 'claim' | 'admin'
/**
* Resolves a human-readable task status label.
*
* The `context` parameter controls wording differences between the
* consumer-facing claim page and the admin manual-redeem page.
*/
export function resolveTaskStatusLabel(
taskStatus: string | undefined,
sessionStatus: string | undefined,
context: TaskStatusLabelContext,
): string {
switch (taskStatus) {
case 'link_generated':
return context === 'admin' ? '待生成二维码' : '等待开始领取'
case 'claimed':
if (sessionStatus === 'scanned') {
return context === 'admin' ? '客户待确认' : '确认中'
}
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
return context === 'admin' ? '已登录待复核' : '已登录'
}
return context === 'admin' ? '等待客户登录' : '领取中'
case 'role_confirmed':
return '角色已确认'
case 'redeeming':
return '正在兑换'
case 'redeemed':
return context === 'admin' ? '兑换完成' : '兑换成功'
case 'waiting_inventory':
return '等待库存'
case 'retry_pending':
return '等待重试'
case 'manual_review':
return '等待人工处理'
case 'expired':
return '链接已过期'
case 'closed':
return '任务已关闭'
default:
return sessionStatus || taskStatus || '等待中'
}
}
// ---------------------------------------------------------------------------
// roleFacts / resultFacts helpers
// ---------------------------------------------------------------------------
export interface FactItem {
label: string
value: string
accent?: boolean
}
export interface UseSessionPresentationOptions {
activityInfo: ComputedRef<{
nickname?: string
role?: {
roleId?: string
roleName?: string
area?: string
ready?: boolean
} | null
} | null>
statusLabel: ComputedRef<string>
/** Context-specific default values for roleFacts */
roleFactDefaults: {
nickname: string
}
}
export function useSessionRoleFacts(options: UseSessionPresentationOptions) {
const { activityInfo, roleFactDefaults } = options
const roleFacts = computed<FactItem[]>(() => [
{
label: '登录昵称',
value: activityInfo.value?.nickname || roleFactDefaults.nickname,
},
{
label: '当前角色',
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
accent: true,
},
{
label: '角色 ID',
value: activityInfo.value?.role?.roleId || '未识别',
},
{
label: '所在大区',
value: activityInfo.value?.role?.area || '未识别',
},
])
return { roleFacts }
}
export interface UseSessionResultFactsOptions {
order: ComputedRef<{ platformOrderId?: string } | null>
orderItem: ComputedRef<{ skuName?: string } | null>
result: ComputedRef<{ resultCode?: string; resultMessage?: string } | null>
/** Admin context has manualRequest.proofValue as an alternative first label */
resultFactOverrides?: {
firstLabel?: string
firstValue?: ComputedRef<string>
}
}
export function useSessionResultFacts(options: UseSessionResultFactsOptions) {
const { order, orderItem, result, resultFactOverrides } = options
const resultFacts = computed<FactItem[]>(() => [
{
label: resultFactOverrides?.firstLabel || '订单号',
value: resultFactOverrides?.firstValue?.value || order.value?.platformOrderId || '-',
},
{
label: '商品',
value: orderItem.value?.skuName || '-',
},
{
label: '业务返回码',
value: result.value?.resultCode || '-',
},
{
label: '业务消息',
value: result.value?.resultMessage || '尚未兑换',
},
])
return { resultFacts }
}
// ---------------------------------------------------------------------------
// redeemBlockedReason — builds the "why can't I redeem" explanation
// ---------------------------------------------------------------------------
export function buildRedeemBlockedReason(options: {
detail: ReadableRef<unknown | null>
hasSession: ReadableRef<boolean>
loginTypeLabel: ReadableRef<string>
redeemLoading: ReadableRef<boolean>
roleReady: ReadableRef<boolean>
roleConfirmed: ReadableRef<boolean>
sessionNotice: ReadableRef<string>
context: 'claim' | 'admin'
/** Claim-specific fields */
tokenStatus?: ReadableRef<string>
requiresSupportReview?: ReadableRef<boolean>
}): string {
const {
detail,
hasSession,
loginTypeLabel,
redeemLoading,
roleReady,
roleConfirmed,
sessionNotice,
context,
tokenStatus,
requiresSupportReview,
} = options
if (context === 'claim') {
if (!detail.value) {
return '正在加载领取信息'
}
if (tokenStatus && tokenStatus.value !== 'active') {
return '当前领取链接不可用'
}
} else {
// admin context
if (!detail.value) {
return '请先创建人工兑换任务并预占库存'
}
}
if (!hasSession.value) {
return context === 'claim'
? `请先选择${loginTypeLabel.value}并初始化登录会话`
: `请先生成${loginTypeLabel.value}二维码并等待客户登录`
}
// Claim-specific: support review flow
if (context === 'claim' && requiresSupportReview?.value) {
if (!roleReady.value) {
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
}
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
}
if (redeemLoading.value) {
return '兑换任务正在执行中'
}
if (!roleReady.value) {
return (
sessionNotice.value ||
(context === 'claim'
? '扫码成功后,后端正在同步角色和大区信息'
: '客户登录后,系统会自动同步角色和大区信息')
)
}
if (!roleConfirmed.value) {
return context === 'claim'
? '请先确认当前角色与大区无误,再开始兑换'
: '请先让客户确认角色截图,再点击确认当前角色'
}
return ''
}
@@ -1,63 +0,0 @@
import { computed, ref, watch, type ComputedRef } from 'vue'
/**
* Shared QR code display logic: renders the base64 QR image, tracks its
* natural width after load, and derives display/preview widths.
*/
export function useSessionQrDisplay(options: {
session: ComputedRef<{ qrImageBase64?: string } | null>
}) {
const { session } = options
const qrImageNaturalWidth = ref(0)
const qrImage = computed(() =>
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
)
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`,
)
return {
qrImage,
qrImageNaturalWidth,
qrDisplayWidth,
qrFigureStyle,
qrPreviewWidth,
handleQrImageLoad,
}
}
@@ -1,48 +0,0 @@
import { showError } from '@/lib/feedback'
export type TencentActionError = Error & {
errorCode?: string
status?: number
}
export function notifyTencentActionError(error: unknown, fallbackMessage: string) {
const message = resolveTencentActionMessage(error, fallbackMessage)
console.error(error)
showError(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
}
@@ -1,193 +0,0 @@
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)
const nextSession = mergeSessionData(currentSession, response.data)
session.value = nextSession
if (shouldRefreshQrImage(currentSession, response.data)) {
const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId)
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)
}
@@ -1,210 +0,0 @@
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,
}
}
@@ -1,714 +0,0 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirm, showSuccess } from '@/lib/feedback'
import {
closeAdminManualRedeemTask,
confirmAdminManualRedeemRole,
createAdminManualRedeemSession,
createAdminManualRedeemTask,
fetchAdminManualRedeemDetail,
fetchAdminManualRedeemSessionSummary,
fetchAdminManualRedeemSkuSuggestions,
fetchAdminTaskScreenshot,
refreshAdminManualRedeemSession,
redeemAdminManualRedeem,
removeAdminManualRedeemSession,
} from '@/services/admin'
import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin'
import type { TencentLoginType } from '@/types/tencent/session'
import { notifyTencentActionError } from './tencent/session-errors'
import {
useSessionPolling,
useSessionQrDisplay,
useSessionResultFacts,
buildRedeemBlockedReason,
resolveTaskStatusLabel,
syncLoginTypeFromDetail,
mergeSessionDetail,
shouldRefreshQrImage,
shouldKeepPolling,
DEFAULT_LOGIN_TYPE,
} from './shared'
export function useAdminManualRedeemPage() {
const route = useRoute()
const router = useRouter()
// ── Form state (admin-only) ────────────────────────────────────────
const form = reactive({
proofValue: '',
skuCode: '',
remark: '',
})
const suggestions = ref<AdminInventorySkuSuggestion[]>([])
// ── Core state ────────────────────────────────────────────────────
const detail = ref<AdminManualRedeemDetail | null>(null)
const detailLoading = ref(false)
const createLoading = ref(false)
const sessionLoading = ref(false)
const roleConfirmLoading = ref(false)
const redeemLoading = ref(false)
const closeLoading = ref(false)
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
// ── Screenshot state (admin-only) ──────────────────────────────────
const screenshotBlob = ref<Blob | null>(null)
const screenshotUrl = ref('')
const screenshotLoading = ref(false)
// ── Derived state ──────────────────────────────────────────────────
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 manualRequest = computed(() => detail.value?.manualRequest || null)
const activityInfo = computed(() => session.value?.activityInfo || null)
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
const roleConfirmed = computed(() =>
Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)),
)
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0)
const reviewReady = computed(
() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed',
)
const resultReady = computed(
() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed',
)
// ── Polling ────────────────────────────────────────────────────────
const {
pollWarningMessage,
startPolling,
resetPolling,
resetPollingWarning,
handleSilentPollingError,
} = useSessionPolling({
hasSession: computed(() => Boolean(task.value?.taskId && hasSession.value)),
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
onPollTick: async () => { await refreshSessionSummary({ silent: true }) },
pollWarningPrefix: '人工兑换状态刷新失败',
})
const sessionNotice = computed(
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
)
// ── QR display ─────────────────────────────────────────────────────
const {
qrImage,
qrFigureStyle,
qrPreviewWidth,
handleQrImageLoad,
} = useSessionQrDisplay({ session })
// ── Presentation ───────────────────────────────────────────────────
const loginTabs = [
{ value: 'qq' as const, label: 'QQ账号登录' },
{ value: 'wx' as const, label: '微信账号登录' },
]
const statusLabel = computed(() =>
resolveTaskStatusLabel(task.value?.status, session.value?.status, 'admin'),
)
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
const scanInstruction = computed(
() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`,
)
const proofValue = computed(() => manualRequest.value?.proofValue || order.value?.platformOrderId || '-')
const { resultFacts } = useSessionResultFacts({
order,
orderItem,
result,
resultFactOverrides: {
firstLabel: '唯一凭据',
firstValue: proofValue,
},
})
// Admin overrides roleFacts to include "所在大区" and exclude "任务状态"
const adminRoleFacts = 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?.role?.area || '未识别',
},
])
const canConfirmRole = computed(() =>
Boolean(
task.value &&
task.value.status === 'claimed' &&
hasSession.value &&
roleReady.value &&
!roleConfirmLoading.value,
),
)
const canRedeem = computed(() =>
Boolean(
task.value &&
['role_confirmed', 'redeeming'].includes(task.value.status) &&
!redeemLoading.value,
),
)
const redeemBlockedReason = computed(() =>
buildRedeemBlockedReason({
detail,
hasSession,
loginTypeLabel,
redeemLoading,
roleReady,
roleConfirmed,
sessionNotice,
context: 'admin',
}),
)
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成最终截图,或截图仍在同步中。'
: '客户登录并识别到角色后,这里会展示给客服复核的角色截图。',
)
const canCreateTask = computed(
() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value,
)
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
// ── Watchers ───────────────────────────────────────────────────────
watch(qrImage, () => {
// QR image natural width reset handled by useSessionQrDisplay
})
watch(
() => currentTaskId.value,
(taskId, previousTaskId) => {
if (!taskId || taskId === previousTaskId) {
return
}
void loadDetail(taskId)
},
{ immediate: true },
)
onMounted(() => {
void loadSkuSuggestions()
})
onBeforeUnmount(() => {
resetPolling()
clearScreenshotPreview()
})
// ── Helpers ────────────────────────────────────────────────────────
function restartPollingIfNeeded(nextDetail = detail.value) {
resetPolling()
if (shouldKeepPolling(nextDetail)) {
startPolling()
}
}
function resolveSkuName(skuCode: string) {
const normalized = String(skuCode || '').trim()
const matched = suggestions.value.find(
(item) => String(item.skuCode || '').trim() === normalized,
)
return matched?.skuCode || normalized
}
// ── Screenshot helpers (admin-only) ───────────────────────────────
async function loadScreenshotPreview(nextDetail = detail.value) {
const taskId = Number(nextDetail?.task?.taskId || 0)
const shouldFetch = Boolean(
taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady),
)
if (!shouldFetch) {
clearScreenshotPreview()
return
}
screenshotLoading.value = true
try {
const blob = await fetchAdminTaskScreenshot(taskId)
clearScreenshotPreview()
screenshotBlob.value = blob
screenshotUrl.value = URL.createObjectURL(blob)
} catch {
clearScreenshotPreview()
} finally {
screenshotLoading.value = false
}
}
function clearScreenshotPreview() {
screenshotBlob.value = null
if (screenshotUrl.value) {
URL.revokeObjectURL(screenshotUrl.value)
screenshotUrl.value = ''
}
}
async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) {
if (
typeof navigator === 'undefined' ||
!navigator.clipboard ||
typeof window === 'undefined' ||
!('ClipboardItem' in window)
) {
throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载')
}
try {
const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem
await navigator.clipboard.write([
new ClipboardItemCtor({
[blob.type || 'image/png']: blob,
}),
])
} catch (error) {
throw error instanceof Error ? error : new Error(fallbackMessage)
}
}
// ── API flows ─────────────────────────────────────────────────────
async function loadSkuSuggestions(keyword = '') {
try {
const response = await fetchAdminManualRedeemSkuSuggestions({
credentialType: 'tencent_code',
keyword: keyword.trim(),
})
suggestions.value = response.data.items
} catch (error) {
notifyTencentActionError(error, '读取人工兑换 SKU 建议失败')
}
}
async function loadDetail(taskId = currentTaskId.value) {
if (!taskId) {
detail.value = null
clearScreenshotPreview()
return false
}
detailLoading.value = true
try {
const response = await fetchAdminManualRedeemDetail(taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
restartPollingIfNeeded()
return true
} catch (error) {
notifyTencentActionError(error, '读取人工兑换详情失败')
return false
} finally {
detailLoading.value = false
}
}
async function createTaskNow() {
if (!canCreateTask.value) {
return
}
createLoading.value = true
try {
const response = await createAdminManualRedeemTask({
proofValue: form.proofValue.trim(),
skuCode: form.skuCode.trim(),
skuName: resolveSkuName(form.skuCode),
remark: form.remark.trim(),
})
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
await router.replace({
path: route.path,
query: {
taskId: String(response.data.task.taskId),
},
})
showSuccess(response.msg || '人工兑换任务已创建')
} catch (error) {
notifyTencentActionError(error, '创建人工兑换任务失败')
} finally {
createLoading.value = false
}
}
async function createSessionFlow(nextLoginType = loginType.value) {
if (!task.value?.taskId) {
return
}
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
loginType.value = nextLoginType
const response = await createAdminManualRedeemSession(task.value.taskId, {
loginType: nextLoginType,
forceRecreate: hasSession.value,
})
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType)
await loadScreenshotPreview(response.data)
startPolling()
} catch (error) {
notifyTencentActionError(error, '创建人工兑换登录会话失败')
} finally {
sessionLoading.value = false
}
}
async function refreshSessionFlow() {
if (!task.value?.taskId) {
return false
}
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await refreshAdminManualRedeemSession(task.value.taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
restartPollingIfNeeded(response.data)
return true
} catch (error) {
notifyTencentActionError(error, '刷新人工兑换会话失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
return false
} finally {
sessionLoading.value = false
}
}
async function closeSessionFlow({ silent = false } = {}) {
if (!task.value?.taskId) {
return false
}
if (!silent) {
sessionLoading.value = true
}
resetPolling()
resetPollingWarning()
try {
const response = await removeAdminManualRedeemSession(task.value.taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
if (!silent) {
showSuccess(response.msg || '人工兑换会话已关闭')
}
return true
} catch (error) {
if (!silent) {
notifyTencentActionError(error, '关闭人工兑换会话失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
async function refreshSessionSummary({ silent = false } = {}) {
if (!task.value?.taskId) {
return false
}
if (!silent) {
sessionLoading.value = true
resetPollingWarning()
}
try {
const currentSession = detail.value?.session || null
const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId)
detail.value = mergeSessionDetail(
detail.value,
response.data,
(currentSession, nextSession) => ({
review: nextSession.review ?? currentSession.review ?? null,
}),
)
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
if (shouldRefreshQrImage(currentSession, response.data.session)) {
const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId!)
detail.value = mergeSessionDetail(
detail.value,
fullResponse.data,
(currentSession, nextSession) => ({
review: nextSession.review ?? currentSession.review ?? null,
}),
)
loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value)
}
await loadScreenshotPreview(detail.value)
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 (!task.value?.taskId || !canConfirmRole.value) {
return
}
roleConfirmLoading.value = true
try {
const response = await confirmAdminManualRedeemRole(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
showSuccess(response.msg || '角色已确认')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '确认角色失败')
await refreshSessionSummary({ silent: true })
} finally {
roleConfirmLoading.value = false
}
}
async function redeemNow() {
if (!task.value?.taskId || !canRedeem.value) {
return
}
redeemLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await redeemAdminManualRedeem(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
showSuccess(response.msg || '兑换完成')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '人工兑换失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
} finally {
redeemLoading.value = false
}
}
async function closeTaskNow() {
if (!task.value?.taskId) {
return
}
try {
await showConfirm(
`确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`,
'关闭人工兑换',
)
} catch {
return
}
closeLoading.value = true
try {
const response = await closeAdminManualRedeemTask(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
resetPolling()
showSuccess(response.msg || '人工兑换任务已关闭')
} catch (error) {
notifyTencentActionError(error, '关闭人工兑换任务失败')
} finally {
closeLoading.value = false
}
}
async function switchLoginType(nextLoginType: TencentLoginType) {
if (nextLoginType === loginType.value) {
return
}
if (hasSession.value) {
await createSessionFlow(nextLoginType)
return
}
loginType.value = nextLoginType
}
function resetCurrentTask() {
resetPolling()
detail.value = null
clearScreenshotPreview()
void router.replace({
path: route.path,
query: {},
})
}
async function copyQrImage() {
if (!qrImage.value) {
return false
}
try {
const response = await fetch(qrImage.value)
const blob = await response.blob()
await writeImageBlobToClipboard(blob, '复制二维码失败')
showSuccess('二维码已复制到剪贴板')
return true
} catch (error) {
notifyTencentActionError(error, '复制二维码失败')
return false
}
}
async function copyScreenshot() {
if (!screenshotBlob.value) {
return false
}
try {
await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败')
showSuccess('截图已复制到剪贴板')
return true
} catch (error) {
notifyTencentActionError(error, '复制截图失败')
return false
}
}
function downloadScreenshot() {
if (!screenshotBlob.value || !screenshotUrl.value) {
return false
}
const anchor = document.createElement('a')
anchor.href = screenshotUrl.value
anchor.download =
task.value?.status === 'redeemed'
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
anchor.click()
return true
}
return {
form,
suggestions,
detail,
detailLoading,
createLoading,
sessionLoading,
roleConfirmLoading,
redeemLoading,
closeLoading,
loginType,
loginTypeLabel,
loginTabs,
session,
task,
order,
orderItem,
manualRequest,
activityInfo,
roleReady,
roleConfirmed,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
sessionNotice,
roleFacts: adminRoleFacts,
resultFacts,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
screenshotLoading,
reviewReady,
resultReady,
canCreateTask,
hasActiveTask,
createTaskNow,
loadSkuSuggestions,
createSessionFlow,
refreshSessionFlow,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
closeTaskNow,
resetCurrentTask,
handleQrImageLoad,
copyQrImage,
copyScreenshot,
downloadScreenshot,
}
}
@@ -1,451 +0,0 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { showSuccess } from '@/lib/feedback'
import {
confirmClaimRole,
createClaimSession,
fetchClaimDetail,
fetchClaimSessionSummary,
refreshClaimSession,
removeClaimSession,
redeemClaim,
} from '@/services/claim'
import type { ClaimDetailData } from '@/types/claim'
import type { TencentLoginType } from '@/types/tencent/session'
import { notifyTencentActionError } from './tencent/session-errors'
import {
useSessionPolling,
useSessionQrDisplay,
useSessionResultFacts,
buildRedeemBlockedReason,
resolveTaskStatusLabel,
syncLoginTypeFromDetail,
mergeSessionDetail,
shouldRefreshQrImage,
shouldKeepPolling,
DEFAULT_LOGIN_TYPE,
} from './shared'
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)
// ── Derived state ──────────────────────────────────────────────────
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'))
// ── Polling ────────────────────────────────────────────────────────
const {
pollWarningMessage,
startPolling,
resetPolling,
resetPollingWarning,
handleSilentPollingError,
} = useSessionPolling({
hasSession,
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
onPollTick: async () => { await refreshSessionSummary({ silent: true }) },
pollWarningPrefix: '领取状态刷新失败',
})
const sessionNotice = computed(
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
)
// ── QR display ─────────────────────────────────────────────────────
const { qrImage, qrFigureStyle, qrPreviewWidth, handleQrImageLoad } = useSessionQrDisplay({
session,
})
// ── Presentation ───────────────────────────────────────────────────
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, 'claim'),
)
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
const scanInstruction = computed(
() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`,
)
// Claim pages show 4 role facts, but original only had 3 (no area).
// Override roleFacts to match original exactly:
const claimRoleFacts = 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 } = useSessionResultFacts({
order,
orderItem,
result,
})
const canConfirmRole = computed(() =>
Boolean(
task.value &&
!task.value.requiresSupportReview &&
hasSession.value &&
roleReady.value &&
task.value.status === 'claimed',
),
)
const canRedeem = computed(() =>
Boolean(
task.value &&
!task.value.requiresSupportReview &&
hasSession.value &&
roleConfirmed.value &&
!redeemLoading.value &&
(task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
),
)
const redeemBlockedReason = computed(() =>
buildRedeemBlockedReason({
detail,
hasSession,
loginTypeLabel,
redeemLoading,
roleReady,
roleConfirmed,
sessionNotice,
context: 'claim',
tokenStatus,
requiresSupportReview: computed(() => Boolean(task.value?.requiresSupportReview)),
}),
)
const redeemButtonLabel = computed(() =>
task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换',
)
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成截图,或截图产物还未同步完成。'
: '领取完成后,如本次生成了结果截图,这里会展示。',
)
// ── Watchers ───────────────────────────────────────────────────────
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 },
)
// ── Helpers ────────────────────────────────────────────────────────
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
}
function restartPollingIfNeeded(nextDetail = detail.value) {
resetPolling()
if (shouldKeepPolling(nextDetail)) {
startPolling()
}
}
// ── API flows ─────────────────────────────────────────────────────
async function loadDetail() {
detailLoading.value = true
try {
const response = await fetchClaimDetail(token)
detail.value = mergeSessionDetail(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, {
forceRecreate: hasSession.value,
})
detail.value = mergeSessionDetail(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
startPolling()
} catch (error) {
notifyTencentActionError(error, '创建领取会话失败')
} finally {
sessionLoading.value = false
}
}
async function reloadSessionPage() {
if (!hasSession.value) {
return false
}
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await refreshClaimSession(token)
detail.value = mergeSessionDetail(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
restartPollingIfNeeded(response.data)
return true
} catch (error) {
notifyTencentActionError(error, '刷新后端页面失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
return false
} finally {
sessionLoading.value = false
}
}
async function closeSessionFlow({ silent = false } = {}) {
if (!hasSession.value) {
return false
}
if (!silent) {
sessionLoading.value = true
}
resetPolling()
resetPollingWarning()
try {
const response = await removeClaimSession(token)
detail.value = mergeSessionDetail(detail.value, response.data)
applyLoginTypeFromDetail(response.data)
if (!silent) {
showSuccess(response.msg || '领取会话已关闭')
}
return true
} catch (error) {
if (!silent) {
notifyTencentActionError(error, '关闭领取会话失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
async function refreshSessionSummary({ silent = false } = {}) {
if (!hasSession.value) {
return false
}
if (!silent) {
sessionLoading.value = true
resetPollingWarning()
}
try {
const currentSession = detail.value?.session || null
const response = await fetchClaimSessionSummary(token)
let nextDetail = mergeSessionDetail(detail.value, response.data)
detail.value = nextDetail
applyLoginTypeFromDetail(nextDetail)
if (shouldRefreshQrImage(currentSession, response.data.session)) {
const fullResponse = await fetchClaimDetail(token)
nextDetail = mergeSessionDetail(nextDetail, fullResponse.data)
detail.value = nextDetail
applyLoginTypeFromDetail(nextDetail)
}
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)
detail.value = mergeSessionDetail(detail.value, response.data)
roleConfirmed.value = true
showSuccess(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)
detail.value = mergeSessionDetail(detail.value, response.data)
showSuccess(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
}
if (hasSession.value) {
await createSessionFlow(nextLoginType)
return
}
loginType.value = nextLoginType
}
// ── Lifecycle ──────────────────────────────────────────────────────
loadDetail()
onBeforeUnmount(() => {
resetPolling()
})
return {
detailLoading,
sessionLoading,
redeemLoading,
roleConfirmLoading,
loginType,
loginTypeLabel,
loginTabs,
detail,
task,
order,
orderItem,
activityInfo,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts: claimRoleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
reloadSessionPage,
closeSessionFlow,
refreshSessionSummary,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
}
}
@@ -1,229 +0,0 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
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,
})
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)
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) {
showError('请先创建浏览器会话')
return
}
if (!redeemCode.value.trim()) {
showError('请输入兑换码')
return
}
redeemLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await redeemTencentBrowserSession(session.value.sessionId, {
code: redeemCode.value.trim(),
maxAttempts: maxAttempts.value,
})
session.value = response.data
showSuccess(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,
teardownSession,
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,
}
}
+1 -15
View File
@@ -1,22 +1,12 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
const isKuaishouMode = import.meta.env.VITE_APP_MODE === 'kuaishou'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: isKuaishouMode ? '/admin/dashboard' : '/tx/browser',
},
{
path: '/tx',
redirect: '/tx/browser',
},
{
path: '/tx/browser',
component: () => import('@/views/tx/TencentBrowserView.vue'),
redirect: '/admin/dashboard',
},
{
path: '/claim/:token',
@@ -59,10 +49,6 @@ const router = createRouter({
path: 'tasks',
component: () => import('@/views/admin/tasks/AdminTasksView.vue'),
},
{
path: 'manual-redeem',
component: () => import('@/views/admin/manual-redeem/AdminManualRedeemView.vue'),
},
{
path: 'tasks/:taskId',
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
@@ -6,6 +6,5 @@ export * from './platform-config'
export * from './orders'
export * from './tasks'
export * from './inventory'
export * from './manual-redeem'
export * from './message-deliveries'
export * from './webhook-events'
@@ -1,56 +0,0 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin'
export function fetchAdminManualRedeemSkuSuggestions(params?: Record<string, unknown>) {
return apiGet<{ items: AdminInventorySkuSuggestion[] }>(
'/api/v1/admin/manual-redeem/sku-suggestions',
params,
)
}
export function createAdminManualRedeemTask(payload: {
proofValue: string
skuCode: string
skuName?: string
remark?: string
}) {
return apiPost<AdminManualRedeemDetail>('/api/v1/admin/manual-redeem', payload)
}
export function fetchAdminManualRedeemDetail(taskId: number | string) {
return apiGet<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}`)
}
export function createAdminManualRedeemSession(
taskId: number | string,
payload: { loginType: string; forceRecreate?: boolean },
) {
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session`, payload)
}
export function fetchAdminManualRedeemSessionSummary(taskId: number | string) {
return apiGet<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session/summary`)
}
export function refreshAdminManualRedeemSession(taskId: number | string) {
return apiPost<AdminManualRedeemDetail>(
`/api/v1/admin/manual-redeem/${taskId}/session/refresh`,
{},
)
}
export function removeAdminManualRedeemSession(taskId: number | string) {
return apiDelete<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session`)
}
export function confirmAdminManualRedeemRole(taskId: number | string) {
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {})
}
export function redeemAdminManualRedeem(taskId: number | string) {
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {})
}
export function closeAdminManualRedeemTask(taskId: number | string) {
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/close`, {})
}
@@ -1,26 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminAgisoMessagingDefaults,
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
} from '@/types/admin'
export function fetchAdminAgisoShopConfigs() {
return apiGet<{
filePath: string
defaults: AdminAgisoMessagingDefaults
shops: AdminAgisoShopConfigItem[]
observedShops: AdminAgisoObservedShopItem[]
}>('/api/v1/admin/platform-config/agiso-shops')
}
export function saveAdminAgisoShopConfigs(payload: {
defaults: AdminAgisoMessagingDefaults
shops: Array<Record<string, unknown>>
}) {
return apiPost<{
filePath: string
defaults: AdminAgisoMessagingDefaults
shops: AdminAgisoShopConfigItem[]
}>('/api/v1/admin/platform-config/agiso-shops', payload)
}
@@ -1,35 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminFulfillmentBindingConfigItem,
AdminFulfillmentLookupResult,
AdminObservedProductItem,
} from '@/types/admin'
export function fetchAdminFulfillmentBindingConfigs() {
return apiGet<{
filePath: string
bindings: AdminFulfillmentBindingConfigItem[]
observedProducts: AdminObservedProductItem[]
}>('/api/v1/admin/platform-config/fulfillment-bindings')
}
export function lookupAdminFulfillmentBindingOrder(payload: {
provider?: string
platform?: string
shopId: string
platformOrderId: string
}) {
return apiPost<AdminFulfillmentLookupResult>(
'/api/v1/admin/platform-config/fulfillment-bindings/lookup-order',
payload,
)
}
export function saveAdminFulfillmentBindingConfigs(payload: {
bindings: Array<Record<string, unknown>>
}) {
return apiPost<{
filePath: string
bindings: AdminFulfillmentBindingConfigItem[]
}>('/api/v1/admin/platform-config/fulfillment-bindings', payload)
}
@@ -1,8 +1,6 @@
export * from './agiso'
export * from './notifications'
export * from './scheduled-jobs'
export * from './ninetyone'
export * from './kuaishou-eticket'
export * from './cloudtentacles'
export * from './fulfillment-bindings'
export * from './kuaishou-cloud-fulfillment'
+1 -33
View File
@@ -1,26 +1,10 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
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,
options: { forceRecreate?: boolean } = {},
) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session`, {
loginType,
forceRecreate: Boolean(options.forceRecreate),
})
}
export function fetchClaimSessionSummary(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}/session/summary`)
}
export function verifyKuaishouCloudClaimTicket(
token: string,
payload: {
@@ -37,19 +21,3 @@ export function confirmKuaishouCloudClaimRole(token: string) {
export function redeemKuaishouCloudClaim(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {})
}
export function refreshClaimSession(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session/refresh`, {})
}
export function removeClaimSession(token: string) {
return apiDelete<ClaimDetailData>(`/api/v1/claim/${token}/session`)
}
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`, {})
}
@@ -1,4 +0,0 @@
export function buildTencentBrowserSessionScreenshotUrl(sessionId: string, cacheKey = '') {
const suffix = cacheKey ? `?t=${encodeURIComponent(cacheKey)}` : ''
return `/api/v1/tencent/browser/session/${sessionId}/screenshot${suffix}`
}
@@ -1,17 +0,0 @@
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,
)
}
@@ -1,35 +0,0 @@
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}`)
}
@@ -1,6 +1,6 @@
/* ============================================================
管理员配置页面共享结构样式
引入: AdminFulfillmentBindings.css, AdminKuaishouCloudFulfillment.css
引入: AdminKuaishouCloudFulfillment.css
各页面自行覆盖颜色、圆角、主题属性
============================================================ */
@@ -1,27 +0,0 @@
import type { AdminObservedProductItem } from './platform-config/fulfillment-bindings'
export interface AdminFulfillmentLookupOrder {
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
buyerName: string
totalAmount: string
totalAmountFen: number
paidAt: string | null
enriched: boolean
enrichReason: string
errorMessage: string
}
export interface AdminFulfillmentLookupItem extends AdminObservedProductItem {
lineId: string
itemTitle: string
quantity: number
}
export interface AdminFulfillmentLookupResult {
order: AdminFulfillmentLookupOrder
items: AdminFulfillmentLookupItem[]
}
-15
View File
@@ -34,9 +34,6 @@ export type {
// Inventory types
export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory'
// Manual redeem types
export type { AdminManualRedeemDetail } from './manual-redeem'
// Webhook events types
export type {
AdminWebhookEventListItem,
@@ -47,18 +44,8 @@ export type {
// Message deliveries types
export type { AdminMessageDeliveryListItem } from './message-deliveries'
// Fulfillment types
export type {
AdminFulfillmentLookupOrder,
AdminFulfillmentLookupItem,
AdminFulfillmentLookupResult,
} from './fulfillment'
// Platform config types
export type {
AdminAgisoShopConfigItem,
AdminAgisoMessagingDefaults,
AdminAgisoObservedShopItem,
AdminNotificationBarkRecipient,
AdminNotificationWpushRecipient,
AdminNotificationConfig,
@@ -92,8 +79,6 @@ export type {
AdminCloudtentaclesValidateSessionResult,
AdminCloudtentaclesSkuItem,
AdminCloudtentaclesSkuListResult,
AdminFulfillmentBindingConfigItem,
AdminObservedProductItem,
AdminKuaishouCloudFulfillmentItem,
AdminKuaishouCloudFulfillmentConfig,
} from './platform-config'
@@ -1,9 +0,0 @@
import type { ClaimDetailData } from '../claim'
export interface AdminManualRedeemDetail extends ClaimDetailData {
manualRequest: {
sourceType: string
proofValue: string
remark: string
}
}
@@ -1,26 +0,0 @@
export interface AdminAgisoShopConfigItem {
shopId: string
shopName: string
AccessToken: string
accessTokenMasked: string
enabled: boolean | null
messageTemplate: string
autoDeliveryMessageTemplate: string
appSecretConfigured: boolean
apiVersion: string
sendMessageEndpoint: string
}
export interface AdminAgisoMessagingDefaults {
messageTemplate: string
autoDeliveryMessageTemplate: string
}
export interface AdminAgisoObservedShopItem {
shopId: string
detectedShopName: string
displayShopName: string
latestSeenAt: string | null
webhookEventCount: number
configured: boolean
}
@@ -1,36 +0,0 @@
export interface AdminFulfillmentBindingConfigItem {
provider: string
platform: string
shopId: string
shopName: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
config: Record<string, unknown>
match: {
externalSkuCode: string
externalItemId: string
externalSkuName: string
config: Record<string, unknown>
}
}
export interface AdminObservedProductItem {
provider: string
platform: string
shopId: string
shopName: string
externalItemId: string
externalSkuCode: string
externalSkuName: string
latestSeenAt: string | null
orderItemCount: number
configured: boolean
matchedBinding: null | {
skuCode: string
skuName: string
profileKey: string
}
}
@@ -1,9 +1,3 @@
export type {
AdminAgisoShopConfigItem,
AdminAgisoMessagingDefaults,
AdminAgisoObservedShopItem,
} from './agiso'
export type {
AdminNotificationBarkRecipient,
AdminNotificationWpushRecipient,
@@ -52,11 +46,6 @@ export type {
AdminCloudtentaclesSkuListResult,
} from './cloudtentacles'
export type {
AdminFulfillmentBindingConfigItem,
AdminObservedProductItem,
} from './fulfillment-bindings'
export type {
AdminKuaishouCloudFulfillmentItem,
AdminKuaishouCloudFulfillmentConfig,
+2 -4
View File
@@ -1,5 +1,3 @@
import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session'
export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {})
export type ClaimTaskStatus =
@@ -128,11 +126,11 @@ export interface ClaimKuaishouCloudFlowInfo {
export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus
claimUrl: string
flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {})
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {})
task: ClaimTaskInfo
order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo
session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null
session: unknown | null
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
result: ClaimResultInfo | null
}
@@ -1,51 +0,0 @@
export interface TencentBrowserSessionArtifacts {
hasQrImage: boolean
hasScreenshot: boolean
}
export interface TencentBrowserReviewInfo {
capturedAt: string
roleId: string
roleName: string
}
export interface TencentBrowserRoleInfo {
ready: boolean
roleName: string
roleId: string
area: string
partition: string
platId: string
md5str: string
checkparam: string
}
export interface TencentBrowserFormInfo {
cdkeyInputId: string
cdkeyValue: string
verifyInputId: string
verifyValue: string
verifyImgId: string
submitId: string
}
export interface TencentBrowserVerifyInfo {
visible: boolean
src: string
naturalWidth: number
naturalHeight: number
}
export interface TencentBrowserPopupInfo {
visible: boolean
text: string
detail: string
}
export interface TencentBrowserActivityInfo {
nickname: string
role: TencentBrowserRoleInfo
form: TencentBrowserFormInfo
verify: TencentBrowserVerifyInfo
popup: TencentBrowserPopupInfo
}
-12
View File
@@ -1,12 +0,0 @@
export interface TencentBrowserRedeemAttempt {
attempt: number
redeem?: Record<string, unknown> | null
}
export interface TencentBrowserRedeemResult {
code: string
area: string
attempts: TencentBrowserRedeemAttempt[]
final?: TencentBrowserRedeemAttempt | null
finishedAt: string
}
@@ -1,45 +0,0 @@
import type {
TencentBrowserActivityInfo,
TencentBrowserReviewInfo,
TencentBrowserSessionArtifacts,
} from './activity'
import type { TencentBrowserRedeemResult } from './redeem'
export type TencentLoginType = 'qq' | 'wx'
export type TencentBrowserSessionStatus =
| 'waiting_scan'
| 'scanned'
| 'expired'
| 'logged_in'
| 'ready_to_redeem'
| 'redeeming'
| 'redeemed'
| 'closed'
| '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
review?: TencentBrowserReviewInfo | null
redeem?: TencentBrowserRedeemResult | null
artifacts: TencentBrowserSessionArtifacts
}
export interface TencentBrowserSessionSummaryData extends Omit<
TencentBrowserSessionData,
'qrImageBase64'
> {
qrImageBase64?: string
}
+2 -2
View File
@@ -3,8 +3,8 @@
<div class="card">
<p class="code">404</p>
<h1>页面不存在</h1>
<p>当前前端只保留腾讯浏览器兑换页这个地址没有对应页面</p>
<RouterLink to="/tx">前往兑换入口</RouterLink>
<p>当前快手轻量前端没有对应页面</p>
<RouterLink to="/admin/dashboard">前往后台</RouterLink>
</div>
</main>
</template>
@@ -1,45 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminFulfillmentBindingsView from './bindings/AdminFulfillmentBindingsView.vue'
import AdminKuaishouCloudFulfillmentView from './kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue'
type FulfillmentTabKey = 'legacy' | 'kuaishou-cloud'
const route = useRoute()
const router = useRouter()
const activeTab = computed<FulfillmentTabKey>({
get() {
const value = String(route.query.tab || '').trim()
return value === 'kuaishou-cloud' ? 'kuaishou-cloud' : 'legacy'
},
set(value) {
const next = value === 'kuaishou-cloud' ? 'kuaishou-cloud' : 'legacy'
void router.replace({
path: '/admin/platform-fulfillment',
query: next === 'legacy' ? {} : { tab: next },
})
},
})
</script>
<template>
<section class="hub-panel">
<AdminPageHeader
title="履约配置中心"
description="维护外部商品到内部 SKU 的映射规则,旧规则和快手 Cloud 新流程都在这里配置。"
description="维护 91 卡券快手商品到 Cloud 履约资源的映射规则。"
/>
<el-tabs v-model="activeTab" class="hub-tabs">
<el-tab-pane label="旧履约规则" name="legacy">
<AdminFulfillmentBindingsView />
</el-tab-pane>
<el-tab-pane label="快手 Cloud 新履约" name="kuaishou-cloud">
<AdminKuaishouCloudFulfillmentView />
</el-tab-pane>
</el-tabs>
<AdminKuaishouCloudFulfillmentView />
</section>
</template>
@@ -48,21 +18,4 @@ const activeTab = computed<FulfillmentTabKey>({
display: grid;
gap: var(--space-4);
}
:deep(.hub-tabs .el-tabs__header) {
margin-bottom: 0;
}
:deep(.hub-tabs .el-tabs__content) {
padding-top: var(--space-4);
}
:deep(.hub-tabs .el-tabs__item) {
font-weight: 500;
font-size: var(--text-base);
}
:deep(.hub-tabs .el-tabs__item.is-active) {
font-weight: 600;
}
</style>
@@ -1,290 +0,0 @@
.admin-panel {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border-default);
background: var(--bg-surface);
}
.section-card :deep(.el-card__header) {
background: var(--bg-surface);
border-bottom: 1px solid var(--border-default);
padding: var(--space-4) var(--space-5);
}
.section-card :deep(.el-card__body) {
padding: var(--space-5);
}
.card-header {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card-header--split {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.card-title {
display: block;
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.card-desc {
display: block;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.5;
}
.overview-card {
display: grid;
gap: var(--space-5);
}
.overview-file {
margin-top: var(--space-1);
}
.overview-file code {
color: var(--text-primary);
word-break: break-all;
font-size: var(--text-sm);
}
.overview-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-4);
}
.overview-stat {
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.overview-stat :deep(.el-statistic__head) {
margin-bottom: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overview-stat :deep(.el-statistic__content) {
color: var(--text-primary);
font-size: var(--text-2xl);
font-weight: 700;
}
.overview-notes,
.section-mini-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.lookup-toolbar,
.lookup-summary {
display: grid;
gap: var(--space-4);
}
.lookup-toolbar {
grid-template-columns: 96px 110px minmax(140px, 1fr) minmax(180px, 1.2fr) 136px;
align-items: end;
}
.lookup-summary {
margin-top: var(--space-1);
}
.field-help {
display: block;
margin-top: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.field-wide {
grid-column: 1 / -1;
}
.text-input {
width: 100%;
}
.admin-panel :deep(.text-input--invalid .el-input__wrapper),
.admin-panel :deep(.text-input--invalid .el-select__wrapper) {
border-color: var(--color-danger);
box-shadow: 0 0 0 1px var(--color-danger) inset;
}
.admin-panel :deep(.el-form-item) {
margin-bottom: 0;
}
.admin-panel :deep(.el-form-item__label) {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: 600;
line-height: 1.5;
margin-bottom: var(--space-2);
}
.cell-subtle {
color: var(--text-secondary);
display: block;
font-size: var(--text-xs);
line-height: 1.6;
}
.cell-stack {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.binding-card {
margin-top: var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
}
.binding-card :deep(.el-card__header) {
padding: var(--space-3) var(--space-4);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-default);
}
.binding-card :deep(.el-card__body) {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.binding-card--invalid {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
}
.binding-header {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.binding-summary {
display: grid;
gap: var(--space-2);
flex: 1;
}
.binding-title-row {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
color: var(--text-primary);
font-size: var(--text-base);
}
.binding-subtle {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.binding-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4) var(--space-5);
}
.binding-collapsed-preview {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.binding-collapsed-preview :deep(.el-descriptions__label) {
font-weight: 600;
}
.binding-actions {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--border-default);
}
@media (max-width: 900px) {
.card-header--split,
.binding-actions,
.binding-header {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
gap: var(--space-3);
}
.overview-stats,
.lookup-toolbar,
.binding-grid {
grid-template-columns: 1fr;
}
.binding-collapsed-preview :deep(.el-descriptions__body .el-descriptions__table) {
display: block;
}
.section-card :deep(.el-card__header),
.section-card :deep(.el-card__body) {
padding: var(--space-4);
}
.binding-card :deep(.el-card__header),
.binding-card :deep(.el-card__body) {
padding: var(--space-3);
}
}
.data-table {
margin-top: var(--space-4);
}
.data-table :deep(.el-table__header th) {
background: var(--bg-surface);
color: var(--text-secondary);
font-weight: 600;
font-size: var(--text-sm);
}
.data-table :deep(.el-table__body td) {
padding: var(--space-3) var(--space-2);
}
.mt-4 {
margin-top: var(--space-4);
}
.w-full {
width: 100%;
}
@@ -1,73 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { hasAdminRole } from '@/utils/admin-auth'
import AdminBindingsLookupSection from './components/AdminBindingsLookupSection.vue'
import AdminBindingsObservedSection from './components/AdminBindingsObservedSection.vue'
import AdminBindingsRuleSection from './components/AdminBindingsRuleSection.vue'
import { useFulfillmentBindings } from './composables/useFulfillmentBindings'
import { useFulfillmentBindingsLookup } from './composables/useFulfillmentBindingsLookup'
const bindings = useFulfillmentBindings()
const lookup = useFulfillmentBindingsLookup(bindings)
onMounted(bindings.loadConfigs)
</script>
<template>
<section class="admin-panel">
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以维护履约配置" />
<template v-else>
<el-alert
v-if="bindings.errorMessage.value"
:title="bindings.errorMessage.value"
type="error"
show-icon
:closable="false"
/>
<el-skeleton v-if="bindings.loading.value" :rows="12" animated />
<template v-else>
<AdminBindingsRuleSection
:saving="bindings.saving.value"
:validation-state="bindings.validationState.value"
:bindings="bindings.bindings.value"
:rule-metrics="bindings.ruleMetrics.value"
:is-binding-collapsed="bindings.isBindingCollapsed"
:is-editable-binding-complete="bindings.isEditableBindingComplete"
:get-binding-title="bindings.getBindingTitle"
:get-binding-status-label="bindings.getBindingStatusLabel"
:get-binding-summary="bindings.getBindingSummary"
:is-binding-invalid="bindings.isBindingInvalid"
:is-field-invalid="bindings.isFieldInvalid"
@add-binding="bindings.addBinding()"
@load-configs="bindings.loadConfigs()"
@save-configs="bindings.saveConfigs()"
@toggle-collapsed="bindings.toggleBindingCollapsed($event)"
@clear-validation="bindings.clearValidationState()"
@remove-binding="bindings.removeBinding($event)"
/>
<AdminBindingsLookupSection
:lookup-loading="lookup.lookupLoading.value"
:lookup-error-message="lookup.lookupErrorMessage.value"
:lookup-result="lookup.lookupResult.value"
:lookup-form="lookup.lookupForm"
:is-lookup-product-imported="lookup.isLookupProductImported"
@lookup="lookup.lookupOrderProducts()"
@import-product="lookup.importLookupProduct($event)"
/>
<AdminBindingsObservedSection
:observed-products="bindings.observedProducts.value"
:pending-observed-count="bindings.pendingObservedCount.value"
@import-product="bindings.importObservedProduct($event)"
/>
</template>
</template>
</section>
</template>
<style scoped src="./AdminFulfillmentBindings.css"></style>
@@ -1,213 +0,0 @@
<script setup lang="ts">
import { PROFILE_OPTIONS } from '../composables/types'
import type { EditableBinding, ValidationField, ValidationState } from '../composables/types'
defineProps<{
binding: EditableBinding
index: number
validationState: ValidationState
isBindingCollapsed: boolean
isComplete: boolean
title: string
statusLabel: string
summary: string
isInvalid: boolean
isFieldInvalid: (
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
) => boolean
}>()
const emit = defineEmits<{
toggleCollapsed: []
clearValidation: []
remove: []
}>()
</script>
<template>
<el-card
:data-binding-id="binding.id"
:class="['binding-card', { 'binding-card--invalid': isInvalid }]"
shadow="never"
>
<template #header>
<div class="binding-header">
<div class="binding-summary">
<div class="binding-title-row">
<strong>{{ title }}</strong>
<el-tag :type="isComplete ? 'success' : 'warning'" effect="plain">
{{ statusLabel }}
</el-tag>
</div>
<span class="binding-subtle">{{ summary }}</span>
</div>
<el-button link type="primary" @click="emit('toggleCollapsed')">
{{ isBindingCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</template>
<el-descriptions v-if="isBindingCollapsed" :column="3" border class="binding-collapsed-preview">
<el-descriptions-item label="外部商品">
{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="内部履约 SKU">
{{ binding.skuCode || '-' }}
</el-descriptions-item>
<el-descriptions-item label="履约方式">
{{
PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label ||
binding.profileKey
}}
</el-descriptions-item>
</el-descriptions>
<el-form v-else label-position="top" class="binding-grid">
<el-form-item label="渠道">
<el-input
v-model="binding.provider"
class="text-input"
placeholder="agiso"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="平台">
<el-input
v-model="binding.platform"
class="text-input"
placeholder="xianyu / taobao / pdd"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="店铺 ID">
<el-input
v-model="binding.shopId"
:class="[{ 'text-input--invalid': isFieldInvalid('shopId') }]"
class="text-input"
data-field="shopId"
placeholder="留空表示跨店铺通用"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="店铺名称">
<el-input
v-model="binding.shopName"
class="text-input"
placeholder="用于保存和识别店铺"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="优先级">
<el-input-number
v-model="binding.priority"
class="text-input"
:min="1"
controls-position="right"
@change="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="外部 SKU 编码">
<el-input
v-model="binding.externalSkuCode"
:class="[{ 'text-input--invalid': isFieldInvalid('externalSkuCode') }]"
class="text-input"
data-field="externalSkuCode"
placeholder="例如 6046726460016"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="外部商品 ID">
<el-input
v-model="binding.externalItemId"
:class="[{ 'text-input--invalid': isFieldInvalid('externalItemId') }]"
class="text-input"
data-field="externalItemId"
placeholder="例如 1033324289962"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="外部商品名" class="field-wide">
<el-input
v-model="binding.externalSkuName"
:class="[{ 'text-input--invalid': isFieldInvalid('externalSkuName') }]"
class="text-input"
data-field="externalSkuName"
placeholder="例如 奥利奥动作"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="内部履约 SKU" class="field-with-help">
<el-input
v-model="binding.skuCode"
:class="[{ 'text-input--invalid': isFieldInvalid('skuCode') }]"
class="text-input"
data-field="skuCode"
placeholder="例如 sjz_hdl_test01"
@input="emit('clearValidation')"
/>
<small class="field-help"
>填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small
>
</el-form-item>
<el-form-item label="内部商品名">
<el-input
v-model="binding.skuName"
class="text-input"
placeholder="用于后台展示"
@input="emit('clearValidation')"
/>
</el-form-item>
<el-form-item label="履约方式">
<el-select
v-model="binding.profileKey"
:class="[{ 'text-input--invalid': isFieldInvalid('profileKey') }]"
class="text-input"
data-field="profileKey"
@change="emit('clearValidation')"
>
<el-option
v-for="item in PROFILE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="匹配后展示名">
<el-input
v-model="binding.resolvedSkuName"
class="text-input"
placeholder="例如 三角洲行动-自动领取"
@input="emit('clearValidation')"
/>
</el-form-item>
</el-form>
<el-alert
v-if="isInvalid"
:title="validationState?.message"
type="error"
show-icon
:closable="false"
class="mt-4"
/>
<div class="binding-actions">
<el-checkbox v-model="binding.enabled">启用规则</el-checkbox>
<el-button link type="danger" @click="emit('remove')">删除</el-button>
</div>
</el-card>
</template>
<style scoped src="../AdminFulfillmentBindings.css"></style>
@@ -1,156 +0,0 @@
<script setup lang="ts">
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
defineProps<{
lookupLoading: boolean
lookupErrorMessage: string
lookupResult: AdminFulfillmentLookupResult | null
lookupForm: {
shopId: string
platformOrderId: string
}
isLookupProductImported: (lineId: string) => boolean
}>()
const emit = defineEmits<{
lookup: []
importProduct: [item: AdminFulfillmentLookupItem]
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header card-header--split">
<div>
<span class="card-title">手动补查订单商品</span>
<span class="card-desc"
>新商品还没配规则时可以按店铺 ID 和平台订单号临时补查查完直接导入规则草稿</span
>
</div>
<el-tag effect="plain" size="small">仅支持 Agiso 咸鱼订单</el-tag>
</div>
</template>
<el-form label-position="top" class="lookup-toolbar">
<el-form-item label="渠道">
<el-input class="text-input" model-value="agiso" disabled />
</el-form-item>
<el-form-item label="平台">
<el-input class="text-input" model-value="xianyu" disabled />
</el-form-item>
<el-form-item label="店铺 ID">
<el-input
v-model="lookupForm.shopId"
class="text-input"
placeholder="例如 252609"
@keydown.enter.prevent="emit('lookup')"
/>
</el-form-item>
<el-form-item label="平台订单号">
<el-input
v-model="lookupForm.platformOrderId"
class="text-input"
placeholder="输入需要补查的订单号"
@keydown.enter.prevent="emit('lookup')"
/>
</el-form-item>
<el-form-item label="操作">
<el-button :loading="lookupLoading" type="primary" class="w-full" @click="emit('lookup')">
查询订单商品
</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="lookupErrorMessage"
:title="lookupErrorMessage"
type="error"
show-icon
:closable="false"
/>
<el-skeleton v-else-if="lookupLoading" :rows="4" animated />
<template v-else-if="lookupResult">
<div class="lookup-summary">
<el-descriptions :column="3" border>
<el-descriptions-item label="订单">
<strong>{{ lookupResult.order.platformOrderId }}</strong>
<span class="cell-subtle">{{
lookupResult.order.shopName || lookupResult.order.shopId || '-'
}}</span>
</el-descriptions-item>
<el-descriptions-item label="买家">
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
<span class="cell-subtle"
>支付时间{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span
>
</el-descriptions-item>
<el-descriptions-item label="金额">
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
<span class="cell-subtle">
补查结果{{
lookupResult.order.enriched ? '已补全' : lookupResult.order.enrichReason || '已返回'
}}
</span>
</el-descriptions-item>
</el-descriptions>
</div>
<el-table
:data="lookupResult.items"
class="data-table element-data-table"
empty-text="暂无订单商品"
>
<el-table-column label="外部商品" min-width="260">
<template #default="{ row: item }">
<div class="cell-stack">
<strong>{{
item.externalSkuName ||
item.itemTitle ||
item.externalSkuCode ||
item.externalItemId ||
'-'
}}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="90" />
<el-table-column label="识别结果" min-width="220">
<template #default="{ row: item }">
<div class="cell-stack">
<strong>{{ item.configured ? '已配置' : '未配置' }}</strong>
<span v-if="item.matchedBinding" class="cell-subtle">
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} /
{{ item.matchedBinding.profileKey || '-' }}
</span>
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row: item }">
<el-button
v-if="!item.configured && !isLookupProductImported(item.lineId)"
link
type="primary"
@click="emit('importProduct', item)"
>
导入到规则
</el-button>
<span v-else-if="item.configured" class="cell-subtle">已存在</span>
<span v-else class="cell-subtle">已导入草稿</span>
</template>
</el-table-column>
</el-table>
</template>
</el-card>
</template>
<style scoped src="../AdminFulfillmentBindings.css"></style>
@@ -1,90 +0,0 @@
<script setup lang="ts">
import type { AdminObservedProductItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import type { ImportableProductCandidate } from '../composables/types'
defineProps<{
observedProducts: AdminObservedProductItem[]
pendingObservedCount: number
}>()
const emit = defineEmits<{
importProduct: [item: ImportableProductCandidate]
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header card-header--split">
<div>
<span class="card-title">近期识别到的外部商品</span>
<span class="card-desc"
>从已入库订单里提取的外部商品信息优先处理未配置商品导入后再补齐内部 SKU</span
>
</div>
<el-space wrap>
<el-tag type="warning" effect="plain" size="small"
>待补 {{ pendingObservedCount }}</el-tag
>
<el-tag effect="plain" size="small">总计 {{ observedProducts.length }}</el-tag>
</el-space>
</div>
</template>
<el-table
:data="observedProducts"
class="data-table element-data-table"
empty-text="最近还没有识别到可用的外部商品"
>
<el-table-column label="来源" min-width="180">
<template #default="{ row: item }">
<div class="cell-stack">
<strong>{{ item.platform || '-' }}</strong>
<span class="cell-subtle"
>{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="外部商品" min-width="280">
<template #default="{ row: item }">
<div class="cell-stack">
<strong>{{
item.externalSkuName || item.externalSkuCode || item.externalItemId || '-'
}}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="orderItemCount" label="订单商品数" width="120" />
<el-table-column label="最近出现时间" width="180">
<template #default="{ row: item }">
{{ formatAdminDateTime(item.latestSeenAt) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row: item }">
{{ item.configured ? '已配置' : '未配置' }}
</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row: item }">
<el-button
v-if="!item.configured"
link
type="primary"
@click="emit('importProduct', item)"
>
导入到规则
</el-button>
<span v-else class="cell-subtle">已存在</span>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<style scoped src="../AdminFulfillmentBindings.css"></style>
@@ -1,48 +0,0 @@
<script setup lang="ts">
defineProps<{
filePath: string
ruleMetrics: {
totalCount: number
completedCount: number
draftCount: number
manualCount: number
assistedCount: number
kuaishouCloudCount: number
}
pendingObservedCount: number
observedProductsLength: number
}>()
</script>
<template>
<el-card shadow="never" class="section-card overview-card">
<template #header>
<div class="card-header">
<span class="card-title">旧履约规则概览</span>
<span class="card-desc">按外部商品信息匹配内部履约 SKU 和交付方式</span>
</div>
</template>
<el-descriptions :column="1" border class="overview-file">
<el-descriptions-item label="配置文件">
<code>{{ filePath || '-' }}</code>
</el-descriptions-item>
</el-descriptions>
<div class="overview-stats">
<el-statistic class="overview-stat" title="商品总数" :value="ruleMetrics.totalCount" />
<el-statistic class="overview-stat" title="完整规则" :value="ruleMetrics.completedCount" />
<el-statistic class="overview-stat" title="待完善" :value="ruleMetrics.draftCount" />
<el-statistic class="overview-stat" title="待补商品" :value="pendingObservedCount" />
<el-statistic class="overview-stat" title="已忽略商品" :value="observedProductsLength" />
</div>
<div class="overview-notes">
<el-tag effect="plain" size="small">外部 SKU / 商品 ID / 商品名命中任一即可</el-tag>
<el-tag effect="plain" size="small">内部 SKU 决定库存绑定与履约链路</el-tag>
<el-tag effect="plain" size="small">同类商品跨平台尽量共用同一内部 SKU</el-tag>
</div>
</el-card>
</template>
<style scoped src="../AdminFulfillmentBindings.css"></style>
@@ -1,106 +0,0 @@
<script setup lang="ts">
import type { EditableBinding, ValidationField, ValidationState } from '../composables/types'
import AdminBindingRuleCard from './AdminBindingRuleCard.vue'
defineProps<{
saving: boolean
validationState: ValidationState
bindings: EditableBinding[]
ruleMetrics: {
completedCount: number
draftCount: number
manualCount: number
assistedCount: number
kuaishouCloudCount: number
}
isBindingCollapsed: (bindingId: string) => boolean
isEditableBindingComplete: (binding: EditableBinding) => boolean
getBindingTitle: (binding: EditableBinding, index: number) => string
getBindingStatusLabel: (binding: EditableBinding) => string
getBindingSummary: (binding: EditableBinding) => string
isBindingInvalid: (bindingId: string) => boolean
isFieldInvalid: (
bindingId: string,
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
) => boolean
}>()
const emit = defineEmits<{
addBinding: []
loadConfigs: []
saveConfigs: []
toggleCollapsed: [bindingId: string]
clearValidation: []
removeBinding: [bindingId: string]
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header card-header--split">
<div>
<span class="card-title">已配置规则</span>
<span class="card-desc"
>正式规则集中维护在这里新补查到的商品也会导入到这个区域继续补全</span
>
</div>
<el-space wrap>
<el-button @click="emit('addBinding')">新增规则</el-button>
<el-button @click="emit('loadConfigs')">刷新</el-button>
<el-button :loading="saving" type="primary" @click="emit('saveConfigs')">
保存配置
</el-button>
</el-space>
</div>
</template>
<div class="section-mini-stats">
<el-tag type="success" effect="plain" size="small"
>完整 {{ ruleMetrics.completedCount }}</el-tag
>
<el-tag type="warning" effect="plain" size="small"
>待完善 {{ ruleMetrics.draftCount }}</el-tag
>
<el-tag effect="plain" size="small">人工 {{ ruleMetrics.manualCount }}</el-tag>
<el-tag effect="plain" size="small">半自动 {{ ruleMetrics.assistedCount }}</el-tag>
<el-tag effect="plain" size="small">快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}</el-tag>
</div>
<el-alert
v-if="validationState?.message"
:title="validationState.message"
type="error"
show-icon
:closable="false"
class="mt-4"
/>
<el-empty
v-if="bindings.length === 0"
description="当前还没有履约规则,先新增一条。"
:image-size="80"
/>
<AdminBindingRuleCard
v-for="(binding, index) in bindings"
:key="binding.id"
:binding="binding"
:index="index"
:validation-state="validationState"
:is-binding-collapsed="isBindingCollapsed(binding.id)"
:is-complete="isEditableBindingComplete(binding)"
:title="getBindingTitle(binding, index)"
:status-label="getBindingStatusLabel(binding)"
:summary="getBindingSummary(binding)"
:is-invalid="isBindingInvalid(binding.id)"
:is-field-invalid="(field) => isFieldInvalid(binding.id, field)"
@toggle-collapsed="emit('toggleCollapsed', binding.id)"
@clear-validation="emit('clearValidation')"
@remove="emit('removeBinding', binding.id)"
/>
</el-card>
</template>
<style scoped src="../AdminFulfillmentBindings.css"></style>
@@ -1,66 +0,0 @@
import type { AdminObservedProductItem } from '@/types/admin'
export type EditableBinding = {
id: string
provider: string
platform: string
shopId: string
shopName: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
externalSkuCode: string
externalItemId: string
externalSkuName: string
resolvedSkuName: string
}
export type SaveBindingPayload = {
provider: string
platform: string
shopId: string
shopName: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
match: {
externalSkuCode: string
externalItemId: string
externalSkuName: string
config: {
resolvedSkuName: string
}
}
}
export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId'
export type ValidationState = {
bindingId: string
field: ValidationField
message: string
} | null
export type ImportableProductCandidate = Pick<
AdminObservedProductItem,
| 'provider'
| 'platform'
| 'shopId'
| 'shopName'
| 'externalSkuCode'
| 'externalItemId'
| 'externalSkuName'
> & {}
export const PROFILE_OPTIONS = [
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
{ label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' },
{ label: '人工发货', value: 'manual_review' },
]
export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
@@ -1,453 +0,0 @@
import { computed, nextTick, ref } from 'vue'
import { showSuccess } from '@/lib/feedback'
import {
fetchAdminFulfillmentBindingConfigs,
saveAdminFulfillmentBindingConfigs,
} from '@/services/admin'
import type {
AdminFulfillmentBindingConfigItem,
AdminObservedProductItem,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import {
VALID_PROFILE_KEYS,
type EditableBinding,
type ImportableProductCandidate,
type SaveBindingPayload,
type ValidationField,
type ValidationState,
} from './types'
export function useFulfillmentBindings() {
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const filePath = ref('')
const bindings = ref<EditableBinding[]>([])
const observedProducts = ref<AdminObservedProductItem[]>([])
const validationState = ref<ValidationState>(null)
const collapsedBindingIds = ref<string[]>([])
// ── computed ──────────────────────────────────────────
const ruleMetrics = computed(() => {
const completedCount = bindings.value.filter(isEditableBindingComplete).length
const manualCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'manual_review',
).length
const assistedCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'tencent_claim_assisted',
).length
const kuaishouCloudCount = bindings.value.filter(
(item) => item.profileKey.trim() === 'kuaishou_ct_assisted',
).length
return {
totalCount: bindings.value.length,
completedCount,
draftCount: Math.max(bindings.value.length - completedCount, 0),
manualCount,
assistedCount,
kuaishouCloudCount,
}
})
const pendingObservedCount = computed(
() => observedProducts.value.filter((item) => !item.configured).length,
)
// ── factories ────────────────────────────────────────
function createEmptyBinding(): EditableBinding {
return {
id: crypto.randomUUID(),
provider: 'agiso',
platform: '',
shopId: '',
shopName: '',
skuCode: '',
skuName: '',
profileKey: 'manual_review',
enabled: true,
priority: 100,
externalSkuCode: '',
externalItemId: '',
externalSkuName: '',
resolvedSkuName: '',
}
}
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
return {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopName: item.shopName || '',
skuCode: item.skuCode,
skuName: item.skuName,
profileKey: item.profileKey || 'manual_review',
enabled: item.enabled !== false,
priority: item.priority || 100,
externalSkuCode: item.match.externalSkuCode,
externalItemId: item.match.externalItemId,
externalSkuName: item.match.externalSkuName,
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
}
}
// ── validation ────────────────────────────────────────
function hasMatchCondition(
item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>,
) {
return Boolean(
item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(),
)
}
function isEditableBindingComplete(item: EditableBinding) {
return Boolean(
item.skuCode.trim() &&
hasMatchCondition(item) &&
VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'),
)
}
// ── collapse state ────────────────────────────────────
function buildCollapsedIds(items: EditableBinding[]) {
return items.filter(isEditableBindingComplete).map((item) => item.id)
}
function isBindingCollapsed(bindingId: string) {
return collapsedBindingIds.value.includes(bindingId)
}
function setBindingCollapsed(bindingId: string, collapsed: boolean) {
const next = new Set(collapsedBindingIds.value)
if (collapsed) {
next.add(bindingId)
} else {
next.delete(bindingId)
}
collapsedBindingIds.value = Array.from(next)
}
function toggleBindingCollapsed(bindingId: string) {
setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId))
}
// ── display helpers ───────────────────────────────────
function getBindingTitle(binding: EditableBinding, index: number) {
const internalName = binding.skuName.trim()
const externalName = binding.externalSkuName.trim()
const skuCode = binding.skuCode.trim()
return internalName || externalName || skuCode || `规则 ${index + 1}`
}
function getBindingStatusLabel(binding: EditableBinding) {
return isEditableBindingComplete(binding) ? '已完成' : '待完善'
}
function getBindingSummary(binding: EditableBinding) {
const parts = [
binding.provider.trim() || 'agiso',
binding.platform.trim() || '未选平台',
binding.shopName.trim() || binding.shopId.trim() || '跨店铺',
binding.profileKey.trim() || 'manual_review',
]
return parts.join(' / ')
}
// ── data loading ──────────────────────────────────────
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminFulfillmentBindingConfigs()
filePath.value = response.data.filePath
const nextBindings = response.data.bindings.map(mapEditableBinding)
bindings.value = nextBindings
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
observedProducts.value = response.data.observedProducts
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败'
} finally {
loading.value = false
}
}
// ── mutation ──────────────────────────────────────────
function addBinding() {
validationState.value = null
const next = createEmptyBinding()
setBindingCollapsed(next.id, false)
bindings.value.unshift(next)
}
function removeBinding(id: string) {
if (validationState.value?.bindingId === id) {
validationState.value = null
}
setBindingCollapsed(id, false)
bindings.value = bindings.value.filter((item) => item.id !== id)
}
// ── import from observed products ────────────────────
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
const binding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopName: item.shopName,
skuCode: '',
skuName: item.externalSkuName,
profileKey: 'manual_review',
enabled: true,
priority: 100,
externalSkuCode: item.externalSkuCode,
externalItemId: item.externalItemId,
externalSkuName: item.externalSkuName,
resolvedSkuName: item.externalSkuName,
}
return binding
}
function importObservedProduct(item: ImportableProductCandidate) {
validationState.value = null
const nextBinding = createBindingFromProductCandidate(item)
setBindingCollapsed(nextBinding.id, false)
bindings.value.unshift(nextBinding)
}
// ── save ──────────────────────────────────────────────
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
return {
provider: item.provider.trim() || 'agiso',
platform: item.platform.trim(),
shopId: item.shopId.trim(),
shopName: item.shopName.trim(),
skuCode: item.skuCode.trim(),
skuName: item.skuName.trim(),
profileKey: item.profileKey.trim() || 'manual_review',
enabled: item.enabled,
priority: Number(item.priority || 100),
match: {
externalSkuCode: item.externalSkuCode.trim(),
externalItemId: item.externalItemId.trim(),
externalSkuName: item.externalSkuName.trim(),
config: {
resolvedSkuName: item.resolvedSkuName.trim(),
},
},
}
}
function hasBindingContent(item: SaveBindingPayload) {
return Boolean(
item.platform ||
item.shopId ||
item.shopName ||
item.skuCode ||
item.skuName ||
item.match.externalSkuCode ||
item.match.externalItemId ||
item.match.externalSkuName ||
item.match.config.resolvedSkuName ||
item.provider !== 'agiso' ||
item.profileKey !== 'manual_review' ||
item.priority !== 100 ||
item.enabled !== true,
)
}
function resolveBindingValidationState(
item: SaveBindingPayload,
index: number,
bindingId: string,
): ValidationState {
const label = `${index + 1} 条规则`
if (!item.skuCode) {
return {
bindingId,
field: 'skuCode',
message: `${label} 还没填写"内部履约 SKU"`,
}
}
if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) {
return {
bindingId,
field: 'match',
message: `${label} 至少填写一种外部匹配条件`,
}
}
if (!VALID_PROFILE_KEYS.has(item.profileKey)) {
return {
bindingId,
field: 'profileKey',
message: `${label} 的履约方式无效`,
}
}
return null
}
async function focusValidationTarget(state: ValidationState) {
if (!state?.bindingId) {
return
}
setBindingCollapsed(state.bindingId, false)
await nextTick()
const card = document.querySelector<HTMLElement>(`[data-binding-id="${state.bindingId}"]`)
if (!card) {
return
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
const selectors: Record<ValidationField, string> = {
shopId: '[data-field="shopId"]',
skuCode: '[data-field="skuCode"]',
match: '[data-field="externalSkuCode"]',
profileKey: '[data-field="profileKey"]',
}
const target = card.querySelector<HTMLInputElement | HTMLSelectElement>(selectors[state.field])
target?.focus()
}
function clearValidationState() {
validationState.value = null
}
function isBindingInvalid(bindingId: string) {
return validationState.value?.bindingId === bindingId
}
function isFieldInvalid(
bindingId: string,
field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName',
) {
if (validationState.value?.bindingId !== bindingId) {
return false
}
if (validationState.value.field === 'match') {
return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field)
}
return validationState.value.field === field
}
async function saveConfigs() {
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
const invalidState = normalizedBindings.reduce<ValidationState>((state, item, index) => {
if (state || !hasBindingContent(item)) {
return state
}
return resolveBindingValidationState(item, index, bindings.value[index]?.id || '')
}, null)
if (invalidState) {
validationState.value = invalidState
await focusValidationTarget(invalidState)
return
}
if (bindings.value.length > 0 && nonEmptyBindings.length === 0) {
validationState.value = {
bindingId: '',
field: 'skuCode',
message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件',
}
return
}
const payload = nonEmptyBindings
saving.value = true
validationState.value = null
try {
const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload })
filePath.value = response.data.filePath
bindings.value = response.data.bindings.map(mapEditableBinding)
showSuccess('履约配置已保存')
await loadConfigs()
} catch (error) {
validationState.value = {
bindingId: '',
field: 'skuCode',
message: error instanceof Error ? error.message : '保存履约配置失败',
}
} finally {
saving.value = false
}
}
return {
// state
loading,
saving,
errorMessage,
filePath,
bindings,
observedProducts,
validationState,
collapsedBindingIds,
// computed
ruleMetrics,
pendingObservedCount,
// factories
createEmptyBinding,
mapEditableBinding,
// validation
isEditableBindingComplete,
clearValidationState,
isBindingInvalid,
isFieldInvalid,
// collapse
isBindingCollapsed,
setBindingCollapsed,
toggleBindingCollapsed,
// display
getBindingTitle,
getBindingStatusLabel,
getBindingSummary,
// data
loadConfigs,
addBinding,
removeBinding,
importObservedProduct,
createBindingFromProductCandidate,
normalizeBindingForSave,
saveConfigs,
}
}
@@ -1,70 +0,0 @@
import { reactive, ref } from 'vue'
import { lookupAdminFulfillmentBindingOrder } from '@/services/admin'
import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin'
import type { useFulfillmentBindings } from './useFulfillmentBindings'
export function useFulfillmentBindingsLookup(
bindings: ReturnType<typeof useFulfillmentBindings>,
) {
const lookupLoading = ref(false)
const lookupErrorMessage = ref('')
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
const importedLookupLineIds = ref<string[]>([])
const lookupForm = reactive({
shopId: '',
platformOrderId: '',
})
function importLookupProduct(item: AdminFulfillmentLookupItem) {
bindings.importObservedProduct(item)
if (!importedLookupLineIds.value.includes(item.lineId)) {
importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId]
}
}
function isLookupProductImported(lineId: string) {
return importedLookupLineIds.value.includes(lineId)
}
async function lookupOrderProducts() {
const shopId = lookupForm.shopId.trim()
const platformOrderId = lookupForm.platformOrderId.trim()
if (!shopId || !platformOrderId) {
lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号'
return
}
lookupLoading.value = true
lookupErrorMessage.value = ''
try {
const response = await lookupAdminFulfillmentBindingOrder({
provider: 'agiso',
platform: 'xianyu',
shopId,
platformOrderId,
})
lookupResult.value = response.data
importedLookupLineIds.value = []
} catch (error) {
lookupResult.value = null
lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败'
} finally {
lookupLoading.value = false
}
}
return {
lookupLoading,
lookupErrorMessage,
lookupResult,
importedLookupLineIds,
lookupForm,
importLookupProduct,
isLookupProductImported,
lookupOrderProducts,
}
}
@@ -11,7 +11,6 @@ import {
Fold,
List,
Message,
Service,
Setting,
SwitchButton,
Tickets,
@@ -63,10 +62,7 @@ const navItems = computed(() => {
items.push({ to: '/admin/inventory', label: '库存', icon: Box })
}
items.push(
{ to: '/admin/manual-redeem', label: '人工兑换', icon: Service },
{ to: '/admin/message-deliveries', label: '消息发送', icon: Message },
)
items.push({ to: '/admin/message-deliveries', label: '消息发送', icon: Message })
if (isAdmin.value || isOperator.value) {
items.push({ to: '/admin/webhook-events', label: 'Webhook', icon: Connection })
@@ -1,275 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useAdminManualRedeemPage } from '@/composables/useAdminManualRedeemPage'
import AdminManualRedeemCreateCard from './components/AdminManualRedeemCreateCard.vue'
import AdminManualRedeemSummaryCard from './components/AdminManualRedeemSummaryCard.vue'
import AdminManualRedeemReviewCard from './components/AdminManualRedeemReviewCard.vue'
const {
form,
suggestions,
detail,
detailLoading,
createLoading,
sessionLoading,
roleConfirmLoading,
redeemLoading,
closeLoading,
loginType,
loginTypeLabel,
loginTabs,
task,
order,
orderItem,
manualRequest,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
screenshotLoading,
reviewReady,
resultReady,
canCreateTask,
hasActiveTask,
createTaskNow,
createSessionFlow,
refreshSessionFlow,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
closeTaskNow,
resetCurrentTask,
handleQrImageLoad,
copyQrImage,
copyScreenshot,
downloadScreenshot,
} = useAdminManualRedeemPage()
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
const reviewCardTitle = computed(() =>
task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图',
)
const canStartNextTask = computed(() =>
Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)),
)
const taskDetailLink = computed(() =>
task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : '',
)
</script>
<template>
<section class="admin-panel manual-redeem-panel">
<AdminPageHeader
title="人工兑换"
description="客服在后台创建唯一任务、发送二维码、复核角色并完成兑换。"
>
<template v-if="taskDetailLink">
<RouterLink :to="taskDetailLink" class="task-link">查看任务详情</RouterLink>
</template>
<el-button
v-if="hasActiveTask"
:loading="closeLoading"
plain
type="danger"
@click="closeTaskNow"
>
关闭当前任务
</el-button>
<el-button v-if="canStartNextTask" plain type="primary" @click="resetCurrentTask">
开始下一单
</el-button>
</AdminPageHeader>
<AdminManualRedeemCreateCard
:form="form"
:suggestions="suggestions"
:can-create-task="canCreateTask"
:create-loading="createLoading"
:create-task-now="createTaskNow"
/>
<AdminManualRedeemSummaryCard
:task="task"
:order="order"
:order-item="orderItem"
:manual-request="manualRequest"
:status-label="statusLabel"
/>
<el-skeleton v-if="detailLoading && !detail" :rows="8" animated />
<div v-else-if="detail" 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-copy-button-label="复制当前二维码"
:qr-copy-enabled="true"
: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"
@close-session="closeSessionFlow"
@copy-qr="copyQrImage"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="refreshSessionFlow"
@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="false"
: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>
<AdminManualRedeemReviewCard
v-if="detail"
:review-card-title="reviewCardTitle"
:review-ready="reviewReady"
:result-ready="resultReady"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-url="screenshotUrl"
:screenshot-loading="screenshotLoading"
:copy-screenshot="copyScreenshot"
:download-screenshot="downloadScreenshot"
:open-preview="() => (screenshotPreviewVisible = true)"
/>
<TencentResultPanel
v-if="detail && task?.status === 'redeemed'"
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="Boolean(screenshotUrl)"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
<el-empty
v-else-if="!detail && !detailLoading"
description="先在上方输入唯一凭据和内部 SKU系统会立即校验唯一性并预占库存"
>
<template #default>
<strong>还没有进行中的人工兑换任务</strong>
</template>
</el-empty>
<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="`Admin Manual Redeem ${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="Manual redeem screenshot preview" />
</el-dialog>
</section>
</template>
<style scoped>
.manual-redeem-panel {
display: grid;
gap: 18px;
}
.task-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
gap: var(--space-4);
align-items: stretch;
}
.control-column {
display: grid;
}
.preview-image {
display: block;
width: 100%;
height: auto;
}
@media (max-width: 900px) {
.layout-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,117 +0,0 @@
<script setup lang="ts">
type Props = {
form: {
proofValue: string
skuCode: string
remark: string
}
suggestions: {
credentialType: string
inventoryGroupCode: string
skuCode: string
availableCount: number
totalCount: number
}[]
canCreateTask: boolean
createLoading: boolean
createTaskNow: () => void | Promise<void>
}
defineProps<Props>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-head">
<div>
<span class="card-title">1. 创建人工兑换任务</span>
<span class="card-desc"
>先录入唯一凭据并锁定库存后续二维码角色确认和兑换都基于这条任务继续</span
>
</div>
</div>
</template>
<el-form label-position="top" class="form-grid">
<el-form-item label="唯一凭据">
<el-input v-model="form.proofValue" placeholder="输入订单号、客户名或其他唯一可追踪信息" />
</el-form-item>
<el-form-item label="内部履约 SKU">
<el-input
v-model="form.skuCode"
list="manual-redeem-sku-suggestions"
placeholder="选择或输入内部履约 SKU"
/>
<datalist id="manual-redeem-sku-suggestions">
<option
v-for="item in suggestions"
:key="`${item.credentialType}-${item.inventoryGroupCode}-${item.skuCode}`"
:value="item.skuCode"
:label="`${item.skuCode} · ${item.inventoryGroupCode || '未分组'} · 可用 ${item.availableCount} / 共 ${item.totalCount}`"
/>
</datalist>
</el-form-item>
<el-form-item label="备注" class="field-remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="可选:记录客户补充信息、兑换要求或客服备注"
/>
</el-form-item>
</el-form>
<div class="action-row">
<el-button
:disabled="!canCreateTask"
:loading="createLoading"
round
type="primary"
@click="createTaskNow"
>
创建任务并预占库存
</el-button>
</div>
</el-card>
</template>
<style scoped>
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.form-grid {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.section-card :deep(.el-form-item__label) {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: 600;
}
.field-remark {
grid-column: 1 / -1;
}
.action-row {
margin-top: 18px;
display: flex;
gap: var(--space-3);
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,87 +0,0 @@
<script setup lang="ts">
type Props = {
reviewCardTitle: string
reviewReady: boolean
resultReady: boolean
screenshotEmptyTitle: string
screenshotEmptyMessage: string
screenshotUrl: string
screenshotLoading: boolean
copyScreenshot: () => void
downloadScreenshot: () => void
openPreview: () => void
}
defineProps<Props>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-head">
<div>
<span class="card-title">2. {{ reviewCardTitle }}</span>
<span v-if="reviewReady" class="card-desc">
客户登录后系统会把当前角色截图回传到这里客服复制后可直接发给客户做二次确认
</span>
<span v-else-if="resultReady" class="card-desc">
最终兑换完成后客服可以把结果截图直接发给客户确认
</span>
<span v-else class="card-desc">
{{ screenshotEmptyMessage }}
</span>
</div>
<el-space wrap>
<el-button :disabled="!screenshotUrl || screenshotLoading" plain @click="copyScreenshot">
复制图片
</el-button>
<el-button
:disabled="!screenshotUrl || screenshotLoading"
plain
@click="downloadScreenshot"
>
下载图片
</el-button>
</el-space>
</div>
</template>
<div v-if="screenshotUrl" class="review-stage">
<el-image
class="screenshot-preview"
:src="screenshotUrl"
alt="manual redeem screenshot"
fit="contain"
@click="openPreview"
/>
</div>
<el-empty v-else :description="screenshotEmptyMessage">
<template #default>
<strong>{{ screenshotEmptyTitle || '' }}</strong>
</template>
</el-empty>
</el-card>
</template>
<style scoped>
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.review-stage {
margin-top: 18px;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-default);
background: var(--bg-page);
}
.screenshot-preview {
width: 100%;
cursor: zoom-in;
}
</style>
@@ -1,56 +0,0 @@
<script setup lang="ts">
type TaskDetail = {
taskNo?: string
taskId?: string | number
status?: string
}
type Order = {
platformOrderId?: string
}
type OrderItem = {
skuName?: string
}
type ManualRequest = {
proofValue?: string
}
type Props = {
task: TaskDetail | null
order: Order | null
orderItem: OrderItem | null
manualRequest: ManualRequest | null
statusLabel: string
}
defineProps<Props>()
</script>
<template>
<el-card v-if="task" shadow="never" class="section-card">
<el-descriptions :column="4" border>
<el-descriptions-item label="唯一凭据">
{{ manualRequest?.proofValue || order?.platformOrderId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="任务号">
{{ task.taskNo || '-' }}
</el-descriptions-item>
<el-descriptions-item label="商品">
{{ orderItem?.skuName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="当前状态">
{{ statusLabel }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<style scoped>
@media (max-width: 900px) {
.section-card :deep(.el-descriptions__body .el-descriptions__table) {
display: block;
}
}
</style>
@@ -1,22 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { RefreshRight } from '@element-plus/icons-vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import {
fetchAdminAgisoShopConfigs,
fetchAdminCloudtentaclesSourceConfig,
fetchAdminKuaishouEticketSourceConfig,
fetchAdminNotificationConfig,
fetchAdminScheduledJobsConfig,
} from '@/services/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import AdminPlatformAgisoSection from './components/AdminPlatformAgisoSection.vue'
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
import { useAdminAgisoPlatform } from './composables/useAdminAgisoPlatform'
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
@@ -26,26 +23,7 @@ import type { PlatformTab } from './composables/types'
const loading = ref(true)
const isDevMode = import.meta.env.DEV
const errorMessage = ref('')
const activePlatform = ref<PlatformTab>('agiso')
const {
saving,
agisoFilePath,
defaults,
shops,
observedShops,
agisoStats,
hydrateAgisoConfig,
addShop,
removeShop,
importObservedShop,
isShopExpanded,
toggleShop,
expandAllShops,
collapseAllShops,
describeShop,
saveAgisoConfigs: persistAgisoConfigs,
} = useAdminAgisoPlatform()
const activePlatform = ref<PlatformTab>('ninetyone')
const {
ninetyoneLoading,
@@ -164,6 +142,55 @@ const {
handleCloudtentaclesRunFullFlow,
} = useAdminCloudtentaclesPlatform()
const overviewCards = computed(() => {
const cards = [
{
key: 'notifications',
label: '内部通知',
tag: 'Bark / WPush',
value:
notificationStats.value.barkEnabledRecipientCount +
notificationStats.value.wpushEnabledRecipientCount,
unit: '启用接收人',
meta: `Bark ${notificationStats.value.barkConfiguredRecipientCount} 人 · WPush ${notificationStats.value.wpushConfiguredRecipientCount} 人 · 最近成功 ${notificationStats.value.lastSuccessCount}`,
},
{
key: 'ninetyone',
label: '91卡券',
tag: '接入 / 待补全',
value: ninetyoneStats.value.pendingCount,
unit: '待补全订单',
meta: `当前 ${ninetyoneStats.value.currentCount} 条 · 已生成任务 ${ninetyoneStats.value.taskReadyCount}`,
},
{
key: 'kuaishouEticket',
label: '快手小店核销',
tag: 'Cookie / 券核销',
value: kuaishouEticketStats.value.configuredShopCount,
unit: '已配置店铺',
meta: `Cookie 就绪 ${kuaishouEticketStats.value.cookieReadyCount} 家 · 当前店铺 ${
kuaishouEticketStats.value.selectedShopReady ? '可调试' : '待补全'
}`,
},
...(isDevMode
? [
{
key: 'cloudtentacles',
label: 'cloudtentacles',
tag: '履约 / 登录',
value: cloudtentaclesStats.value.hasCredential ? '已配置' : '未配置',
unit: '履约凭据',
meta: `短信 ${cloudtentaclesStats.value.smsSent ? '已发送' : '未发送'} · 会话 ${
cloudtentaclesStats.value.validated ? '已验证' : '未验证'
}`,
},
]
: []),
]
return cards
})
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
@@ -175,20 +202,17 @@ async function loadConfigs() {
try {
const [
agisoResponse,
notificationResponse,
scheduledJobsResponse,
kuaishouEticketResponse,
cloudtentaclesResponse,
] = await Promise.all([
fetchAdminAgisoShopConfigs(),
fetchAdminNotificationConfig(),
fetchAdminScheduledJobsConfig(),
fetchAdminKuaishouEticketSourceConfig(),
fetchAdminCloudtentaclesSourceConfig(),
])
hydrateAgisoConfig(agisoResponse.data)
hydrateNotificationConfig(notificationResponse.data)
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
@@ -201,10 +225,6 @@ async function loadConfigs() {
}
}
async function saveAgisoConfigs() {
await persistAgisoConfigs(loadConfigs)
}
function switchTab(tab: string) {
activePlatform.value = tab as PlatformTab
}
@@ -221,9 +241,6 @@ onMounted(loadConfigs)
><el-icon><RefreshRight /></el-icon
></template>
</el-button>
<el-button v-if="activePlatform === 'agiso'" type="primary" @click="addShop">
新增 Agiso 店铺
</el-button>
</template>
</AdminPageHeader>
@@ -253,58 +270,7 @@ onMounted(loadConfigs)
<!-- 概览卡片 -->
<div class="overview-grid">
<el-card
v-for="card in [
{
key: 'agiso',
label: 'Agiso',
tag: 'Webhook / 店铺',
value: agisoStats.configuredShopCount,
unit: '已配置店铺',
meta: `识别到 ${agisoStats.observedShopCount} 家 · 待补 ${agisoStats.pendingObservedCount} 家`,
},
{
key: 'notifications',
label: '内部通知',
tag: 'Bark / WPush',
value:
notificationStats.barkEnabledRecipientCount +
notificationStats.wpushEnabledRecipientCount,
unit: '启用接收人',
meta: `Bark ${notificationStats.barkConfiguredRecipientCount} 人 · WPush ${notificationStats.wpushConfiguredRecipientCount} 人 · 最近成功 ${notificationStats.lastSuccessCount} 个`,
},
{
key: 'ninetyone',
label: '91卡券',
tag: '接入 / 待补全',
value: ninetyoneStats.pendingCount,
unit: '待补全订单',
meta: `当前 ${ninetyoneStats.currentCount} 条 · 已生成任务 ${ninetyoneStats.taskReadyCount} 条`,
},
{
key: 'kuaishouEticket',
label: '快手小店核销',
tag: 'Cookie / 券核销',
value: kuaishouEticketStats.configuredShopCount,
unit: '已配置店铺',
meta: `Cookie 就绪 ${kuaishouEticketStats.cookieReadyCount} 家 · 当前店铺 ${
kuaishouEticketStats.selectedShopReady ? '可调试' : '待补全'
}`,
},
...(isDevMode
? [
{
key: 'cloudtentacles',
label: 'cloudtentacles',
tag: '履约 / 登录',
value: cloudtentaclesStats.hasCredential ? '已配置' : '未配置',
unit: '履约凭据',
meta: `短信 ${cloudtentaclesStats.smsSent ? '已发送' : '未发送'} · 会话 ${
cloudtentaclesStats.validated ? '已验证' : '未验证'
}`,
},
]
: []),
]"
v-for="card in overviewCards"
:key="card.key"
:class="['overview-card', { 'is-active': activePlatform === card.key }]"
shadow="hover"
@@ -322,24 +288,6 @@ onMounted(loadConfigs)
<!-- 平台切换 Tab -->
<el-tabs v-model="activePlatform" type="card" class="platform-tabs">
<el-tab-pane label="Agiso 店铺与消息" name="agiso">
<AdminPlatformAgisoSection
:agiso-file-path="agisoFilePath"
:defaults="defaults"
:shops="shops"
:observed-shops="observedShops"
:saving="saving"
:is-shop-expanded="isShopExpanded"
:toggle-shop="toggleShop"
:expand-all-shops="expandAllShops"
:collapse-all-shops="collapseAllShops"
:describe-shop="describeShop"
:remove-shop="removeShop"
:import-observed-shop="importObservedShop"
:save-agiso-configs="saveAgisoConfigs"
/>
</el-tab-pane>
<el-tab-pane label="91卡券接入" name="ninetyone">
<AdminPlatformNinetyoneSection
:ninetyone-loading="ninetyoneLoading"
@@ -1,264 +0,0 @@
<script setup lang="ts">
import type { AdminAgisoObservedShopItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import type { EditableDefaults, EditableShop } from '../composables/types'
type Props = {
agisoFilePath: string
defaults: EditableDefaults
shops: EditableShop[]
observedShops: AdminAgisoObservedShopItem[]
saving: boolean
isShopExpanded: (id: string) => boolean
toggleShop: (id: string) => void
expandAllShops: () => void
collapseAllShops: () => void
describeShop: (shop: EditableShop) => string
removeShop: (id: string) => void
importObservedShop: (item: AdminAgisoObservedShopItem) => void
saveAgisoConfigs: () => void | Promise<void>
}
defineProps<Props>()
</script>
<template>
<div class="platform-section">
<!-- 配置信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">
<code>{{ agisoFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="职责">
管理 Agiso 默认消息模板店铺 AccessToken 以及店铺级模板覆盖
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 默认消息模板 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">默认消息模板</span>
<p class="card-desc">
Agiso 店铺未单独配置时统一使用这里的领取消息与自动发货消息模板
</p>
</div>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-form-item label="默认领取链接消息模板">
<el-input
v-model="defaults.messageTemplate"
type="textarea"
:rows="3"
placeholder="例如:您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}"
/>
</el-form-item>
<el-form-item label="默认自动发货消息模板">
<el-input
v-model="defaults.autoDeliveryMessageTemplate"
type="textarea"
:rows="3"
placeholder="例如:您的订单 {platformOrderId} 已完成自动发货,请注意查收。"
/>
</el-form-item>
</el-form>
</el-card>
<!-- 店铺列表 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">Agiso 店铺列表</span>
<p class="card-desc">
每家店铺可单独配置 AccessToken 和模板覆盖建议先用"近期识别到的店铺"导入再补
AccessToken
</p>
</div>
<div class="card-actions">
<el-button :disabled="shops.length === 0" @click="expandAllShops">全部展开</el-button>
<el-button :disabled="shops.length === 0" @click="collapseAllShops">全部折叠</el-button>
<el-button :loading="saving" type="primary" @click="saveAgisoConfigs"
>保存 Agiso 配置</el-button
>
</div>
</div>
</template>
<el-empty v-if="shops.length === 0" description="当前还没有 Agiso 店铺配置,先新增一条。" />
<el-collapse v-else :model-value="shops.filter((s) => isShopExpanded(s.id)).map((s) => s.id)">
<el-collapse-item v-for="shop in shops" :key="shop.id" :name="shop.id">
<template #title>
<div class="shop-title">
<strong>{{ shop.shopName || shop.shopId || '未命名店铺' }}</strong>
<span class="shop-subtitle">ID: {{ shop.shopId || '未填写' }}</span>
<el-tag
:type="shop.enabled ? 'success' : 'info'"
size="small"
class="shop-status-tag"
>
{{ shop.enabled ? '已启用' : '已停用' }}
</el-tag>
<span class="shop-brief">{{ describeShop(shop) }}</span>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="店铺 ID">
<el-input v-model="shop.shopId" placeholder="例如 693760716" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺名称">
<el-input v-model="shop.shopName" placeholder="例如 大锤商行" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="AccessToken">
<el-input v-model="shop.accessToken" placeholder="AldsIdle..." />
</el-form-item>
<el-form-item label="领取链接消息模板">
<el-input
v-model="shop.messageTemplate"
type="textarea"
:rows="2"
placeholder="留空则沿用默认领取链接模板"
/>
</el-form-item>
<el-form-item label="自动发货消息模板">
<el-input
v-model="shop.autoDeliveryMessageTemplate"
type="textarea"
:rows="2"
placeholder="留空则沿用默认自动发货模板"
/>
</el-form-item>
</el-form>
<div class="shop-footer">
<el-switch v-model="shop.enabled" active-text="启用店铺级覆盖" />
<el-button type="danger" size="small" @click="removeShop(shop.id)">删除</el-button>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 近期识别到的店铺 -->
<el-card class="section-card">
<template #header>
<div>
<span class="card-title">近期识别到的 Agiso 店铺</span>
<p class="card-desc">从 webhook 自动识别出的店铺,可一键导入到上方店铺列表。</p>
</div>
</template>
<el-table :data="observedShops" stripe size="small">
<el-table-column label="店铺" min-width="180">
<template #default="{ row }">
<div>
<div>{{ row.displayShopName || row.shopId }}</div>
<div class="text-muted-xs">ID: {{ row.shopId }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="webhookEventCount" label="Webhook 次数" width="120" align="center" />
<el-table-column label="最近出现时间" width="180">
<template #default="{ row }">{{ formatAdminDateTime(row.latestSeenAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.configured ? 'success' : 'warning'" size="small">
{{ row.configured ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button
v-if="!row.configured"
size="small"
type="primary"
@click="importObservedShop(row)"
>
导入到店铺
</el-button>
<span v-else class="text-muted">已存在</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="最近还没有识别到可用店铺" :image-size="60" />
</template>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.shop-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
padding-right: var(--space-4);
}
.shop-title strong {
white-space: nowrap;
}
.shop-subtitle {
font-size: var(--text-xs);
color: var(--text-muted);
}
.shop-status-tag {
margin-left: auto;
margin-right: var(--space-3);
}
.shop-brief {
font-size: var(--text-xs);
color: var(--text-muted);
}
.shop-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-default);
}
.text-muted {
color: var(--text-muted);
}
</style>
@@ -1,25 +1,9 @@
export type PlatformTab =
| 'agiso'
| 'notifications'
| 'ninetyone'
| 'kuaishouEticket'
| 'cloudtentacles'
export type EditableDefaults = {
messageTemplate: string
autoDeliveryMessageTemplate: string
}
export type EditableShop = {
id: string
shopId: string
shopName: string
accessToken: string
enabled: boolean
messageTemplate: string
autoDeliveryMessageTemplate: string
}
export type EditableKuaishouEticketShop = {
id: string
shopId: string
@@ -1,203 +0,0 @@
import { computed, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import { saveAdminAgisoShopConfigs } from '@/services/admin'
import type {
AdminAgisoMessagingDefaults,
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
} from '@/types/admin'
import type { EditableDefaults, EditableShop } from './types'
export function useAdminAgisoPlatform() {
const saving = ref(false)
const agisoFilePath = ref('')
const defaults = ref<EditableDefaults>(createEmptyDefaults())
const shops = ref<EditableShop[]>([])
const observedShops = ref<AdminAgisoObservedShopItem[]>([])
const expandedShopIds = ref<string[]>([])
const agisoStats = computed(() => ({
configuredShopCount: shops.value.length,
observedShopCount: observedShops.value.length,
pendingObservedCount: observedShops.value.filter((item) => !item.configured).length,
}))
function hydrateAgisoConfig(data: {
filePath: string
defaults: AdminAgisoMessagingDefaults
shops: AdminAgisoShopConfigItem[]
observedShops: AdminAgisoObservedShopItem[]
}) {
agisoFilePath.value = data.filePath
defaults.value = mapEditableDefaults(data.defaults)
shops.value = data.shops.map(mapEditableShop)
observedShops.value = data.observedShops
expandedShopIds.value = []
}
function addShop() {
const shop = createEmptyShop()
shops.value.unshift(shop)
expandShop(shop.id)
}
function removeShop(id: string) {
shops.value = shops.value.filter((item) => item.id !== id)
collapseShop(id)
}
function importObservedShop(item: AdminAgisoObservedShopItem) {
const shop: EditableShop = {
id: crypto.randomUUID(),
shopId: item.shopId,
shopName: item.detectedShopName || item.displayShopName || '',
accessToken: '',
enabled: true,
messageTemplate: '',
autoDeliveryMessageTemplate: '',
}
shops.value.unshift(shop)
expandShop(shop.id)
}
function isShopExpanded(id: string) {
return expandedShopIds.value.includes(id)
}
function expandShop(id: string) {
if (expandedShopIds.value.includes(id)) {
return
}
expandedShopIds.value = [id, ...expandedShopIds.value]
}
function collapseShop(id: string) {
expandedShopIds.value = expandedShopIds.value.filter((item) => item !== id)
}
function toggleShop(id: string) {
if (isShopExpanded(id)) {
collapseShop(id)
return
}
expandShop(id)
}
function expandAllShops() {
expandedShopIds.value = shops.value.map((item) => item.id)
}
function collapseAllShops() {
expandedShopIds.value = []
}
function describeShop(shop: EditableShop) {
const overrides: string[] = []
if (shop.messageTemplate.trim()) {
overrides.push('领取模板')
}
if (shop.autoDeliveryMessageTemplate.trim()) {
overrides.push('发货模板')
}
return overrides.length === 0 ? '使用默认模板' : `已覆盖 ${overrides.join('、')}`
}
async function saveAgisoConfigs(reloadConfigs: () => Promise<void>) {
const payloadDefaults = {
messageTemplate: defaults.value.messageTemplate.trim(),
autoDeliveryMessageTemplate: defaults.value.autoDeliveryMessageTemplate.trim(),
}
const payload = shops.value
.map((item) => ({
shopId: item.shopId.trim(),
shopName: item.shopName.trim(),
accessToken: item.accessToken.trim(),
enabled: item.enabled,
messageTemplate: item.messageTemplate.trim(),
autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate.trim(),
}))
.filter((item) => item.shopId && item.accessToken)
saving.value = true
try {
const response = await saveAdminAgisoShopConfigs({
defaults: payloadDefaults,
shops: payload,
})
agisoFilePath.value = response.data.filePath
defaults.value = mapEditableDefaults(response.data.defaults)
shops.value = response.data.shops.map(mapEditableShop)
showSuccess('Agiso 配置已保存')
await reloadConfigs()
} catch (error) {
showError(error instanceof Error ? error.message : '保存 Agiso 配置失败')
} finally {
saving.value = false
}
}
return {
saving,
agisoFilePath,
defaults,
shops,
observedShops,
expandedShopIds,
agisoStats,
hydrateAgisoConfig,
addShop,
removeShop,
importObservedShop,
isShopExpanded,
expandShop,
collapseShop,
toggleShop,
expandAllShops,
collapseAllShops,
describeShop,
saveAgisoConfigs,
}
}
function createEmptyDefaults(): EditableDefaults {
return {
messageTemplate: '',
autoDeliveryMessageTemplate: '',
}
}
function createEmptyShop(): EditableShop {
return {
id: crypto.randomUUID(),
shopId: '',
shopName: '',
accessToken: '',
enabled: true,
messageTemplate: '',
autoDeliveryMessageTemplate: '',
}
}
function mapEditableDefaults(item?: AdminAgisoMessagingDefaults): EditableDefaults {
return {
messageTemplate: item?.messageTemplate ?? '',
autoDeliveryMessageTemplate: item?.autoDeliveryMessageTemplate ?? '',
}
}
function mapEditableShop(item: AdminAgisoShopConfigItem): EditableShop {
return {
id: crypto.randomUUID(),
shopId: item.shopId,
shopName: item.shopName,
accessToken: item.AccessToken,
enabled: item.enabled !== false,
messageTemplate: item.messageTemplate,
autoDeliveryMessageTemplate: item.autoDeliveryMessageTemplate,
}
}
@@ -1,357 +0,0 @@
<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,
reloadSessionPage,
closeSessionFlow,
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 '正在加载领取任务'
}
if (task.value.requiresSupportReview) {
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
}
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"
@close-session="closeSessionFlow"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@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 && !task?.requiresSupportReview"
: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.9);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 22px 60px rgba(30, 41, 59, 0.08);
}
.order-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.order-meta article {
padding: 14px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.16);
}
.order-meta span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
.order-meta strong {
font-size: 16px;
color: #0f172a;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr);
gap: 18px;
align-items: start;
}
.control-column {
display: grid;
gap: 18px;
}
.empty-block {
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #475569;
}
.loading-block strong {
display: block;
margin-bottom: 6px;
color: #0f172a;
}
.preview-dialog :deep(.el-dialog__body) {
padding: 0;
}
.preview-image {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 20px;
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
}
.qr-preview-image {
max-width: 100%;
max-height: min(78vh, 920px);
}
@media (max-width: 980px) {
.claim-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.order-meta,
.layout-grid {
grid-template-columns: 1fr;
}
}
</style>
+2 -79
View File
@@ -1,90 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { fetchClaimDetail } from '@/services/claim'
import ClaimTencentView from './ClaimTencentView.vue'
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const loading = ref(true)
const errorMessage = ref('')
const flowType = ref('tencent_claim')
async function bootstrap() {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchClaimDetail(token.value)
flowType.value = String(
response.data.flowType || response.data.task.executorKey || 'tencent_claim',
).trim()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
} finally {
loading.value = false
}
}
const isKuaishouCloudFlow = computed(
() => flowType.value === 'kuaishou_cloud' || flowType.value === 'kuaishou_ct_assisted',
)
onMounted(() => {
void bootstrap()
})
</script>
<template>
<main v-if="loading" class="claim-shell loading-shell">
<section class="shell-card">
<strong>页面加载中</strong>
<p>正在识别领取流程类型</p>
</section>
</main>
<main v-else-if="errorMessage" class="claim-shell error-shell">
<section class="shell-card">
<strong>无法打开领取页</strong>
<p>{{ errorMessage }}</p>
</section>
</main>
<KuaishouCloudClaimView v-else-if="isKuaishouCloudFlow" :token="token" />
<ClaimTencentView v-else />
<KuaishouCloudClaimView :token="token" />
</template>
<style scoped>
.claim-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
}
.shell-card {
width: min(520px, 100%);
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
text-align: center;
}
.shell-card strong {
display: block;
margin-bottom: 10px;
color: #0f172a;
}
.shell-card p {
margin: 0;
color: #475569;
}
</style>
@@ -1,303 +0,0 @@
<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,
teardownSession,
} = 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"
@close-session="teardownSession"
@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>