feat(semi-auto): 重构客户资料收集与虎牙绑定确认流程

- 客户资料页按设计稿重构,区服固定四项下拉,游戏编号/角色名校验

- 虎牙登录与绑定页统一深色样式,扫码页新增改用手机号登录

- 待确认角色 UID 改用 huyaUid,不再展示虎牙加密 roleId

- 绑定确认新增两侧角色比对,不一致禁止确认,会话过期引导重新登录

- 新增迁移 168/169 与角色绑定单测,脚本换行符固定为 LF
This commit is contained in:
2026-11-15 21:57:31 +08:00
parent 8181ef50a5
commit 8e1cbdba9f
18 changed files with 2238 additions and 209 deletions
+4
View File
@@ -0,0 +1,4 @@
# 容器内 /bin/sh 直接执行的脚本必须是 LF。
# Windows 下 core.autocrlf=true 会把 checkout 出来的 .sh 转成 CRLF,
# 导致 `set: Illegal option -`,backend 容器反复重启。
*.sh text eol=lf
@@ -0,0 +1,14 @@
-- 168_semi_auto_binding_role_id.sql
--
-- 客户页角色绑定需要保存平台侧角色 ID:
-- 1. 虎牙 checkUserBindGameAccount 的 gameRole.roleId 即平台侧角色 ID;
-- 2. 客户工单资料里的游戏 ID(gameId / characterId)作为客户填写角色 ID;
-- 3. 两侧角色 ID 不一致时禁止确认绑定,只允许重新扫码或联系客服。
--
-- 该列只保存平台识别结果,不参与订单、资金等 A 级事实。
ALTER TABLE semi_auto_tasks
ADD COLUMN IF NOT EXISTS bind_game_role_id TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN semi_auto_tasks.bind_game_role_id IS
'平台侧游戏角色 ID(虎牙 gameRole.roleId),用于与客户填写角色比对';
@@ -0,0 +1,18 @@
-- 169_semi_auto_customer_profile.sql
--
-- 半自动订单不会生成 work_orders,客户资料页 /c/<订单号> 是唯一入口:
-- 1. 客户在授权页填写本次需要绑定的角色名称 / 游戏编号(角色 ID)/ 区服;
-- 2. 平台识别到角色后,用这里填写的角色 ID 与 bind_game_role_id 比对;
-- 3. 两侧不一致时禁止确认绑定,只能重新扫码或联系客服。
ALTER TABLE semi_auto_tasks
ADD COLUMN IF NOT EXISTS customer_game_role_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS customer_game_role_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS customer_game_server_zone TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN semi_auto_tasks.customer_game_role_name IS
'客户在授权页填写的游戏角色名称';
COMMENT ON COLUMN semi_auto_tasks.customer_game_role_id IS
'客户在授权页填写的游戏编号 / 角色 ID,用于与平台识别角色比对';
COMMENT ON COLUMN semi_auto_tasks.customer_game_server_zone IS
'客户在授权页填写的区服';
@@ -1,15 +1,19 @@
import { Router } from 'express'
import {
confirmPublicBinding,
getPublicWeiboCountryCodes,
listPublicSemiAutoTasks,
loginPublicHuyaSms,
loginPublicWeiboSms,
markCustomerLinkOpened,
pollPublicBinding,
pollPublicLogin,
pollPublicWeiboQrLogin,
savePublicTaskProfile,
sendPublicHuyaSmsCode,
sendPublicWeiboSmsCode,
startPublicBinding,
startPublicLogin,
startPublicWeiboQrLogin,
verifyPublicHuyaSmsChallenge,
@@ -70,6 +74,48 @@ router.get(
}),
)
router.post(
'/:orderNo/tasks/:taskId/profile',
writeLimit,
createRouteHandler(
(req) => savePublicTaskProfile(req.params.orderNo, req.params.taskId, req.body || {}),
{
successMessage: '角色资料已保存',
errorMessage: '保存角色资料失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/profile]',
},
),
)
router.post(
'/:orderNo/tasks/:taskId/binding/start',
writeLimit,
createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId, req.body), {
successMessage: '绑定会话已创建',
errorMessage: '开始账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/binding/status',
readLimit,
createRouteHandler((req) => pollPublicBinding(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新账号绑定状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/confirm',
writeLimit,
createRouteHandler((req) => confirmPublicBinding(req.params.orderNo, req.params.taskId), {
successMessage: '绑定已确认',
errorMessage: '确认账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/confirm]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/weibo/config',
readLimit,
@@ -50,6 +50,7 @@ type MockSession = {
bindingQrExpiresAtMs: number
bindGameAccount: string
bindGameRole: string
bindGameRoleId: string
bindGameChannel: string
message: string
tickets: Map<string, MockTicket>
@@ -136,8 +137,10 @@ function bindingView(session: MockSession): ConnectorBindingSession {
status: session.bindingStatus,
qrImage: session.bindingStatus === 'not_started' ? '' : buildMockBindingQr(session.sessionId),
qrExpiresAt: new Date(session.bindingQrExpiresAtMs).toISOString(),
qrUrl: '',
bindGameAccount: session.bindGameAccount,
bindGameRole: session.bindGameRole,
bindGameRoleId: session.bindGameRoleId,
bindGameChannel: session.bindGameChannel,
message: session.message,
}
@@ -158,6 +161,7 @@ function markRoleReady(session: MockSession) {
session.bindingStatus = 'role_ready'
session.bindGameAccount = `mock-account-${session.sessionId.slice(-6)}`
session.bindGameRole = 'Mock测试角色'
session.bindGameRoleId = `mock-role-${session.sessionId.slice(-6)}`
session.bindGameChannel = '虎牙'
session.message = 'Mock 已识别游戏角色,请确认绑定'
}
@@ -188,6 +192,7 @@ export function createMockLoginSession(input: {
bindingQrExpiresAtMs: 0,
bindGameAccount: '',
bindGameRole: '',
bindGameRoleId: '',
bindGameChannel: '',
message: 'Mock 登录二维码已生成,轮询后会自动模拟扫码成功',
tickets: new Map(),
@@ -230,6 +235,7 @@ export function sendMockHuyaSmsCode(input: {
bindingQrExpiresAtMs: 0,
bindGameAccount: '',
bindGameRole: '',
bindGameRoleId: '',
bindGameChannel: '',
message: 'Mock 短信验证码已发送,请输入 123456',
tickets: new Map(),
@@ -0,0 +1,16 @@
/**
* 虎牙 checkUserBindGameAccount 返回的 gameRole.roleId 是 40 位加密串,
* 无法直接和客户填写的游戏编号比对;平台侧可用于核对的是登录账号的数值 UID(huyaUid)。
*
* 取数优先级:角色 ID 本身是纯数字 > 数值 UID > 原始角色 ID。
* 这样既兼容未来虎牙直接下发数值角色 ID,也避免把加密串展示给客户。
*/
export function resolveBindingRoleId(roleId: unknown, huyaUid: unknown): string {
const rawRoleId = String(roleId ?? '').trim()
if (/^\d+$/.test(rawRoleId)) return rawRoleId
const uid = String(huyaUid ?? '').trim()
return /^\d+$/.test(uid) ? uid : rawRoleId
}
@@ -95,8 +95,10 @@ export type ConnectorBindingSession = {
| 'failed'
qrImage: string
qrExpiresAt: string
qrUrl: string
bindGameAccount: string
bindGameRole: string
bindGameRoleId: string
bindGameChannel: string
hasExistingBinding?: boolean
rebindRequested?: boolean
@@ -12,6 +12,7 @@ import path from 'node:path'
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import { logWarn } from '../../../utils/logger.js'
import { resolveBindingRoleId } from '../binding-role-id.js'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
@@ -62,10 +63,12 @@ type NativeBindingStatus = ConnectorBindingSession['status']
type NativeBindState = {
status: NativeBindingStatus
qrImage: string
qrUrl: string
qrToken: string
expiresAtMs: number
account: string
role: string
roleId: string
channel: string
hasExistingBinding: boolean
rebindRequested: boolean
@@ -135,10 +138,12 @@ function defaultBindingState(): NativeBindState {
return {
status: 'not_started',
qrImage: '',
qrUrl: '',
qrToken: '',
expiresAtMs: 0,
account: '',
role: '',
roleId: '',
channel: '',
hasExistingBinding: false,
rebindRequested: false,
@@ -223,18 +228,29 @@ function smsBehavior(page = 'https://www.huya.com/g'): { behavior: string; page:
function parseRole(response: HuyaBindResponse | null): {
account: string
role: string
roleId: string
channel: string
} {
if (!response || response.status !== 200) return { account: '', role: '', channel: '' }
if (!response || response.status !== 200) {
return { account: '', role: '', roleId: '', channel: '' }
}
const data: HuyaGameAccountData = response.accountData
const account = data.gameAccount.nick || data.gameRole.roleName
const role = data.gameRole.roleName
const channel = [data.gameRole.platName, data.gameRole.areaName].filter(Boolean).join(' · ')
return { account, role, channel }
// 虎牙 checkUserBindGameAccount 的 gameRole.roleId 就是平台侧角色 ID;
// 该字段一直存在于 RPC 响应里,之前被丢弃,导致客户页无法做两侧角色比对。
const roleId = String(data.gameRole.roleId || '').trim()
return { account, role, roleId, channel }
}
function bindingRoleKey(role: { account: string; role: string; channel: string }): string {
return [role.account, role.role, role.channel].join('\u0000')
function bindingRoleKey(role: {
account: string
role: string
roleId?: string
channel: string
}): string {
return [role.account, role.role, role.roleId || '', role.channel].join('\u0000')
}
function configValues() {
@@ -720,6 +736,7 @@ export class HuyaNativeConnector {
}
session.binding.status = 'qr_pending'
session.binding.qrImage = mini.image
session.binding.qrUrl = urls.qrUrl
session.binding.qrToken = mini.token
session.binding.expiresAtMs = this.now() + BINDING_QR_TTL_MS
session.binding.message = options.forceRebind
@@ -1042,12 +1059,13 @@ export class HuyaNativeConnector {
private markRoleReady(
session: NativeSession,
role: { account: string; role: string; channel: string },
role: { account: string; role: string; roleId?: string; channel: string },
existingBinding = false,
): void {
session.binding.status = 'role_ready'
session.binding.account = role.account
session.binding.role = role.role
session.binding.roleId = role.roleId || ''
session.binding.channel = role.channel
session.binding.message = existingBinding
? '当前账号已有绑定角色,请确认直接使用'
@@ -1131,8 +1149,10 @@ export class HuyaNativeConnector {
status: binding.status,
qrImage: binding.qrImage,
qrExpiresAt: binding.expiresAtMs ? iso(binding.expiresAtMs) : '',
qrUrl: binding.qrUrl,
bindGameAccount: binding.account,
bindGameRole: binding.role,
bindGameRoleId: resolveBindingRoleId(binding.roleId, session.uid),
bindGameChannel: binding.channel,
hasExistingBinding: binding.hasExistingBinding,
rebindRequested: binding.rebindRequested,
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { resolveBindingRoleId } from './binding-role-id.js'
import { compareBindingRole, normalizeCustomerProfile } from './semi-auto-task-service.js'
const task = (id: string, name: string, huyaUid = '') => ({
customer_game_id: id,
customer_game_role: name,
huya_uid: huyaUid,
})
describe('待确认角色 UID 取数(huyaUid)', () => {
it('角色 ID 本身是纯数字时直接使用', () => {
assert.equal(resolveBindingRoleId('1192083858', 1199608910400), '1192083858')
})
it('角色 ID 是加密串时改用数值 UID', () => {
assert.equal(
resolveBindingRoleId('834ee3e75a1bfc9f65fb6b966bac7ef318e68ea', '1199608910400'),
'1199608910400',
)
})
it('缺少角色 ID 时回退到数值 UID', () => {
assert.equal(resolveBindingRoleId('', 1199608910400), '1199608910400')
})
it('没有可用数值 UID 时保留原始角色 ID', () => {
assert.equal(
resolveBindingRoleId('834ee3e75a1bfc9f65fb6b966bac7ef318e68ea', ''),
'834ee3e75a1bfc9f65fb6b966bac7ef318e68ea',
)
assert.equal(resolveBindingRoleId(null, undefined), '')
})
it('虎牙加密串角色 ID 改用 huyaUid 参与比对', () => {
const result = compareBindingRole(task('1199608910400', '大鲨鱼伊塞53', '1199608910400'), {
bindGameRole: '大鲨鱼伊塞53',
bindGameRoleId: '834ee3e75a1bfc9f65fb6b966bac7ef318e68ea',
})
assert.equal(result.verdict, 'match')
assert.equal(result.boundRoleId, '1199608910400')
})
it('没有 huyaUid 时保留原始加密串,仍判不一致', () => {
const result = compareBindingRole(task('1199608910400', '大鲨鱼伊塞53'), {
bindGameRole: '大鲨鱼伊塞53',
bindGameRoleId: '834ee3e75a1bfc9f65fb6b966bac7ef318e68ea',
})
assert.equal(result.verdict, 'mismatch')
assert.equal(result.boundRoleId, '834ee3e75a1bfc9f65fb6b966bac7ef318e68ea')
})
})
describe('客户填写角色与平台识别角色比对', () => {
it('两侧角色 ID 一致时允许确认', () => {
const result = compareBindingRole(task('1192083858', '巫师基尔斯84'), {
bindGameRole: '巫师基尔斯84',
bindGameRoleId: '1192083858',
})
assert.equal(result.verdict, 'match')
assert.equal(result.customerRoleId, '1192083858')
assert.equal(result.boundRoleId, '1192083858')
})
it('两侧角色 ID 不一致时禁止确认', () => {
const result = compareBindingRole(task('1192083858', '巫师基尔斯84'), {
bindGameRole: '我是火星人2222',
bindGameRoleId: '166909256',
})
assert.equal(result.verdict, 'mismatch')
assert.match(result.message, /不一致/)
})
it('缺少角色 ID 时退回角色名称比对', () => {
const matched = compareBindingRole(task('', '巫师基尔斯84'), {
bindGameRole: '巫师基尔斯84',
bindGameRoleId: '',
})
assert.equal(matched.verdict, 'match')
const mismatched = compareBindingRole(task('', '巫师基尔斯84'), {
bindGameRole: '我是火星人2222',
bindGameRoleId: '',
})
assert.equal(mismatched.verdict, 'mismatch')
})
it('两侧都没有可核对信息时标记为 unknown', () => {
const result = compareBindingRole(task('', ''), { bindGameRole: '', bindGameRoleId: '' })
assert.equal(result.verdict, 'unknown')
})
})
describe('客户角色资料校验', () => {
it('清理空白并保留纯数字角色 ID', () => {
const profile = normalizeCustomerProfile({
roleName: ' 巫师基尔斯84 ',
roleId: ' 1192083858 ',
serverZone: ' 苹果V区 ',
})
assert.deepEqual(profile, {
roleName: '巫师基尔斯84',
roleId: '1192083858',
serverZone: '苹果V区',
})
})
it('角色 ID 非数字时拒绝保存', () => {
assert.throws(
() => normalizeCustomerProfile({ roleName: '巫师基尔斯84', roleId: 'abc123' }),
/只能是数字/,
)
})
it('缺区服时拒绝保存', () => {
assert.throws(
() => normalizeCustomerProfile({ roleName: '巫师基尔斯84', roleId: '1192083858' }),
/区服只能选择/,
)
})
it('区服不在固定选项时拒绝保存', () => {
assert.throws(
() =>
normalizeCustomerProfile({
roleName: '巫师基尔斯84',
roleId: '1192083858',
serverZone: '苹果微信区',
}),
/区服只能选择/,
)
})
it('角色名称与角色 ID 都为空时拒绝保存', () => {
assert.throws(
() => normalizeCustomerProfile({ roleName: ' ', roleId: '', serverZone: '苹果V区' }),
/请完整填写区服/,
)
})
})
@@ -98,7 +98,14 @@ type TaskRow = {
platform_nickname: string
bind_game_account: string
bind_game_role: string
bind_game_role_id: string
bind_game_channel: string
customer_game_role_name: string
customer_game_role_id: string
customer_game_server_zone: string
customer_game_id: string
customer_game_role: string
customer_server_zone: string
target_quantity: number
completed_quantity: number
assigned_admin_user_id: number | null
@@ -175,7 +182,20 @@ function displayStatus(task: TaskRow) {
return { key: 'failed', label: '需客服处理' }
}
if (task.login_status === 'authenticated') {
return { key: 'waiting_operator', label: '等待客服处理' }
// 虎牙需要客户确认绑定角色后才算授权完成;微博登录即授权。
if (task.platform === 'weibo' || task.binding_status === 'confirmed') {
return { key: 'waiting_operator', label: '等待客服处理' }
}
if (task.binding_status === 'role_ready') {
return { key: 'binding_confirm', label: '待确认绑定角色' }
}
if (task.binding_status === 'expired') {
return { key: 'binding_expired', label: '绑定二维码已过期' }
}
if (task.binding_status === 'failed') {
return { key: 'binding_failed', label: '绑定失败,需重新扫码' }
}
return { key: 'waiting_binding', label: '等待客户绑定角色' }
}
if (task.login_status === 'scanned') return { key: 'login_scanned', label: '已扫码,确认中' }
if (task.login_status === 'qr_pending') return { key: 'waiting_login', label: '等待客户登录' }
@@ -205,7 +225,11 @@ function mapTask(task: TaskRow) {
platformNickname: task.platform_nickname,
bindGameAccount: task.bind_game_account,
bindGameRole: task.bind_game_role,
bindGameRoleId: task.bind_game_role_id,
bindGameChannel: task.bind_game_channel,
customerGameRoleId: task.customer_game_id || '',
customerGameRoleName: task.customer_game_role || '',
customerServerZone: task.customer_server_zone || '',
targetQuantity: Number(task.target_quantity || 1),
completedQuantity: Number(task.completed_quantity || 0),
assignedAdminUserId: task.assigned_admin_user_id ? Number(task.assigned_admin_user_id) : null,
@@ -289,13 +313,41 @@ const TASK_SELECT = `
cfg.semi_auto_config_json,
credential.credential_status,
credential.credential_source,
credential.credential_updated_at
credential.credential_updated_at,
COALESCE(
NULLIF(task.customer_game_role_id, ''),
wo.material_json->'collect'->'fields'->>'gameId',
wo.material_json->'collect'->'fields'->>'characterId',
''
) AS customer_game_id,
COALESCE(
NULLIF(task.customer_game_role_name, ''),
NULLIF(wo.material_json->'collect'->'fields'->>'gameNickname', ''),
NULLIF(wo.material_json->'collect'->'fields'->>'roleName', ''),
''
) AS customer_game_role,
COALESCE(
NULLIF(task.customer_game_server_zone, ''),
wo.material_json->'collect'->'fields'->>'serverZone',
''
) AS customer_server_zone
FROM semi_auto_tasks task
JOIN orders o ON o.id = task.order_id
JOIN order_items oi ON oi.id = task.order_item_id
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
LEFT JOIN admin_users admin ON admin.id = task.assigned_admin_user_id
LEFT JOIN semi_auto_credentials credential ON credential.id = task.credential_id
LEFT JOIN LATERAL (
SELECT candidate.material_json
FROM work_orders candidate
WHERE candidate.order_item_id = task.order_item_id
OR (candidate.order_item_id IS NULL AND candidate.order_id = task.order_id)
OR (o.platform_order_id <> '' AND candidate.platform_order_id = o.platform_order_id)
ORDER BY (candidate.order_item_id = task.order_item_id) DESC NULLS LAST,
(candidate.order_id = task.order_id) DESC NULLS LAST,
candidate.id DESC
LIMIT 1
) wo ON TRUE
`
async function loadTask(taskId: number): Promise<TaskRow> {
@@ -354,6 +406,24 @@ function assertHuyaTask(task: TaskRow) {
}
}
/**
* 半自动订单没有工单,资料页 /c/<订单号> 是唯一入口。
* 未提交区服 / 游戏编号 / 角色名时,不允许开始登录,避免后续无法比对角色。
*/
function assertCustomerProfileReady(task: TaskRow) {
if (task.platform !== 'huya') return
if (
!String(task.customer_game_server_zone || '').trim() ||
!String(task.customer_game_role_id || '').trim() ||
!String(task.customer_game_role_name || '').trim()
) {
throw createHttpError('请先提交游戏角色资料', {
statusCode: 409,
errorCode: 'semi_auto_profile_required',
})
}
}
async function createLoginSessionForTask(
task: TaskRow,
orderNo: string,
@@ -679,6 +749,98 @@ export async function markCustomerLinkOpened(orderNoInput: unknown, taskIdInput:
return { ok: true }
}
export type CustomerProfileInput = {
roleName?: unknown
roleId?: unknown
serverZone?: unknown
}
const CUSTOMER_SERVER_ZONES = new Set(['苹果Q区', '苹果V区', '安卓Q区', '安卓V区'])
export function normalizeCustomerProfile(input: CustomerProfileInput = {}) {
const roleName = String(input.roleName ?? '')
.trim()
.slice(0, 100)
const roleId = String(input.roleId ?? '')
.replace(/\s+/g, '')
.slice(0, 64)
const serverZone = String(input.serverZone ?? '')
.trim()
.slice(0, 100)
if (roleId && !/^\d+$/.test(roleId)) {
throw createHttpError('游戏编号 / 角色 ID 只能是数字', {
statusCode: 400,
errorCode: 'semi_auto_profile_role_id_invalid',
})
}
if (!CUSTOMER_SERVER_ZONES.has(serverZone)) {
throw createHttpError('区服只能选择 苹果Q区 / 苹果V区 / 安卓Q区 / 安卓V区', {
statusCode: 400,
errorCode: 'semi_auto_profile_server_zone_invalid',
})
}
if (!roleName || !roleId) {
throw createHttpError('请完整填写区服、游戏编号和角色名', {
statusCode: 400,
errorCode: 'semi_auto_profile_required',
})
}
return { roleName, roleId, serverZone }
}
/**
* 半自动订单没有 work_orders,客户资料只能在授权页 /c/<订单号> 收集。
* 保存后立即参与“客户填写角色 vs 平台识别角色”的比对。
*/
export async function savePublicTaskProfile(
orderNoInput: unknown,
taskIdInput: unknown,
payload: CustomerProfileInput = {},
) {
const orderNo = normalizeOrderNo(orderNoInput)
const taskId = normalizeId(taskIdInput)
const task = await loadTaskForOrder(orderNo, taskId)
if (task.platform !== 'huya') {
throw createHttpError('当前平台不需要填写游戏角色', {
statusCode: 409,
errorCode: 'semi_auto_profile_not_supported',
})
}
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
throw createHttpError('当前订单已结束,不能修改角色资料', {
statusCode: 409,
errorCode: 'semi_auto_task_closed',
})
}
if (task.binding_status === 'confirmed') {
throw createHttpError('绑定已确认,不能修改角色资料', {
statusCode: 409,
errorCode: 'semi_auto_profile_locked',
})
}
const profile = normalizeCustomerProfile(payload)
await withTransaction(async (client) => {
await client.query(
`
UPDATE semi_auto_tasks
SET customer_game_role_name = $2,
customer_game_role_id = $3,
customer_game_server_zone = $4,
updated_at = NOW()
WHERE id = $1
`,
[task.id, profile.roleName, profile.roleId, profile.serverZone],
)
await appendTaskEvent(client, task.id, {
eventType: 'customer_profile_saved',
actorType: 'customer',
detail: profile,
})
})
const updated = await loadTask(task.id)
return { task: mapTask(updated) }
}
function mapLoginUpdate(session: ConnectorLoginSession) {
const statusMap: Record<ConnectorLoginSession['status'], LoginStatus> = {
verification_pending: 'waiting_login',
@@ -705,8 +867,13 @@ async function updateLoginTask(
options: { credentialSource?: 'qr' | 'sms'; method?: 'qr' | 'sms' } = {},
) {
const mapped = mapLoginUpdate(session)
// 虎牙登录成功还不算授权完成,必须等客户确认绑定角色;微博登录即授权。
const nextExecutionStatus: ExecutionStatus =
mapped.status === 'authenticated' ? 'waiting_operator' : task.execution_status
mapped.status === 'authenticated'
? task.platform === 'weibo' || task.binding_status === 'confirmed'
? 'waiting_operator'
: 'waiting_customer'
: task.execution_status
const storedCredential =
mapped.status === 'authenticated' && mapped.hasCredential
? task.platform === 'weibo'
@@ -779,27 +946,86 @@ function mapBindingUpdate(session: ConnectorBindingSession) {
return {
status: statusMap[session.status] || 'failed',
qrImage: String(session.qrImage || ''),
qrExpiresAt: String(session.qrExpiresAt || ''),
qrUrl: String(session.qrUrl || ''),
bindGameAccount: String(session.bindGameAccount || ''),
bindGameRole: String(session.bindGameRole || ''),
bindGameRoleId: String(session.bindGameRoleId || ''),
bindGameChannel: String(session.bindGameChannel || ''),
hasExistingBinding: session.hasExistingBinding === true,
rebindRequested: session.rebindRequested === true,
message: String(session.message || ''),
}
}
export type BindingRoleComparison = {
verdict: 'match' | 'mismatch' | 'unknown'
customerRoleName: string
customerRoleId: string
boundRoleName: string
boundRoleId: string
message: string
}
function normalizeRoleText(value: unknown): string {
return String(value ?? '')
.replace(/\s+/g, '')
.toLowerCase()
}
/**
* 客户填写角色与平台识别角色必须一致才允许确认绑定。
*
* 两侧都有角色 ID 时以 ID 为准(截图里的“两侧角色 ID 不一致”);
* 任一侧缺 ID 时退回角色名比对;两边都取不到才判 unknown,转客服核对。
*/
export function compareBindingRole(
task: Pick<TaskRow, 'customer_game_id' | 'customer_game_role' | 'huya_uid'>,
binding: { bindGameRole?: string; bindGameRoleId?: string },
): BindingRoleComparison {
const customerRoleName = String(task.customer_game_role || '').trim()
const customerRoleId = String(task.customer_game_id || '').trim()
const boundRoleName = String(binding.bindGameRole || '').trim()
const rawBoundRoleId = String(binding.bindGameRoleId || '').trim()
// 虎牙 gameRole.roleId 是 40 位加密串,客户填写的是数值游戏编号;
// 平台侧可比对的是登录账号的数值 UID(huyaUid),不能拿加密串直接比对。
const numericHuyaUid = /^\d+$/.test(String(task.huya_uid || '').trim())
? String(task.huya_uid).trim()
: ''
const boundRoleId = /^\d+$/.test(rawBoundRoleId)
? rawBoundRoleId
: numericHuyaUid || rawBoundRoleId
const base = { customerRoleName, customerRoleId, boundRoleName, boundRoleId }
if (customerRoleId && boundRoleId) {
return customerRoleId === boundRoleId
? { ...base, verdict: 'match', message: '两侧角色 ID 一致,可确认绑定' }
: { ...base, verdict: 'mismatch', message: '两侧角色 ID 不一致,无法确认绑定' }
}
if (customerRoleName && boundRoleName) {
return normalizeRoleText(customerRoleName) === normalizeRoleText(boundRoleName)
? { ...base, verdict: 'match', message: '两侧角色名称一致,可确认绑定' }
: { ...base, verdict: 'mismatch', message: '两侧角色名称不一致,无法确认绑定' }
}
return {
...base,
verdict: 'unknown',
message: '未获取到可核对的两侧角色信息,请联系客服核对后再确认',
}
}
async function updateBindingTask(
task: TaskRow,
session: ConnectorBindingSession,
actorType: TaskActorType,
) {
const mapped = mapBindingUpdate(session)
const comparison = compareBindingRole(task, mapped)
const executionStatus: ExecutionStatus =
mapped.status === 'confirmed'
? 'waiting_operator'
: mapped.status === 'failed' || mapped.status === 'expired'
? 'failed'
: task.execution_status === 'completed' || task.execution_status === 'cancelled'
? task.execution_status
: 'waiting_customer'
: task.execution_status === 'completed' || task.execution_status === 'cancelled'
? task.execution_status
: 'waiting_customer'
await withTransaction(async (client) => {
await client.query(
`
@@ -807,6 +1033,7 @@ async function updateBindingTask(
SET binding_status = $2,
bind_game_account = CASE WHEN $3 <> '' THEN $3 ELSE bind_game_account END,
bind_game_role = CASE WHEN $4 <> '' THEN $4 ELSE bind_game_role END,
bind_game_role_id = CASE WHEN $8 <> '' THEN $8 ELSE bind_game_role_id END,
bind_game_channel = CASE WHEN $5 <> '' THEN $5 ELSE bind_game_channel END,
bound_at = CASE WHEN $2 IN ('role_ready', 'confirmed') THEN COALESCE(bound_at, NOW()) ELSE bound_at END,
execution_status = $6,
@@ -822,6 +1049,7 @@ async function updateBindingTask(
mapped.bindGameChannel,
executionStatus,
mapped.message,
mapped.bindGameRoleId,
],
)
await appendTaskEvent(client, task.id, {
@@ -834,7 +1062,9 @@ async function updateBindingTask(
detail: {
gameAccount: mapped.bindGameAccount,
gameRole: mapped.bindGameRole,
gameRoleId: mapped.bindGameRoleId,
gameChannel: mapped.bindGameChannel,
verdict: comparison.verdict,
message: mapped.message,
},
})
@@ -842,10 +1072,16 @@ async function updateBindingTask(
return {
status: mapped.status,
qrImage: mapped.qrImage,
qrExpiresAt: mapped.qrExpiresAt,
qrUrl: mapped.qrUrl,
bindGameAccount: mapped.bindGameAccount,
bindGameRole: mapped.bindGameRole,
bindGameRoleId: mapped.bindGameRoleId,
bindGameChannel: mapped.bindGameChannel,
hasExistingBinding: mapped.hasExistingBinding,
rebindRequested: mapped.rebindRequested,
message: mapped.message,
comparison,
}
}
@@ -854,6 +1090,7 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
const taskId = normalizeId(taskIdInput)
await ensureSemiAutoTasksForOrder(orderNo)
const task = await loadTaskForOrder(orderNo, taskId)
assertCustomerProfileReady(task)
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
throw createHttpError('当前订单已结束,不能重新登录', {
statusCode: 409,
@@ -907,6 +1144,13 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'qr_pending',
binding_status = 'not_started',
bind_game_account = '',
bind_game_role = '',
bind_game_role_id = '',
bind_game_channel = '',
binding_started_at = NULL,
bound_at = NULL,
execution_status = 'waiting_customer',
auth_started_at = COALESCE(auth_started_at, NOW()),
last_error = '',
@@ -1027,6 +1271,13 @@ export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: u
})
}
const session = await getBindingForTask(task, task.connector_session_id)
const comparison = compareBindingRole(task, session)
if (comparison.verdict === 'mismatch') {
throw createHttpError(comparison.message, {
statusCode: 409,
errorCode: 'semi_auto_binding_role_mismatch',
})
}
const binding = await updateBindingTask(task, session, 'customer')
const updated = await loadTask(task.id)
return { task: mapTask(updated), binding }
@@ -1041,6 +1292,7 @@ export async function sendPublicHuyaSmsCode(
const taskId = normalizeId(taskIdInput)
const task = await loadTaskForOrder(orderNo, taskId)
assertHuyaTask(task)
assertCustomerProfileReady(task)
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
throw createHttpError('当前订单已结束,不能重新登录', {
statusCode: 409,
@@ -1190,6 +1442,7 @@ export async function loginPublicHuyaSms(
const taskId = normalizeId(taskIdInput)
const task = await loadTaskForOrder(orderNo, taskId)
assertHuyaTask(task)
assertCustomerProfileReady(task)
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
throw createHttpError('当前订单已结束,不能重新登录', {
statusCode: 409,
@@ -1076,8 +1076,10 @@ export class WeiboNativeConnector {
status: 'confirmed',
qrImage: '',
qrExpiresAt: '',
qrUrl: '',
bindGameAccount: account?.nickname || '',
bindGameRole: account?.uid || '',
bindGameRoleId: account?.uid || '',
bindGameChannel: '微博',
hasExistingBinding: true,
rebindRequested: false,
+22
View File
@@ -138,6 +138,21 @@ function applySessionExpiryHeaders(
}
}
/** HTTP 状态码兜底文案:避免把英文 statusText(如 Not Found)原样抛给用户 */
const HTTP_STATUS_MESSAGES: Record<number, string> = {
400: '请求参数有误',
401: '登录状态已失效,请重新登录',
403: '没有操作权限',
404: '接口不存在,请刷新页面后重试',
409: '数据已更新,请刷新后重试',
422: '提交的数据不合法',
429: '操作过于频繁,请稍后再试',
500: '服务器开小差了,请稍后重试',
502: '服务暂时不可用,请稍后重试',
503: '服务维护中,请稍后重试',
504: '请求超时,请稍后重试',
}
function resolveFallbackHttpMessage(error: unknown) {
const message = String((error as { message?: string })?.message ?? '')
@@ -149,6 +164,13 @@ function resolveFallbackHttpMessage(error: unknown) {
return '网络连接错误'
}
const status = Number((error as { response?: { status?: number } })?.response?.status || 0)
const statusMessage = status > 0 ? HTTP_STATUS_MESSAGES[status] : ''
if (statusMessage) {
return statusMessage
}
if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') {
return String((error as { response: { statusText: string } }).response.statusText).trim()
}
@@ -54,6 +54,8 @@ const STATUS_COLORS: Record<string, string> = {
login_scanned: 'cyan',
waiting_binding: 'gold',
binding_confirm: 'orange',
binding_expired: 'red',
binding_failed: 'red',
waiting_operator: 'volcano',
credential_expired: 'red',
processing: 'processing',
@@ -67,6 +69,16 @@ const PLATFORM_LABELS: Record<string, string> = {
weibo: '微博',
}
const BINDING_STATUS_LABELS: Record<string, string> = {
not_started: '未开始',
qr_pending: '待扫码',
scanned: '已扫码',
role_ready: '待确认角色',
confirmed: '已确认',
failed: '失败',
expired: '已过期',
}
type CompleteFormValues = {
note?: string
externalOrderNo?: string
@@ -344,6 +356,16 @@ export default function AdminSemiAutoTasksPage() {
<Space direction="vertical" size={2}>
<Tag>{task.displayStatus.label}</Tag>
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
<Text type="secondary">
{task.platform === 'huya'
? `绑定:${BINDING_STATUS_LABELS[task.bindingStatus] || task.bindingStatus}`
: '微博登录即授权'}
</Text>
{task.platform === 'huya' && (task.bindGameRole || task.bindGameRoleId) ? (
<Text type="secondary">
{`角色:${task.bindGameRole || '-'}${task.bindGameRoleId ? `(${task.bindGameRoleId})` : '(无角色ID)'}`}
</Text>
) : null}
<Text type="secondary">
CK:
{task.credentialStatus === 'valid'
@@ -616,6 +638,24 @@ export default function AdminSemiAutoTasksPage() {
<Descriptions.Item label="登录状态">
{detail.task.loginStatus === 'authenticated' ? 'CK 已获取' : '等待客户登录'}
</Descriptions.Item>
<Descriptions.Item label="绑定状态">
{detail.task.platform === 'weibo'
? '微博登录即授权'
: BINDING_STATUS_LABELS[detail.task.bindingStatus] || detail.task.bindingStatus}
</Descriptions.Item>
<Descriptions.Item label="绑定角色">
{detail.task.bindGameRole
? `${detail.task.bindGameRole}${detail.task.bindGameRoleId ? `(${detail.task.bindGameRoleId})` : '(无角色ID)'}`
: '-'}
</Descriptions.Item>
<Descriptions.Item label="客户填写角色">
{detail.task.customerGameRoleName
? `${detail.task.customerGameRoleName}${detail.task.customerGameRoleId ? `(${detail.task.customerGameRoleId})` : ''}`
: '-'}
</Descriptions.Item>
<Descriptions.Item label="区服">
{detail.task.customerServerZone || '-'}
</Descriptions.Item>
<Descriptions.Item label="客服">
{detail.task.assignedAdminName || '未领取'}
</Descriptions.Item>
@@ -765,6 +805,7 @@ function taskNeedsCustomerReauth(task: SemiAutoTask) {
function taskCanUseCredential(task: SemiAutoTask) {
return (
task.loginStatus === 'authenticated' &&
(task.platform === 'weibo' || task.bindingStatus === 'confirmed') &&
task.credentialReady &&
task.credentialStatus !== 'invalid' &&
task.credentialStatus !== 'error'
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,9 @@
import { apiGet, apiPost } from '@/lib/http'
import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
import type {
CustomerBindingInfo,
CustomerLoginInfo,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
export function fetchCustomerFulfillment(orderNo: string) {
return apiGet<CustomerSemiAutoTask[]>(
@@ -14,6 +18,17 @@ export function markCustomerFulfillmentOpened(orderNo: string, taskId: number) {
)
}
export function saveCustomerTaskProfile(
orderNo: string,
taskId: number,
input: { roleName: string; roleId: string; serverZone: string },
) {
return apiPost<{ task: CustomerSemiAutoTask }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/profile`,
input,
)
}
export function startCustomerLogin(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/login/start`,
@@ -27,6 +42,30 @@ export function pollCustomerLogin(orderNo: string, taskId: number) {
)
}
export function startCustomerBinding(
orderNo: string,
taskId: number,
options: { forceRebind?: boolean } = {},
) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`,
options.forceRebind ? { forceRebind: true } : {},
)
}
export function pollCustomerBinding(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/status`,
)
}
export function confirmCustomerBinding(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/confirm`,
{},
)
}
type CustomerHuyaChallengeBase = {
image: string
width: number
+657 -67
View File
@@ -1437,54 +1437,47 @@
}
.customer-auth-page {
--auth-accent: #ff8a00;
--auth-accent-hover: #ffa12f;
--auth-panel: #07142e;
--auth-panel-soft: #0d1d3d;
--auth-line: rgba(255, 255, 255, 0.11);
--auth-text: #f8fafc;
--auth-muted: #a6b0c3;
--auth-accent: #2f6fed;
--auth-accent-hover: #4a83ff;
--auth-panel: #232936;
--auth-panel-soft: #2a3140;
--auth-line: rgba(255, 255, 255, 0.08);
--auth-text: #f5f7fa;
--auth-muted: #9aa3b2;
min-height: 100vh;
display: flex;
justify-content: center;
overflow-x: hidden;
padding: clamp(20px, 4vw, 48px) 16px;
padding: 0;
color: var(--auth-text);
background: #202124;
background: #232936;
}
.customer-auth-shell {
width: min(760px, 100%);
width: min(560px, 100%);
min-width: 0;
align-self: flex-start;
align-self: stretch;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: var(--auth-panel);
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.34);
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.customer-auth-hero {
min-height: 176px;
min-height: auto;
display: flex;
align-items: center;
padding: 34px 38px;
background-image:
linear-gradient(
90deg,
rgba(3, 18, 55, 0.97) 0%,
rgba(5, 23, 65, 0.83) 48%,
rgba(7, 20, 43, 0.45) 100%
),
url('https://game.gtimg.cn/images/gp/web201908/inside_kv.jpg');
background-position: center;
background-size: cover;
justify-content: center;
padding: 38px 16px 10px;
background: none;
}
.customer-auth-brand {
display: flex;
align-items: center;
gap: 18px;
justify-content: center;
gap: 0;
min-width: 0;
}
@@ -1505,28 +1498,30 @@
.customer-auth-brand h1 {
margin: 0;
color: #fff;
font-size: clamp(18px, 2.3vw, 22px);
line-height: 1.3;
font-weight: 600;
font-size: 20px;
line-height: 1.4;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
text-align: center;
white-space: normal;
overflow-wrap: anywhere;
}
.customer-auth-warning {
padding: 19px 24px;
padding: 6px 16px 10px;
color: #ff6975;
text-align: center;
font-size: 20px;
font-weight: 700;
background: #06122b;
border-bottom: 1px solid var(--auth-line);
font-size: 14px;
font-weight: 600;
background: transparent;
border-bottom: none;
}
.customer-auth-main {
min-width: 0;
padding-bottom: 24px;
color: var(--auth-text);
background: var(--auth-panel);
background: transparent;
}
.customer-auth-content-stack {
@@ -1605,8 +1600,8 @@
.customer-auth-section {
min-width: 0;
padding: 28px 26px 30px;
border-bottom: 1px solid var(--auth-line);
padding: 16px;
border-bottom: none;
}
.customer-auth-section-heading {
@@ -1616,24 +1611,24 @@
}
.customer-auth-section-icon {
width: 42px;
height: 42px;
width: 36px;
height: 36px;
flex: none;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 7px;
border-radius: 8px;
color: var(--auth-accent);
font-size: 22px;
font-size: 18px;
background: rgba(255, 255, 255, 0.035);
}
.customer-auth-section-heading h2,
.customer-auth-state h2 {
margin: 0 0 8px;
margin: 0 0 6px;
color: #fff;
font-size: 23px;
line-height: 1.35;
font-size: 18px;
line-height: 1.4;
font-weight: 700;
}
@@ -2081,6 +2076,607 @@
font-size: 12px;
}
.customer-auth-binding {
display: flex;
flex-direction: column;
gap: 16px;
}
.customer-auth-binding-tip {
color: #ffb454;
font-size: 13px;
}
.customer-auth-profile,
.customer-auth-profile-summary {
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
}
.customer-auth-profile > p {
margin: 0 0 10px;
color: var(--auth-muted);
font-size: 13px;
line-height: 1.6;
}
.customer-auth-profile-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.customer-auth-profile-summary span {
color: var(--auth-muted);
font-size: 13px;
}
.customer-auth-profile-summary strong {
color: #fff;
font-size: 15px;
}
.customer-auth-profile-summary em {
color: var(--auth-muted);
font-style: normal;
font-size: 13px;
}
/* 客户资料页(图 1) */
.customer-collect-page {
min-height: 100vh;
background: #0b1220;
color: #e5e7eb;
}
.customer-collect-hero {
position: relative;
height: 210px;
overflow: hidden;
}
.customer-collect-hero-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: left center;
filter: saturate(0.9) brightness(0.72);
}
.customer-collect-hero-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(11, 18, 32, 0.1), rgba(11, 18, 32, 0.96));
}
.customer-collect-warning {
position: absolute;
left: 0;
right: 0;
bottom: 22px;
margin: 0;
text-align: center;
color: #f87171;
font-size: 17px;
font-weight: 600;
}
.customer-collect-body {
max-width: 560px;
margin: 0 auto;
padding: 24px 20px 48px;
display: flex;
flex-direction: column;
gap: 12px;
}
.customer-collect-label {
margin-top: 8px;
color: #e5e7eb;
font-size: 15px;
line-height: 1.4;
}
.customer-collect-label span {
color: #f87171;
}
.customer-collect-readonly {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
border-radius: 12px;
background: #1b2436;
border: 1px solid #26344d;
color: #94a3b8;
font-size: 15px;
}
.customer-collect-card {
margin: 10px 0;
padding: 16px;
border-radius: 12px;
border: 1px solid #1e2f4a;
background: #141d30;
display: flex;
flex-direction: column;
gap: 12px;
}
.customer-collect-card > div {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: #cbd5e1;
font-size: 15px;
line-height: 1.5;
}
.customer-collect-card > div > span {
flex: 0 0 auto;
white-space: nowrap;
}
.customer-collect-card > div strong {
flex: 1 1 auto;
min-width: 0;
color: #fff;
font-weight: 600;
text-align: right;
overflow-wrap: anywhere;
}
.customer-collect-card > p {
margin: 6px 0 0;
color: #fbbf24;
font-size: 13px;
text-align: center;
}
.customer-collect-input.ant-input {
height: 56px;
border-radius: 12px;
background: #1b2436;
border-color: #26344d;
color: #fff;
}
.customer-collect-input.ant-input::placeholder {
color: #64748b;
}
.customer-collect-select.ant-select {
width: 100%;
height: 56px;
padding-inline: 16px;
align-items: center;
font-size: 15px;
border-radius: 12px;
border: 1px solid #26344d;
background: #1b2436;
color: #fff;
--ant-select-height: 56px;
--ant-select-border-radius: 12px;
--ant-select-border-color: #26344d;
--ant-select-background-color: #1b2436;
--ant-select-color: #fff;
--ant-select-hover-border-color: #3b4d6b;
--ant-select-active-border-color: #2563eb;
--ant-select-active-outline-color: rgba(37, 99, 235, 0.2);
}
.customer-collect-select.ant-select:hover {
border-color: #3b4d6b;
}
.customer-collect-select.ant-select.ant-select-focused {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.customer-collect-select .ant-select-content {
align-items: center;
}
.customer-collect-select .ant-select-selection-item {
color: #fff;
line-height: 1.5;
}
.customer-collect-select .ant-select-placeholder {
color: #64748b;
}
.customer-collect-select .ant-select-suffix {
color: #94a3b8;
}
.ant-select-dropdown.customer-collect-dropdown {
padding: 6px;
border-radius: 12px;
background: #141d30;
box-shadow: 0 14px 34px rgba(2, 6, 23, 0.55);
--ant-select-option-active-bg: #1b2436;
--ant-select-option-selected-bg: #1e3a8a;
--ant-select-option-selected-color: #fff;
}
.customer-collect-dropdown .ant-select-item {
border-radius: 8px;
color: #cbd5e1;
}
.customer-collect-dropdown .ant-select-item-option-active {
background: #1b2436 !important;
color: #fff;
}
.customer-collect-dropdown .ant-select-item-option-selected {
background: #1e3a8a !important;
color: #fff;
font-weight: 600;
}
.customer-collect-page .customer-collect-submit.ant-btn {
height: 56px;
margin-top: 14px;
border-radius: 14px;
font-size: 17px;
font-weight: 600;
--ant-btn-bg-color: #2563eb;
--ant-btn-bg-color-hover: #1d4ed8;
--ant-btn-bg-color-active: #1e40af;
--ant-btn-text-color: #fff;
--ant-btn-text-color-hover: #fff;
--ant-btn-text-color-active: #fff;
--ant-btn-border-color: #2563eb;
--ant-btn-border-color-hover: #1d4ed8;
--ant-btn-border-color-active: #1e40af;
background: #2563eb !important;
border-color: #2563eb !important;
color: #fff !important;
}
.customer-collect-page .customer-collect-submit.ant-btn:hover,
.customer-collect-page .customer-collect-submit.ant-btn:focus-visible {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
color: #fff !important;
}
/* 登录与绑定页(图 2-10) */
.customer-login-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.customer-login-hint {
margin: 0 0 2px;
color: #d5dae3;
font-size: 16px;
line-height: 1.5;
}
.customer-login-mobile-row {
display: flex;
align-items: stretch;
gap: 12px;
}
.customer-login-mobile.ant-input {
flex: 1;
min-width: 0;
height: 52px;
padding: 0 16px;
border-radius: 8px;
border-color: #3a4150;
background: #1b2130;
color: #fff;
font-size: 15px;
}
.customer-login-mobile.ant-input::placeholder {
color: #6b7482;
}
.customer-login-code-button.ant-btn {
flex: none;
width: 118px;
height: 52px;
border-radius: 8px;
background: #3b82f6 !important;
border-color: #3b82f6 !important;
color: #fff !important;
font-size: 16px;
}
.customer-login-code-input.ant-input {
height: 52px;
border-radius: 8px;
border-color: #3a4150;
background: #1b2130;
color: #fff;
text-align: center;
font-size: 15px;
letter-spacing: 6px;
}
.customer-login-code-input.ant-input::placeholder {
color: #6b7482;
}
.customer-login-submit.ant-btn {
height: 52px;
border-radius: 8px;
background: #0aa53a !important;
border-color: #0aa53a !important;
color: #fff !important;
font-size: 18px;
font-weight: 700;
}
.customer-login-alt.ant-btn {
height: 52px;
border-radius: 8px;
background: transparent !important;
border-color: #3a4150 !important;
color: #cbd5e1 !important;
font-size: 16px;
}
.customer-order-card {
margin: 10px 16px 0;
padding: 16px;
border: 1px solid #3a4150;
border-radius: 12px;
background: #212733;
display: flex;
flex-direction: column;
gap: 10px;
}
.customer-order-card h3 {
margin: 0 0 4px;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
}
.customer-order-card > div {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: #cbd5e1;
font-size: 14px;
line-height: 1.5;
}
.customer-order-card > div > span {
flex: 0 0 auto;
white-space: nowrap;
color: #9aa3b2;
}
.customer-order-card > div strong {
flex: 1 1 auto;
min-width: 0;
color: #fff;
text-align: right;
overflow-wrap: anywhere;
}
.customer-binding-stage {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.customer-binding-info {
padding: 16px;
border: 1px solid #3a4150;
border-radius: 12px;
background: #212733;
display: flex;
flex-direction: column;
gap: 10px;
}
.customer-binding-info h3 {
margin: 0 0 4px;
color: #9aa3b2;
font-size: 13px;
font-weight: 500;
}
.customer-binding-info h3 small {
color: #64748b;
font-weight: 400;
}
.customer-binding-info > div {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: #cbd5e1;
font-size: 14px;
line-height: 1.5;
}
.customer-binding-info > div > span {
flex: 0 0 auto;
white-space: nowrap;
}
.customer-binding-info > div strong {
flex: 1 1 auto;
min-width: 0;
color: #fff;
text-align: right;
overflow-wrap: anywhere;
}
.customer-binding-alert {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 14px;
}
.customer-binding-alert.is-warning {
color: #fbbf24;
border-color: #78350f;
background: rgba(120, 53, 15, 0.25);
}
.customer-binding-alert.is-info {
color: #93c5fd;
border-color: #1e3a8a;
background: rgba(30, 58, 138, 0.3);
}
.customer-binding-qr-card {
padding: 18px;
border-radius: 12px;
background: #fff;
color: #111827;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.customer-binding-qr-card h3 {
margin: 0;
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.customer-binding-qr-hint {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.customer-binding-qr-tip {
margin: 0;
color: #f59e0b;
font-size: 13px;
}
.customer-binding-qr-countdown {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.customer-binding-qr-actions {
width: 100%;
margin-top: 4px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.customer-binding-qr-actions .ant-btn {
height: 40px;
border-radius: 8px;
}
.customer-binding-next.ant-btn {
width: 100%;
height: 46px;
margin-top: 6px;
border-radius: 8px;
background: #2563eb !important;
border-color: #2563eb !important;
color: #fff !important;
font-weight: 600;
}
.customer-binding-modal-footer {
display: flex;
flex-direction: column;
gap: 10px;
}
.customer-binding-modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.customer-binding-modal-actions .ant-btn {
height: 42px;
border-radius: 8px;
}
.customer-binding-modal-error {
margin: 0;
color: #dc2626;
font-size: 13px;
text-align: center;
}
.customer-binding-modal-warning {
margin: 0;
color: #d97706;
font-size: 13px;
text-align: center;
}
.customer-binding-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.customer-binding-compare-side {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 6px;
background: rgba(15, 23, 42, 0.04);
}
.customer-binding-compare-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #64748b;
font-size: 13px;
}
.customer-binding-compare-side strong {
color: #0f172a;
font-size: 16px;
word-break: break-all;
}
.customer-binding-compare-side em {
color: #64748b;
font-style: normal;
font-size: 13px;
}
@media (max-width: 480px) {
.customer-binding-compare {
grid-template-columns: minmax(0, 1fr);
}
}
.customer-auth-button-stack {
width: min(100%, 580px);
display: flex;
@@ -2190,49 +2786,43 @@
@media (max-width: 640px) {
.customer-auth-page {
height: 100dvh;
min-height: 0;
overflow: hidden;
height: auto;
min-height: 100vh;
overflow: visible;
padding: 0;
background: var(--auth-panel);
}
.customer-auth-shell {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
height: auto;
display: block;
border: 0;
border-radius: 0;
box-shadow: none;
}
.customer-auth-hero {
min-height: 116px;
flex: none;
padding: 20px 22px;
background-position: 58% center;
min-height: auto;
padding: 38px 16px 10px;
background: none;
}
.customer-auth-brand h1 {
font-size: 18px;
font-size: 20px;
}
.customer-auth-warning {
flex: none;
padding: 12px 14px;
font-size: 16px;
padding: 6px 16px 10px;
font-size: 14px;
}
.customer-auth-main {
flex: 1;
min-height: 0;
overflow: hidden;
overflow: visible;
}
.customer-auth-content-stack {
height: 100%;
height: auto;
min-height: 0;
}
@@ -2257,7 +2847,7 @@
}
.customer-auth-section {
padding: 14px 16px 10px;
padding: 16px;
}
.customer-auth-section-copy h2,
@@ -13,6 +13,7 @@ export type SemiAutoTask = {
platform: SemiAutoPlatform
platformLabel: string
loginStatus: string
bindingStatus: string
executionStatus: string
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
@@ -23,6 +24,13 @@ export type SemiAutoTask = {
credentialUpdatedAt: string | null
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameRoleId: string
bindGameChannel: string
customerGameRoleId: string
customerGameRoleName: string
customerServerZone: string
targetQuantity: number
completedQuantity: number
assignedAdminUserId: number | null
@@ -1,3 +1,12 @@
export type CustomerBindingComparison = {
verdict: 'match' | 'mismatch' | 'unknown'
customerRoleName: string
customerRoleId: string
boundRoleName: string
boundRoleId: string
message: string
}
export type CustomerSemiAutoTask = {
id: number
platform: 'huya' | 'weibo'
@@ -8,10 +17,18 @@ export type CustomerSemiAutoTask = {
skuCode: string
quantity: number
loginStatus: string
bindingStatus: string
executionStatus: string
displayStatus: { key: string; label: string }
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameRoleId: string
bindGameChannel: string
customerGameRoleId: string
customerGameRoleName: string
customerServerZone: string
completedAt: string | null
completionNote: string
semiAutoConfig: {
@@ -28,3 +45,18 @@ export type CustomerLoginInfo = {
expiresAt: string
message: string
}
export type CustomerBindingInfo = {
status: string
qrImage: string
qrExpiresAt: string
qrUrl: string
bindGameAccount: string
bindGameRole: string
bindGameRoleId: string
bindGameChannel: string
hasExistingBinding: boolean
rebindRequested: boolean
message: string
comparison: CustomerBindingComparison
}