diff --git a/apps/backend/.gitattributes b/apps/backend/.gitattributes new file mode 100644 index 00000000..375c8b6f --- /dev/null +++ b/apps/backend/.gitattributes @@ -0,0 +1,4 @@ +# 容器内 /bin/sh 直接执行的脚本必须是 LF。 +# Windows 下 core.autocrlf=true 会把 checkout 出来的 .sh 转成 CRLF, +# 导致 `set: Illegal option -`,backend 容器反复重启。 +*.sh text eol=lf diff --git a/apps/backend/src/db/migrations/168_semi_auto_binding_role_id.sql b/apps/backend/src/db/migrations/168_semi_auto_binding_role_id.sql new file mode 100644 index 00000000..7b5b0bc6 --- /dev/null +++ b/apps/backend/src/db/migrations/168_semi_auto_binding_role_id.sql @@ -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),用于与客户填写角色比对'; diff --git a/apps/backend/src/db/migrations/169_semi_auto_customer_profile.sql b/apps/backend/src/db/migrations/169_semi_auto_customer_profile.sql new file mode 100644 index 00000000..81996656 --- /dev/null +++ b/apps/backend/src/db/migrations/169_semi_auto_customer_profile.sql @@ -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 + '客户在授权页填写的区服'; diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts index 793b121c..98507025 100644 --- a/apps/backend/src/routes/customer-fulfillment.ts +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -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, diff --git a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts index 1349c6d5..7c35bdf6 100644 --- a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts +++ b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts @@ -50,6 +50,7 @@ type MockSession = { bindingQrExpiresAtMs: number bindGameAccount: string bindGameRole: string + bindGameRoleId: string bindGameChannel: string message: string tickets: Map @@ -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(), diff --git a/apps/backend/src/services/semi-auto/binding-role-id.ts b/apps/backend/src/services/semi-auto/binding-role-id.ts new file mode 100644 index 00000000..6ecda7f1 --- /dev/null +++ b/apps/backend/src/services/semi-auto/binding-role-id.ts @@ -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 +} diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.ts b/apps/backend/src/services/semi-auto/huya-connector-client.ts index ea1751b5..d2e06045 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.ts @@ -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 diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts index 8bc31c4f..a1dbc772 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts @@ -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, diff --git a/apps/backend/src/services/semi-auto/semi-auto-binding-role.test.ts b/apps/backend/src/services/semi-auto/semi-auto-binding-role.test.ts new file mode 100644 index 00000000..5ba3ea98 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-binding-role.test.ts @@ -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区' }), + /请完整填写区服/, + ) + }) +}) diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 5efe0b64..5ee7a8da 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -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 { @@ -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 = { 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, + 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, diff --git a/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts index e8711548..2af1a670 100644 --- a/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts +++ b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts @@ -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, diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index fb8e6802..284e0e19 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -138,6 +138,21 @@ function applySessionExpiryHeaders( } } +/** HTTP 状态码兜底文案:避免把英文 statusText(如 Not Found)原样抛给用户 */ +const HTTP_STATUS_MESSAGES: Record = { + 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() } diff --git a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx index e367e2af..07daf332 100644 --- a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx @@ -54,6 +54,8 @@ const STATUS_COLORS: Record = { 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 = { weibo: '微博', } +const BINDING_STATUS_LABELS: Record = { + not_started: '未开始', + qr_pending: '待扫码', + scanned: '已扫码', + role_ready: '待确认角色', + confirmed: '已确认', + failed: '失败', + expired: '已过期', +} + type CompleteFormValues = { note?: string externalOrderNo?: string @@ -344,6 +356,16 @@ export default function AdminSemiAutoTasksPage() { {task.displayStatus.label} {task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'} + + {task.platform === 'huya' + ? `绑定:${BINDING_STATUS_LABELS[task.bindingStatus] || task.bindingStatus}` + : '微博登录即授权'} + + {task.platform === 'huya' && (task.bindGameRole || task.bindGameRoleId) ? ( + + {`角色:${task.bindGameRole || '-'}${task.bindGameRoleId ? `(${task.bindGameRoleId})` : '(无角色ID)'}`} + + ) : null} CK: {task.credentialStatus === 'valid' @@ -616,6 +638,24 @@ export default function AdminSemiAutoTasksPage() { {detail.task.loginStatus === 'authenticated' ? 'CK 已获取' : '等待客户登录'} + + {detail.task.platform === 'weibo' + ? '微博登录即授权' + : BINDING_STATUS_LABELS[detail.task.bindingStatus] || detail.task.bindingStatus} + + + {detail.task.bindGameRole + ? `${detail.task.bindGameRole}${detail.task.bindGameRoleId ? `(${detail.task.bindGameRoleId})` : '(无角色ID)'}` + : '-'} + + + {detail.task.customerGameRoleName + ? `${detail.task.customerGameRoleName}${detail.task.customerGameRoleId ? `(${detail.task.customerGameRoleId})` : ''}` + : '-'} + + + {detail.task.customerServerZone || '-'} + {detail.task.assignedAdminName || '未领取'} @@ -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' diff --git a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx index 3b1da320..e1212209 100644 --- a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx +++ b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx @@ -5,33 +5,47 @@ import { ReloadOutlined, SafetyCertificateOutlined, } from '@ant-design/icons' -import { App, Button, Image, Input, Spin, Tabs } from 'antd' +import { App, Alert, Button, Image, Input, Modal, Select, Space, Spin, Tabs } from 'antd' import QRCode from 'qrcode' -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useParams } from 'react-router' import { + confirmCustomerBinding, fetchCustomerFulfillment, loginCustomerHuyaSms, loginCustomerWeiboSms, markCustomerFulfillmentOpened, + pollCustomerBinding, pollCustomerLogin, pollCustomerWeiboQrLogin, + saveCustomerTaskProfile, sendCustomerHuyaSmsCode, sendCustomerWeiboSmsCode, + startCustomerBinding, startCustomerLogin, startCustomerWeiboQrLogin, verifyCustomerHuyaSmsChallenge, type CustomerHuyaChallengeSolution, type CustomerHuyaSmsChallenge, } from '@/services/customer-fulfillment' -import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment' +import type { + CustomerBindingInfo, + CustomerLoginInfo, + CustomerSemiAutoTask, +} from '@/types/customer-fulfillment' import { formatAdminDateTime } from '@/utils/admin-time' +import collectHero from '@/assets/claim/game-role-id-guide.jpg' import { runWeiboYidunChallenge } from '@/utils/weibo-yidun' import HuyaChallengeModal from './HuyaChallengeModal' const POLL_INTERVAL_MS = 2000 +const SERVER_ZONE_OPTIONS = ['苹果Q区', '苹果V区', '安卓Q区', '安卓V区'] + +/** antd v6 语义化 classNames:让区服下拉弹层用页面深色样式 */ +const SERVER_ZONE_SELECT_CLASS_NAMES = { popup: { root: 'customer-collect-dropdown' } } + export default function CustomerFulfillmentPage() { const { message } = App.useApp() const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>() @@ -43,6 +57,8 @@ export default function CustomerFulfillmentPage() { const [loginInfo, setLoginInfo] = useState(null) const [loginQrImage, setLoginQrImage] = useState('') const [loginMode, setLoginMode] = useState<'sms' | 'qr'>('sms') + // 扫码页点「改用手机号登录」时,即使登录状态仍是 qr_pending 也切回短信表单。 + const [showSmsFallback, setShowSmsFallback] = useState(false) const [mobile, setMobile] = useState('') const [smsCode, setSmsCode] = useState('') const [smsSending, setSmsSending] = useState(false) @@ -51,6 +67,21 @@ export default function CustomerFulfillmentPage() { const [smsMfaRequired, setSmsMfaRequired] = useState(false) const [huyaChallenge, setHuyaChallenge] = useState(null) const [huyaChallengeLoading, setHuyaChallengeLoading] = useState(false) + const [bindingInfo, setBindingInfo] = useState(null) + const [bindingLoading, setBindingLoading] = useState(false) + const [bindingConfirmOpen, setBindingConfirmOpen] = useState(false) + const [bindingSecondsLeft, setBindingSecondsLeft] = useState(0) + const [bindingQrImage, setBindingQrImage] = useState('') + const [profileRoleName, setProfileRoleName] = useState('') + const [profileRoleId, setProfileRoleId] = useState('') + const [profileServerZone, setProfileServerZone] = useState('') + const [profileSaving, setProfileSaving] = useState(false) + const [profileEditing, setProfileEditing] = useState(false) + const startingBindingRef = useRef(false) + const hydratingBindingRef = useRef(false) + const autoOpenedRoleRef = useRef('') + // Connector 重启或会话被回收后,绑定/确认接口会返回 huya_native_session_not_found。 + const [bindingSessionExpired, setBindingSessionExpired] = useState(false) const selectedTask = useMemo( () => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null, @@ -95,11 +126,18 @@ export default function CustomerFulfillmentPage() { useEffect(() => { if (!selectedTask) return setLoginMode('sms') + setShowSmsFallback(false) setMobile('') setSmsCode('') setSmsMfaRequired(false) setSmsCountdown(0) setHuyaChallenge(null) + setBindingInfo(null) + setBindingConfirmOpen(false) + startingBindingRef.current = false + hydratingBindingRef.current = false + autoOpenedRoleRef.current = '' + setBindingSessionExpired(false) }, [selectedTask?.id]) useEffect(() => { @@ -131,6 +169,19 @@ export default function CustomerFulfillmentPage() { setHuyaChallenge(null) }, [selectedTask?.id, selectedTask?.loginStatus]) + useEffect(() => { + if (!selectedTask) return + setProfileRoleName(selectedTask.customerGameRoleName || '') + setProfileRoleId(selectedTask.customerGameRoleId || '') + setProfileServerZone(selectedTask.customerServerZone || '') + setProfileEditing(false) + }, [ + selectedTask?.id, + selectedTask?.customerGameRoleName, + selectedTask?.customerGameRoleId, + selectedTask?.customerServerZone, + ]) + useEffect(() => { if (loginInfo?.qrImage) { setLoginQrImage(loginInfo.qrImage) @@ -178,6 +229,116 @@ export default function CustomerFulfillmentPage() { return () => window.clearInterval(timer) }, [orderNo, selectedTask?.id, selectedTask?.loginStatus, selectedTask?.platform]) + // 登录成功后自动进入绑定阶段;失败或过期时由客户点按钮重试,避免页面来回跳。 + useEffect(() => { + if (!selectedTask || !orderNo || selectedTask.platform !== 'huya') return + if (selectedTask.loginStatus !== 'authenticated') return + if (selectedTask.bindingStatus === 'confirmed') return + if (selectedTask.bindingStatus !== 'not_started' || startingBindingRef.current) return + startingBindingRef.current = true + void startCustomerBinding(orderNo, selectedTask.id) + .then((response) => { + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + setBindingInfo(response.data.binding) + }) + .catch((error) => { + if (isHuyaSessionExpired(error)) { + setBindingSessionExpired(true) + return + } + message.error(error instanceof Error ? error.message : '创建绑定二维码失败') + }) + .finally(() => { + startingBindingRef.current = false + }) + }, [ + message, + orderNo, + selectedTask?.bindingStatus, + selectedTask?.id, + selectedTask?.loginStatus, + selectedTask?.platform, + ]) + + useEffect(() => { + if (!selectedTask || !orderNo || selectedTask.platform !== 'huya') return + if (!['qr_pending', 'scanned'].includes(selectedTask.bindingStatus)) return + const tick = async () => { + try { + const response = await pollCustomerBinding(orderNo, selectedTask.id) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + if (response.data.binding) setBindingInfo(response.data.binding) + } catch { + // 短暂轮询失败不打断客户页面,下一轮继续。 + } + } + const timer = window.setInterval(() => { + void tick() + }, POLL_INTERVAL_MS) + return () => window.clearInterval(timer) + }, [orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.platform]) + + // 进入 role_ready 时补一次绑定详情,保证确认弹窗能拿到两侧角色 ID。 + useEffect(() => { + if (!selectedTask || !orderNo || selectedTask.platform !== 'huya') return + if (selectedTask.bindingStatus !== 'role_ready' || bindingInfo || hydratingBindingRef.current) + return + hydratingBindingRef.current = true + void pollCustomerBinding(orderNo, selectedTask.id) + .then((response) => { + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + if (response.data.binding) setBindingInfo(response.data.binding) + }) + .catch((error) => { + if (isHuyaSessionExpired(error)) setBindingSessionExpired(true) + }) + .finally(() => { + hydratingBindingRef.current = false + }) + }, [bindingInfo, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.platform]) + + useEffect(() => { + if (!selectedTask || selectedTask.bindingStatus !== 'role_ready') return + if (!bindingInfo?.comparison) return + const key = `${selectedTask.id}:${bindingInfo.bindGameRoleId || ''}` + if (autoOpenedRoleRef.current === key) return + autoOpenedRoleRef.current = key + setBindingConfirmOpen(true) + }, [bindingInfo, selectedTask?.bindingStatus, selectedTask?.id]) + + useEffect(() => { + const url = bindingInfo?.qrUrl + if (!url) { + setBindingQrImage('') + return + } + void QRCode.toDataURL(url, { + width: 320, + margin: 2, + color: { dark: '#111827', light: '#ffffff' }, + }).then(setBindingQrImage) + }, [bindingInfo?.qrUrl]) + + useEffect(() => { + const expiresAt = bindingInfo?.qrExpiresAt ? new Date(bindingInfo.qrExpiresAt).getTime() : 0 + if (!expiresAt || Number.isNaN(expiresAt)) { + setBindingSecondsLeft(0) + return + } + const update = () => { + setBindingSecondsLeft(Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))) + } + update() + const timer = window.setInterval(update, 1000) + return () => window.clearInterval(timer) + }, [bindingInfo?.qrExpiresAt]) + async function handleStartLogin() { if (!selectedTask) return setLoading(true) @@ -190,6 +351,7 @@ export default function CustomerFulfillmentPage() { current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), ) setLoginInfo(response.data.login) + setBindingSessionExpired(false) } catch (error) { message.error(error instanceof Error ? error.message : '创建登录二维码失败') } finally { @@ -273,7 +435,7 @@ export default function CustomerFulfillmentPage() { ) setLoginInfo(response.data.login) setHuyaChallenge(null) - message.success(`${isWeibo ? '微博' : '虎牙'}登录成功,等待客服处理`) + message.success(isWeibo ? '微博登录成功,等待客服处理' : '登录成功,请继续绑定游戏角色') } catch (error) { const errorCode = (error as { errorCode?: string } | null)?.errorCode || '' if (errorCode === 'weibo_sms_mfa_required') { @@ -307,10 +469,217 @@ export default function CustomerFulfillmentPage() { } } + async function handleRefreshBinding(forceRebind: boolean) { + if (!selectedTask) return + setBindingLoading(true) + try { + const response = await startCustomerBinding(orderNo, selectedTask.id, { forceRebind }) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + setBindingInfo(response.data.binding) + setBindingConfirmOpen(false) + } catch (error) { + if (isHuyaSessionExpired(error)) { + setBindingSessionExpired(true) + setBindingConfirmOpen(false) + message.warning('虎牙登录会话已过期,请重新登录') + return + } + message.error(error instanceof Error ? error.message : '创建绑定二维码失败') + } finally { + setBindingLoading(false) + } + } + + async function handlePollBindingNow(openConfirm = false) { + if (!selectedTask) return + setBindingLoading(true) + try { + const response = await pollCustomerBinding(orderNo, selectedTask.id) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + if (response.data.binding) setBindingInfo(response.data.binding) + if (openConfirm || response.data.task.bindingStatus === 'role_ready') { + setBindingConfirmOpen(true) + } + } catch (error) { + if (isHuyaSessionExpired(error)) { + setBindingSessionExpired(true) + setBindingConfirmOpen(false) + message.warning('虎牙登录会话已过期,请重新登录') + return + } + message.error(error instanceof Error ? error.message : '刷新绑定状态失败') + } finally { + setBindingLoading(false) + } + } + + async function handleConfirmBinding() { + if (!selectedTask) return + setBindingLoading(true) + try { + const response = await confirmCustomerBinding(orderNo, selectedTask.id) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + setBindingInfo(response.data.binding) + setBindingConfirmOpen(false) + message.success('绑定已确认,等待客服处理') + } catch (error) { + if (isHuyaSessionExpired(error)) { + setBindingSessionExpired(true) + setBindingConfirmOpen(false) + message.warning('虎牙登录会话已过期,请重新登录') + return + } + message.error(error instanceof Error ? error.message : '确认绑定失败') + } finally { + setBindingLoading(false) + } + } + + async function handleSaveProfile() { + if (!selectedTask) return + if (!profileServerZone.trim() || !profileRoleId.trim() || !profileRoleName.trim()) { + message.warning('请完整填写区服、游戏编号和角色名') + return + } + if (!SERVER_ZONE_OPTIONS.includes(profileServerZone.trim())) { + message.warning('请选择区服') + return + } + setProfileSaving(true) + try { + const response = await saveCustomerTaskProfile(orderNo, selectedTask.id, { + roleName: profileRoleName.trim(), + roleId: profileRoleId.trim(), + serverZone: profileServerZone.trim(), + }) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + setProfileEditing(false) + try { + const bindingResponse = await pollCustomerBinding(orderNo, selectedTask.id) + if (bindingResponse.data.binding) setBindingInfo(bindingResponse.data.binding) + } catch { + // 绑定尚未开始时没有 binding,可忽略。 + } + message.success('角色资料已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存角色资料失败') + } finally { + setProfileSaving(false) + } + } + + async function handleCopyBindingLink() { + const url = bindingInfo?.qrUrl || '' + if (!url) { + message.warning('当前二维码不支持复制链接,请长按二维码保存图片') + return + } + try { + await navigator.clipboard.writeText(url) + message.success('二维码链接已复制') + } catch { + message.error('复制失败,请长按二维码保存图片') + } + } + const config = selectedTask?.semiAutoConfig || {} - const displayTitle = isWeibo ? '微博账号登录授权' : '虎牙账号登录授权' + const displayTitle = isWeibo ? '微博账号登录授权' : '绑定游戏角色' const loginQrVisible = - selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus) + Boolean(selectedTask) && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus) + const showBindingStage = Boolean( + selectedTask && + selectedTask.platform === 'huya' && + selectedTask.loginStatus === 'authenticated' && + selectedTask.bindingStatus !== 'confirmed', + ) + const bindingQrVisible = ['not_started', 'qr_pending', 'scanned'].includes( + selectedTask?.bindingStatus || '', + ) + const bindingComparison = bindingInfo?.comparison || null + const bindingRoleId = bindingInfo?.bindGameRoleId || selectedTask?.bindGameRoleId || '' + // 虎牙 gameRole.roleId 是加密串,客户侧统一展示数值 UID(huyaUid)。 + const bindingPendingUid = (() => { + const comparisonId = String(bindingComparison?.boundRoleId || '').trim() + if (/^\d+$/.test(comparisonId)) return comparisonId + const huyaUid = String(selectedTask?.huyaUid || '').trim() + return /^\d+$/.test(huyaUid) ? huyaUid : comparisonId + })() + const bindingRoleName = bindingInfo?.bindGameRole || selectedTask?.bindGameRole || '' + const showProfileForm = showBindingStage && profileEditing + const bindingCustomerReady = Boolean( + bindingComparison?.customerRoleId || bindingComparison?.customerRoleName, + ) + const needsProfileCollection = Boolean( + selectedTask && + selectedTask.platform === 'huya' && + selectedTask.executionStatus !== 'completed' && + selectedTask.bindingStatus !== 'confirmed' && + (!selectedTask.customerServerZone || + !selectedTask.customerGameRoleId || + !selectedTask.customerGameRoleName), + ) + + const huyaLoginForm = ( +
+

