清理快手前端无用入口
This commit is contained in:
+22
-46
@@ -1,64 +1,40 @@
|
|||||||
# 腾讯浏览器兑换前端
|
# 快手轻量前端
|
||||||
|
|
||||||
这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责。
|
这是快手自动发货拆分后的 Vue 3 前端,只保留快手领取和运营后台相关入口。
|
||||||
|
|
||||||
## 功能范围
|
## 功能范围
|
||||||
|
|
||||||
- 提供 `/tx/browser` 单页入口
|
- `/claim/:token` 快手 Cloud 领取页
|
||||||
- 支持 QQ / 微信扫码登录切换
|
- `/admin` 运营后台
|
||||||
- 支持浏览器会话创建、状态轮询、角色确认、兑换提交
|
- 91 卡券订单补全与任务生成
|
||||||
- 支持结果信息展示,以及在后端产物存在时预览截图
|
- 快手小店核销配置
|
||||||
|
- Cloudtentacles 履约配置与调试
|
||||||
|
- 内部通知和定时任务配置
|
||||||
|
|
||||||
## 项目原则
|
## 已移除内容
|
||||||
|
|
||||||
- 保持页面职责单一,只服务腾讯浏览器兑换链路
|
- 腾讯浏览器调试入口 `/tx/browser`
|
||||||
- 与本地后端接口保持一致,优先保证稳定性和可维护性
|
- 腾讯领取页 fallback
|
||||||
- 页面层尽量薄,状态编排、轮询、展示映射分别收口
|
- 人工兑换页面
|
||||||
|
- 旧履约规则配置页
|
||||||
|
- Agiso 店铺消息配置前端入口
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
安装依赖并启动开发环境:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/yml/codes/order-site-rewrite
|
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
构建生产包:
|
开发环境默认通过 `VITE_API_TARGET` 代理后端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
VITE_API_TARGET=http://localhost:3000 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
npm run build
|
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,所有状态都围绕单页流程局部管理
|
|
||||||
|
|||||||
Vendored
-6
@@ -25,7 +25,6 @@ declare module 'vue' {
|
|||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
@@ -33,7 +32,6 @@ declare module 'vue' {
|
|||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
@@ -47,7 +45,6 @@ declare module 'vue' {
|
|||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
@@ -57,9 +54,6 @@ declare module 'vue' {
|
|||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
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 {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
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,22 +1,12 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||||
|
|
||||||
const isKuaishouMode = import.meta.env.VITE_APP_MODE === 'kuaishou'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: isKuaishouMode ? '/admin/dashboard' : '/tx/browser',
|
redirect: '/admin/dashboard',
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/tx',
|
|
||||||
redirect: '/tx/browser',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/tx/browser',
|
|
||||||
component: () => import('@/views/tx/TencentBrowserView.vue'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/claim/:token',
|
path: '/claim/:token',
|
||||||
@@ -59,10 +49,6 @@ const router = createRouter({
|
|||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
component: () => import('@/views/admin/tasks/AdminTasksView.vue'),
|
component: () => import('@/views/admin/tasks/AdminTasksView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'manual-redeem',
|
|
||||||
component: () => import('@/views/admin/manual-redeem/AdminManualRedeemView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'tasks/:taskId',
|
path: 'tasks/:taskId',
|
||||||
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
||||||
|
|||||||
@@ -6,6 +6,5 @@ export * from './platform-config'
|
|||||||
export * from './orders'
|
export * from './orders'
|
||||||
export * from './tasks'
|
export * from './tasks'
|
||||||
export * from './inventory'
|
export * from './inventory'
|
||||||
export * from './manual-redeem'
|
|
||||||
export * from './message-deliveries'
|
export * from './message-deliveries'
|
||||||
export * from './webhook-events'
|
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 './notifications'
|
||||||
export * from './scheduled-jobs'
|
export * from './scheduled-jobs'
|
||||||
export * from './ninetyone'
|
export * from './ninetyone'
|
||||||
export * from './kuaishou-eticket'
|
export * from './kuaishou-eticket'
|
||||||
export * from './cloudtentacles'
|
export * from './cloudtentacles'
|
||||||
export * from './fulfillment-bindings'
|
|
||||||
export * from './kuaishou-cloud-fulfillment'
|
export * from './kuaishou-cloud-fulfillment'
|
||||||
|
|||||||
@@ -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 { ClaimDetailData } from '@/types/claim'
|
||||||
import type { TencentLoginType } from '@/types/tencent/session'
|
|
||||||
|
|
||||||
export function fetchClaimDetail(token: string) {
|
export function fetchClaimDetail(token: string) {
|
||||||
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}`)
|
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(
|
export function verifyKuaishouCloudClaimTicket(
|
||||||
token: string,
|
token: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -37,19 +21,3 @@ export function confirmKuaishouCloudClaimRole(token: string) {
|
|||||||
export function redeemKuaishouCloudClaim(token: string) {
|
export function redeemKuaishouCloudClaim(token: string) {
|
||||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {})
|
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[]
|
|
||||||
}
|
|
||||||
@@ -34,9 +34,6 @@ export type {
|
|||||||
// Inventory types
|
// Inventory types
|
||||||
export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory'
|
export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory'
|
||||||
|
|
||||||
// Manual redeem types
|
|
||||||
export type { AdminManualRedeemDetail } from './manual-redeem'
|
|
||||||
|
|
||||||
// Webhook events types
|
// Webhook events types
|
||||||
export type {
|
export type {
|
||||||
AdminWebhookEventListItem,
|
AdminWebhookEventListItem,
|
||||||
@@ -47,18 +44,8 @@ export type {
|
|||||||
// Message deliveries types
|
// Message deliveries types
|
||||||
export type { AdminMessageDeliveryListItem } from './message-deliveries'
|
export type { AdminMessageDeliveryListItem } from './message-deliveries'
|
||||||
|
|
||||||
// Fulfillment types
|
|
||||||
export type {
|
|
||||||
AdminFulfillmentLookupOrder,
|
|
||||||
AdminFulfillmentLookupItem,
|
|
||||||
AdminFulfillmentLookupResult,
|
|
||||||
} from './fulfillment'
|
|
||||||
|
|
||||||
// Platform config types
|
// Platform config types
|
||||||
export type {
|
export type {
|
||||||
AdminAgisoShopConfigItem,
|
|
||||||
AdminAgisoMessagingDefaults,
|
|
||||||
AdminAgisoObservedShopItem,
|
|
||||||
AdminNotificationBarkRecipient,
|
AdminNotificationBarkRecipient,
|
||||||
AdminNotificationWpushRecipient,
|
AdminNotificationWpushRecipient,
|
||||||
AdminNotificationConfig,
|
AdminNotificationConfig,
|
||||||
@@ -92,8 +79,6 @@ export type {
|
|||||||
AdminCloudtentaclesValidateSessionResult,
|
AdminCloudtentaclesValidateSessionResult,
|
||||||
AdminCloudtentaclesSkuItem,
|
AdminCloudtentaclesSkuItem,
|
||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
AdminFulfillmentBindingConfigItem,
|
|
||||||
AdminObservedProductItem,
|
|
||||||
AdminKuaishouCloudFulfillmentItem,
|
AdminKuaishouCloudFulfillmentItem,
|
||||||
AdminKuaishouCloudFulfillmentConfig,
|
AdminKuaishouCloudFulfillmentConfig,
|
||||||
} from './platform-config'
|
} 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 {
|
export type {
|
||||||
AdminNotificationBarkRecipient,
|
AdminNotificationBarkRecipient,
|
||||||
AdminNotificationWpushRecipient,
|
AdminNotificationWpushRecipient,
|
||||||
@@ -52,11 +46,6 @@ export type {
|
|||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
} from './cloudtentacles'
|
} from './cloudtentacles'
|
||||||
|
|
||||||
export type {
|
|
||||||
AdminFulfillmentBindingConfigItem,
|
|
||||||
AdminObservedProductItem,
|
|
||||||
} from './fulfillment-bindings'
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AdminKuaishouCloudFulfillmentItem,
|
AdminKuaishouCloudFulfillmentItem,
|
||||||
AdminKuaishouCloudFulfillmentConfig,
|
AdminKuaishouCloudFulfillmentConfig,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session'
|
|
||||||
|
|
||||||
export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {})
|
export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {})
|
||||||
|
|
||||||
export type ClaimTaskStatus =
|
export type ClaimTaskStatus =
|
||||||
@@ -128,11 +126,11 @@ export interface ClaimKuaishouCloudFlowInfo {
|
|||||||
export interface ClaimDetailData {
|
export interface ClaimDetailData {
|
||||||
tokenStatus: ClaimTokenStatus
|
tokenStatus: ClaimTokenStatus
|
||||||
claimUrl: string
|
claimUrl: string
|
||||||
flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {})
|
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {})
|
||||||
task: ClaimTaskInfo
|
task: ClaimTaskInfo
|
||||||
order: ClaimOrderInfo
|
order: ClaimOrderInfo
|
||||||
orderItem: ClaimOrderItemInfo
|
orderItem: ClaimOrderItemInfo
|
||||||
session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null
|
session: unknown | null
|
||||||
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
|
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
|
||||||
result: ClaimResultInfo | 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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="code">404</p>
|
<p class="code">404</p>
|
||||||
<h1>页面不存在</h1>
|
<h1>页面不存在</h1>
|
||||||
<p>当前前端只保留腾讯浏览器兑换页,这个地址没有对应页面。</p>
|
<p>当前快手轻量前端没有对应页面。</p>
|
||||||
<RouterLink to="/tx">前往兑换入口</RouterLink>
|
<RouterLink to="/admin/dashboard">前往后台</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,45 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="hub-panel">
|
<section class="hub-panel">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="履约配置中心"
|
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 />
|
<AdminKuaishouCloudFulfillmentView />
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,21 +18,4 @@ const activeTab = computed<FulfillmentTabKey>({
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
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>
|
</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>
|
|
||||||
-213
@@ -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>
|
|
||||||
-156
@@ -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>
|
|
||||||
-90
@@ -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>
|
|
||||||
-48
@@ -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>
|
|
||||||
-106
@@ -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))
|
|
||||||
-453
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-70
@@ -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,
|
Fold,
|
||||||
List,
|
List,
|
||||||
Message,
|
Message,
|
||||||
Service,
|
|
||||||
Setting,
|
Setting,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
Tickets,
|
Tickets,
|
||||||
@@ -63,10 +62,7 @@ const navItems = computed(() => {
|
|||||||
items.push({ to: '/admin/inventory', label: '库存', icon: Box })
|
items.push({ to: '/admin/inventory', label: '库存', icon: Box })
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(
|
items.push({ to: '/admin/message-deliveries', label: '消息发送', icon: Message })
|
||||||
{ to: '/admin/manual-redeem', label: '人工兑换', icon: Service },
|
|
||||||
{ to: '/admin/message-deliveries', label: '消息发送', icon: Message },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isAdmin.value || isOperator.value) {
|
if (isAdmin.value || isOperator.value) {
|
||||||
items.push({ to: '/admin/webhook-events', label: 'Webhook', icon: Connection })
|
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>
|
|
||||||
-117
@@ -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>
|
|
||||||
-87
@@ -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>
|
|
||||||
-56
@@ -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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RefreshRight } from '@element-plus/icons-vue'
|
import { RefreshRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||||
import {
|
import {
|
||||||
fetchAdminAgisoShopConfigs,
|
|
||||||
fetchAdminCloudtentaclesSourceConfig,
|
fetchAdminCloudtentaclesSourceConfig,
|
||||||
fetchAdminKuaishouEticketSourceConfig,
|
fetchAdminKuaishouEticketSourceConfig,
|
||||||
fetchAdminNotificationConfig,
|
fetchAdminNotificationConfig,
|
||||||
fetchAdminScheduledJobsConfig,
|
fetchAdminScheduledJobsConfig,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
import AdminPlatformAgisoSection from './components/AdminPlatformAgisoSection.vue'
|
|
||||||
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
|
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
|
||||||
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
|
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
|
||||||
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
|
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
|
||||||
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
|
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
|
||||||
import { useAdminAgisoPlatform } from './composables/useAdminAgisoPlatform'
|
|
||||||
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
|
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
|
||||||
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
|
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
|
||||||
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
|
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
|
||||||
@@ -26,26 +23,7 @@ import type { PlatformTab } from './composables/types'
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const isDevMode = import.meta.env.DEV
|
const isDevMode = import.meta.env.DEV
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const activePlatform = ref<PlatformTab>('agiso')
|
const activePlatform = ref<PlatformTab>('ninetyone')
|
||||||
|
|
||||||
const {
|
|
||||||
saving,
|
|
||||||
agisoFilePath,
|
|
||||||
defaults,
|
|
||||||
shops,
|
|
||||||
observedShops,
|
|
||||||
agisoStats,
|
|
||||||
hydrateAgisoConfig,
|
|
||||||
addShop,
|
|
||||||
removeShop,
|
|
||||||
importObservedShop,
|
|
||||||
isShopExpanded,
|
|
||||||
toggleShop,
|
|
||||||
expandAllShops,
|
|
||||||
collapseAllShops,
|
|
||||||
describeShop,
|
|
||||||
saveAgisoConfigs: persistAgisoConfigs,
|
|
||||||
} = useAdminAgisoPlatform()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ninetyoneLoading,
|
ninetyoneLoading,
|
||||||
@@ -164,6 +142,55 @@ const {
|
|||||||
handleCloudtentaclesRunFullFlow,
|
handleCloudtentaclesRunFullFlow,
|
||||||
} = useAdminCloudtentaclesPlatform()
|
} = 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() {
|
async function loadConfigs() {
|
||||||
if (!hasAdminRole('admin')) {
|
if (!hasAdminRole('admin')) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -175,20 +202,17 @@ async function loadConfigs() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [
|
const [
|
||||||
agisoResponse,
|
|
||||||
notificationResponse,
|
notificationResponse,
|
||||||
scheduledJobsResponse,
|
scheduledJobsResponse,
|
||||||
kuaishouEticketResponse,
|
kuaishouEticketResponse,
|
||||||
cloudtentaclesResponse,
|
cloudtentaclesResponse,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchAdminAgisoShopConfigs(),
|
|
||||||
fetchAdminNotificationConfig(),
|
fetchAdminNotificationConfig(),
|
||||||
fetchAdminScheduledJobsConfig(),
|
fetchAdminScheduledJobsConfig(),
|
||||||
fetchAdminKuaishouEticketSourceConfig(),
|
fetchAdminKuaishouEticketSourceConfig(),
|
||||||
fetchAdminCloudtentaclesSourceConfig(),
|
fetchAdminCloudtentaclesSourceConfig(),
|
||||||
])
|
])
|
||||||
|
|
||||||
hydrateAgisoConfig(agisoResponse.data)
|
|
||||||
hydrateNotificationConfig(notificationResponse.data)
|
hydrateNotificationConfig(notificationResponse.data)
|
||||||
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
|
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
|
||||||
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
||||||
@@ -201,10 +225,6 @@ async function loadConfigs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAgisoConfigs() {
|
|
||||||
await persistAgisoConfigs(loadConfigs)
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(tab: string) {
|
function switchTab(tab: string) {
|
||||||
activePlatform.value = tab as PlatformTab
|
activePlatform.value = tab as PlatformTab
|
||||||
}
|
}
|
||||||
@@ -221,9 +241,6 @@ onMounted(loadConfigs)
|
|||||||
><el-icon><RefreshRight /></el-icon
|
><el-icon><RefreshRight /></el-icon
|
||||||
></template>
|
></template>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="activePlatform === 'agiso'" type="primary" @click="addShop">
|
|
||||||
新增 Agiso 店铺
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</AdminPageHeader>
|
</AdminPageHeader>
|
||||||
|
|
||||||
@@ -253,58 +270,7 @@ onMounted(loadConfigs)
|
|||||||
<!-- 概览卡片 -->
|
<!-- 概览卡片 -->
|
||||||
<div class="overview-grid">
|
<div class="overview-grid">
|
||||||
<el-card
|
<el-card
|
||||||
v-for="card in [
|
v-for="card in overviewCards"
|
||||||
{
|
|
||||||
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 ? '已验证' : '未验证'
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]"
|
|
||||||
:key="card.key"
|
:key="card.key"
|
||||||
:class="['overview-card', { 'is-active': activePlatform === card.key }]"
|
:class="['overview-card', { 'is-active': activePlatform === card.key }]"
|
||||||
shadow="hover"
|
shadow="hover"
|
||||||
@@ -322,24 +288,6 @@ onMounted(loadConfigs)
|
|||||||
|
|
||||||
<!-- 平台切换 Tab -->
|
<!-- 平台切换 Tab -->
|
||||||
<el-tabs v-model="activePlatform" type="card" class="platform-tabs">
|
<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">
|
<el-tab-pane label="91卡券接入" name="ninetyone">
|
||||||
<AdminPlatformNinetyoneSection
|
<AdminPlatformNinetyoneSection
|
||||||
:ninetyone-loading="ninetyoneLoading"
|
:ninetyone-loading="ninetyoneLoading"
|
||||||
|
|||||||
-264
@@ -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 =
|
export type PlatformTab =
|
||||||
| 'agiso'
|
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'ninetyone'
|
| 'ninetyone'
|
||||||
| 'kuaishouEticket'
|
| 'kuaishouEticket'
|
||||||
| 'cloudtentacles'
|
| '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 = {
|
export type EditableKuaishouEticketShop = {
|
||||||
id: string
|
id: string
|
||||||
shopId: 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>
|
|
||||||
@@ -1,90 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { fetchClaimDetail } from '@/services/claim'
|
|
||||||
|
|
||||||
import ClaimTencentView from './ClaimTencentView.vue'
|
|
||||||
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
|
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const token = computed(() => String(route.params.token || '').trim())
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-if="loading" class="claim-shell loading-shell">
|
<KuaishouCloudClaimView :token="token" />
|
||||||
<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 />
|
|
||||||
</template>
|
</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>
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
FROM docker.m.daocloud.io/library/node:22-bookworm AS builder
|
FROM docker.m.daocloud.io/library/node:22-bookworm AS builder
|
||||||
|
|
||||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
ARG VITE_APP_MODE=
|
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
|
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
|
||||||
ENV VITE_APP_MODE=${VITE_APP_MODE}
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ services:
|
|||||||
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||||||
VITE_API_TARGET: http://backend:3000
|
VITE_API_TARGET: http://backend:3000
|
||||||
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
VITE_APP_MODE: kuaishou
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/frontend:/app
|
- ./apps/frontend:/app
|
||||||
- frontend_kuaishou_node_modules:/app/node_modules
|
- frontend_kuaishou_node_modules:/app/node_modules
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ services:
|
|||||||
dockerfile: deploy/docker/frontend.Dockerfile
|
dockerfile: deploy/docker/frontend.Dockerfile
|
||||||
args:
|
args:
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||||
VITE_APP_MODE: kuaishou
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
Reference in New Issue
Block a user