请使用你的「虎牙」账号登录

+
+ setMobile(event.target.value)} + placeholder="请输入号码" + inputMode="numeric" + maxLength={11} + /> + +
+ setSmsCode(event.target.value)} + placeholder="6 位验证码" + maxLength={8} + inputMode="numeric" + onPressEnter={() => void handleLoginSms()} + /> + + +
+ ) if (loading) { return ( @@ -337,7 +706,7 @@ export default function CustomerFulfillmentPage() { if (selectedTask?.executionStatus === 'completed') { return ( - +
@@ -360,6 +729,17 @@ export default function CustomerFulfillmentPage() { ) } + if (needsProfileCollection && selectedTask) { + return ( + + setTasks((current) => current.map((item) => (item.id === task.id ? task : item))) + } + /> + ) + } + return (
@@ -383,98 +763,103 @@ export default function CustomerFulfillmentPage() {
) : null} - + {selectedTask?.loginStatus === 'waiting_login' || selectedTask?.loginStatus === 'failed' || - selectedTask?.loginStatus === 'expired' ? ( + selectedTask?.loginStatus === 'expired' || + (showSmsFallback && selectedTask?.loginStatus === 'qr_pending') ? (
- setLoginMode(key as 'sms' | 'qr')} - items={[ - { - key: 'sms', - label: '手机号登录', - children: ( -
-
- +86 - setMobile(event.target.value)} - placeholder={`请输入${isHuya ? '虎牙' : '微博'}绑定手机号`} - maxLength={11} - inputMode="numeric" - /> - -
- {isWeibo && smsMfaRequired ? ( -
- 请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。 + {isWeibo ? ( + setLoginMode(key as 'sms' | 'qr')} + items={[ + { + key: 'sms', + label: '手机号登录', + children: ( +
+
+ +86 + setMobile(event.target.value)} + placeholder={`请输入${isHuya ? '虎牙' : '微博'}绑定手机号`} + maxLength={11} + inputMode="numeric" + /> +
- ) : null} -
- setSmsCode(event.target.value)} - placeholder="请输入短信验证码" - maxLength={8} - prefix={} - onPressEnter={() => void handleLoginSms()} - /> + {isWeibo && smsMfaRequired ? ( +
+ 请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。 +
+ ) : null} +
+ setSmsCode(event.target.value)} + placeholder="请输入短信验证码" + maxLength={8} + prefix={} + onPressEnter={() => void handleLoginSms()} + /> + +
+ {isWeibo ? ( + + 如安全验证弹窗未出现,请检查浏览器是否拦截脚本。 + + ) : null} +
+ ), + }, + { + key: 'qr', + label: '扫码登录', + children: ( +
+

+ {config.loginInstructions || + `点击下方按钮后,使用${isHuya ? '虎牙' : '微博'} App 扫码并确认登录。`} +

- {isWeibo ? ( - - 如安全验证弹窗未出现,请检查浏览器是否拦截脚本。 - - ) : null} -
- ), - }, - { - key: 'qr', - label: '扫码登录', - children: ( -
-

- {config.loginInstructions || - `点击下方按钮后,使用${isHuya ? '虎牙' : '微博'} App 扫码并确认登录。`} -

- -
- ), - }, - ]} - /> + ), + }, + ]} + /> + ) : ( + huyaLoginForm + )}
) : null} - {loginQrVisible ? ( + {loginQrVisible && !showSmsFallback ? (
@@ -508,16 +893,226 @@ export default function CustomerFulfillmentPage() { ) : null} + {isWeibo ? null : ( + + )}
) : null} + {showBindingStage ? ( +
+
+

+ 绑定信息 (已登录虎牙账号) +

+
+ 登录账号 + + {bindingInfo?.bindGameAccount || selectedTask.bindGameAccount || '未获取'} + +
+
+ 绑定角色 + {bindingRoleName || '未识别到角色'} +
+
+ + {bindingSessionExpired ? ( + { + setBindingSessionExpired(false) + void handleStartLogin() + }} + > + 重新登录 + + } + /> + ) : null} + + {!bindingSessionExpired ? ( + !bindingRoleId ? ( +
+ 未获取到已绑定的角色ID,请重新扫码绑定 +
+ ) : ( +
+ 登录成功,请继续扫码绑定游戏角色 +
+ ) + ) : null} + + {showProfileForm && !bindingSessionExpired ? ( +
+

修改资料后会重新核对平台识别角色。

+ + setProfileRoleId(event.target.value)} + placeholder="游戏编号 / 角色 ID(必填,纯数字)" + maxLength={64} + inputMode="numeric" + /> + setProfileRoleName(event.target.value)} + placeholder="角色名(必填)" + maxLength={100} + /> + + +
+ ) : null} + + {bindingQrVisible && !bindingSessionExpired ? ( +
+

游戏角色绑定二维码

+ {bindingQrImage || bindingInfo?.qrImage ? ( + 游戏角色绑定二维码 + ) : ( +
+ + 正在生成绑定二维码... +
+ )} +

请使用微信APP扫码进行游戏角色绑定

+

手机端可长按二维码或截图保存图片再识别

+

+ {selectedTask?.bindingStatus === 'scanned' + ? '已扫码,等待角色生效' + : `建议 ${formatCountdown(bindingSecondsLeft)} 内完成扫码`} +

+
+ + +
+ +
+ ) : null} + + {selectedTask?.bindingStatus === 'expired' ? ( + void handleRefreshBinding(false)}> + 重新获取 + + } + /> + ) : null} + + {selectedTask?.bindingStatus === 'failed' ? ( + void handleRefreshBinding(false)}> + 重新获取 + + } + /> + ) : null} + + {selectedTask?.bindingStatus === 'role_ready' && !bindingSessionExpired ? ( + + + {!bindingRoleId ? ( + + ) : null} + + + + + + ) : null} +
+ ) : null} + {selectedTask?.loginStatus === 'authenticated' && + (isWeibo || selectedTask.bindingStatus === 'confirmed') && selectedTask.executionStatus !== 'completed' ? (
@@ -535,6 +1130,84 @@ export default function CustomerFulfillmentPage() {
) : null} + { + if (!bindingLoading) setBindingConfirmOpen(false) + }} + footer={ +
+
+ + +
+ {bindingComparison?.verdict === 'mismatch' ? ( +

两侧角色ID不一致,无法确认绑定

+ ) : bindingComparison?.verdict === 'unknown' ? ( +

两侧角色信息不完整,请联系客服核实

+ ) : null} +
+ } + destroyOnClose + > + +

检测到待确认的绑定角色,请核对两侧角色是否一致。

+
+
+
+ 您填写的角色 + +
+ {bindingComparison?.customerRoleName || '未填写'} + {bindingComparison?.customerRoleId || '未填写角色ID'} +
+
+
+ 待确认角色 + +
+ {bindingComparison?.boundRoleName || '未识别到角色'} + {bindingPendingUid || '未获取到角色ID'} +
+
+
+
+ void handleVerifyHuyaChallenge(solution)} /> - + {isWeibo ? : null}
) @@ -568,60 +1241,149 @@ function CustomerAuthFrame(input: {
-
- {input.platform === 'weibo' - ? '温馨提示:未成年人禁止下单' - : '温馨提示:请使用本人账号完成授权'} -
+ {input.platform === 'weibo' ? ( +
温馨提示:未成年人禁止下单
+ ) : null}
{input.children}
) } -function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) { - const task = input.task - if (!task) return null - const statusTone = - task.displayStatus.key === 'credential_expired' || task.displayStatus.key === 'failed' - ? 'is-danger' - : task.loginStatus === 'authenticated' - ? 'is-success' - : 'is-pending' - const showAccountDetails = ['authenticated', 'scanned'].includes(task.loginStatus) +function CustomerOrderCard({ task }: { task: CustomerSemiAutoTask }) { + const zone = task.customerServerZone + ? /区$/.test(task.customerServerZone) + ? task.customerServerZone + : `${task.customerServerZone}区` + : '' + const roleText = [zone, task.customerGameRoleName].filter(Boolean).join('-') || '未填写' return ( -
+
+

订单信息

- 订单号 - {task.orderNo} + 游戏名称 + 和平精英
- 商品 - - {task.productName || task.skuName} × {task.quantity} - + 角色名称 + {roleText}
- 平台 - {task.platformLabel || task.platform} + 购买物品 + {task.productName || task.skuName}
- {showAccountDetails && task.huyaUid ? ( -
- 平台账号 ID - {task.huyaUid} +
+ ) +} + +function CustomerCollectStep(input: { + task: CustomerSemiAutoTask + onSaved: (task: CustomerSemiAutoTask) => void +}) { + const { message } = App.useApp() + const [serverZone, setServerZone] = useState(input.task.customerServerZone || '') + const [roleId, setRoleId] = useState(input.task.customerGameRoleId || '') + const [roleName, setRoleName] = useState(input.task.customerGameRoleName || '') + const [saving, setSaving] = useState(false) + + async function submit() { + if (!serverZone.trim() || !roleId.trim() || !roleName.trim()) { + message.warning('请完整填写区服、游戏编号和角色名') + return + } + if (!SERVER_ZONE_OPTIONS.includes(serverZone.trim())) { + message.warning('请选择区服') + return + } + if (!/^\d+$/.test(roleId.trim())) { + message.warning('游戏编号只能是数字') + return + } + setSaving(true) + try { + const response = await saveCustomerTaskProfile(input.task.orderNo, input.task.id, { + roleName: roleName.trim(), + roleId: roleId.trim(), + serverZone: serverZone.trim(), + }) + input.onSaved(response.data.task) + message.success('资料已提交') + } catch (error) { + message.error(error instanceof Error ? error.message : '提交资料失败') + } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+

温馨提示:未成年人禁止下单

+
+
+ +
{input.task.orderNo}
+ +
+
+ 商品 + {input.task.productName || input.task.skuName} +
+
+ 游戏 + 和平精英 +
+

填完资料记得截图联系客服。

- ) : null} - {showAccountDetails && task.platform === 'weibo' && task.platformNickname ? ( -
- 账号昵称 - {task.platformNickname} -
- ) : null} -
- 状态 - - {task.displayStatus.label} - + + + setRoleId(event.target.value)} + placeholder="请输入游戏编号" + maxLength={64} + inputMode="numeric" + /> + + + setRoleName(event.target.value)} + placeholder="请输入角色名" + maxLength={100} + /> + +
) @@ -635,8 +1397,20 @@ function CustomerPrivacyNote() { ) } +/** Connector 重启或会话被回收时后端返回该错误码,需要引导客户重新登录。 */ +function isHuyaSessionExpired(error: unknown) { + return (error as { errorCode?: string } | null)?.errorCode === 'huya_native_session_not_found' +} + function isValidMainlandMobile(value: string) { const digits = value.replace(/\D/g, '') if (!digits) return false return /^1\d{10}$/.test(digits) } + +function formatCountdown(seconds: number) { + const safe = Math.max(0, Math.floor(seconds)) + const minutes = Math.floor(safe / 60) + const rest = safe % 60 + return `${minutes}:${String(rest).padStart(2, '0')}` +} diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts index b160b86f..036843a2 100644 --- a/apps/frontend/src/services/customer-fulfillment.ts +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -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( @@ -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 diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index ede4a554..7fa58962 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -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, diff --git a/apps/frontend/src/types/admin/semi-auto.ts b/apps/frontend/src/types/admin/semi-auto.ts index 1d9fbfdf..7ec570e6 100644 --- a/apps/frontend/src/types/admin/semi-auto.ts +++ b/apps/frontend/src/types/admin/semi-auto.ts @@ -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 diff --git a/apps/frontend/src/types/customer-fulfillment.ts b/apps/frontend/src/types/customer-fulfillment.ts index 842f423a..1e3fbf20 100644 --- a/apps/frontend/src/types/customer-fulfillment.ts +++ b/apps/frontend/src/types/customer-fulfillment.ts @@ -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 +}