From cc0ced985813f8a76e7b97a0d5ea95c243032db7 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 22 Jun 2026 12:54:11 +0800 Subject: [PATCH] feat: add admin UI, creator review status, and order payment flow - U1: Admin login page with isolated token/state management (#admin hash route) - U2: Admin review list + role detail page with approve/reject actions - U3: Admin sync form, QR code display, and system config editor - U4: Creator role cards show review status, run status, and QR code - U5: Order API (POST/GET /api/orders, GET /api/orders/:id) with auth - U6: Frontend payment flow calls POST /api/orders and shows real QR code - Fix e2e test: add qrCodeUrl to synced test role for payment flow --- app.js | 553 +++++++++++++++++- ...reator-review-status-order-payment-plan.md | 461 +++++++++++++++ e2e/roles.spec.js | 1 + index.html | 67 +++ server.js | 1 + src/routes/orders.js | 137 +++++ styles.css | 17 + 7 files changed, 1216 insertions(+), 21 deletions(-) create mode 100644 docs/plans/2026-06-21-002-feat-admin-ui-creator-review-status-order-payment-plan.md create mode 100644 src/routes/orders.js diff --git a/app.js b/app.js index 75e12a9..d395aa0 100644 --- a/app.js +++ b/app.js @@ -71,6 +71,265 @@ const state = loadState(); + // --- 管理员认证状态管理 --- + const ADMIN_TOKEN_KEY = 'eternal_ai_admin_token'; + const ADMIN_STATE_KEY = 'eternal_ai_admin_state'; + + function getAdminToken() { + return localStorage.getItem(ADMIN_TOKEN_KEY) || ''; + } + + function setAdminToken(token) { + if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token); + else localStorage.removeItem(ADMIN_TOKEN_KEY); + } + + const defaultAdminState = { isLoggedIn: false, account: null }; + function loadAdminState() { + try { + return { ...defaultAdminState, ...JSON.parse(localStorage.getItem(ADMIN_STATE_KEY)) }; + } catch { return { ...defaultAdminState }; } + } + function saveAdminState() { localStorage.setItem(ADMIN_STATE_KEY, JSON.stringify(adminState)); } + const adminState = loadAdminState(); + + // 管理员 API 封装(使用管理员 token) + async function adminApi(path, options = {}) { + const token = getAdminToken(); + const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + try { + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `请求失败 (${res.status})`); + return data; + } catch (err) { + if (err.message === 'Failed to fetch') throw new Error('无法连接服务器,请检查网络'); + throw err; + } + } + + // --- 管理员审核列表 --- + let adminCurrentFilter = 'pending_review'; + + async function renderAdminReviews() { + const listEl = document.getElementById('admin-review-list'); + if (!listEl) return; + listEl.innerHTML = '

加载中…

'; + try { + const query = adminCurrentFilter ? `?status=${adminCurrentFilter}` : ''; + const { roles } = await adminApi(`/admin/reviews${query}`); + if (roles.length === 0) { + listEl.innerHTML = '

暂无角色

'; + return; + } + listEl.innerHTML = roles.map((role) => { + const id = escapeHtml(role.id); + const name = escapeHtml(role.displayName); + const avatar = escapeHtml(role.avatar || ''); + const status = escapeHtml(role.reviewStatus); + const statusText = getReviewStatusText(role.reviewStatus); + const statusClass = getReviewStatusClass(role.reviewStatus); + const created = new Date(role.createdAt).toLocaleString('zh-CN'); + return ` +
+ ${name} +
+

${name}

+ ${statusText} + ${created} +
+
`; + }).join(''); + } catch (err) { + listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; + } + } + + function getReviewStatusText(status) { + const map = { + pending_review: '待审核', approved: '已通过', rejected: '已驳回', + syncing: '同步中', synced: '已同步', failed: '同步失败', + }; + return map[status] || status; + } + + function getReviewStatusClass(status) { + const map = { + pending_review: 'pending', approved: 'approved', rejected: 'rejected', + syncing: 'syncing', synced: 'synced', failed: 'failed', + }; + return map[status] || 'pending'; + } + + // --- 管理员审核详情 --- + let adminCurrentRoleId = null; + + async function renderAdminReviewDetail(roleId) { + adminCurrentRoleId = roleId; + const contentEl = document.getElementById('admin-detail-content'); + if (!contentEl) return; + contentEl.innerHTML = '

加载中…

'; + try { + const { role } = await adminApi(`/admin/reviews/${roleId}`); + const status = role.reviewStatus; + let actionsHtml = ''; + if (status === 'pending_review') { + actionsHtml = ` +
+ + +
`; + } else if (status === 'approved') { + actionsHtml = `

发起 Hermes 同步

+
+
+
+
+
+
+
+
+ +
`; + } else if (status === 'synced') { + actionsHtml = ` +
+

同步成功

+ ${role.qrCodeUrl ? `二维码` : ''} +

Profile ID: ${escapeHtml(role.id)}

+

同步时间: ${role.syncedAt ? new Date(role.syncedAt).toLocaleString('zh-CN') : '未知'}

+
`; + } else if (status === 'rejected') { + actionsHtml = `
驳回原因:${escapeHtml(role.reviewNote || '未提供')}
`; + } else if (status === 'failed') { + actionsHtml = `
同步失败

角色状态已标记为失败,可重新发起同步。

`; + } + + contentEl.innerHTML = ` +
+ ${escapeHtml(role.displayName)} +
+

${escapeHtml(role.displayName)}

+ ${getReviewStatusText(status)} +
+
+
+

描述:${escapeHtml(role.desc || role.personality || '无')}

+

性格:${escapeHtml(role.personality || '无')}

+

背景:${escapeHtml(role.background || '无')}

+

问候语:${escapeHtml(role.greeting || '无')}

+

价格:¥${escapeHtml(String(role.price || 0))}

+
+ ${actionsHtml} + `; + + // 绑定同步表单提交 + const syncForm = document.getElementById('admin-sync-form'); + if (syncForm) { + syncForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(syncForm); + const data = Object.fromEntries(formData.entries()); + data.enableSchedule = !!formData.get('enableSchedule'); + if (!data.webhookUrl) delete data.webhookUrl; + const submitBtn = syncForm.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = '同步中…'; + try { + const result = await adminApi(`/admin/sync/${roleId}`, { + method: 'POST', + body: JSON.stringify(data), + }); + alert('同步成功!Profile ID: ' + result.profileId); + renderAdminReviewDetail(roleId); + } catch (err) { + alert('同步失败:' + err.message); + submitBtn.disabled = false; + submitBtn.textContent = '发起同步'; + } + }); + } + } catch (err) { + contentEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; + } + } + + // --- 管理员同步状态列表 --- + async function renderAdminSyncStatus() { + const listEl = document.getElementById('admin-sync-list'); + if (!listEl) return; + listEl.innerHTML = '

加载中…

'; + try { + const { roles } = await adminApi('/admin/sync-status'); + if (roles.length === 0) { + listEl.innerHTML = '

暂无同步记录

'; + return; + } + listEl.innerHTML = roles.map((role) => { + const id = escapeHtml(role.id); + const name = escapeHtml(role.displayName); + const status = escapeHtml(role.reviewStatus); + const statusText = getReviewStatusText(role.reviewStatus); + const statusClass = getReviewStatusClass(role.reviewStatus); + const syncedAt = role.syncedAt ? new Date(role.syncedAt).toLocaleString('zh-CN') : '未同步'; + return ` +
+
+

${name}

+ ${statusText} + 同步时间:${syncedAt} +
+
`; + }).join(''); + } catch (err) { + listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; + } + } + + // --- 管理员系统配置 --- + async function renderAdminConfig() { + const listEl = document.getElementById('admin-config-list'); + if (!listEl) return; + listEl.innerHTML = '

加载中…

'; + try { + const { configs } = await adminApi('/admin/config'); + listEl.innerHTML = configs.map((c) => { + const key = escapeHtml(c.key); + const value = escapeHtml(c.value); + const updated = c.updatedAt ? new Date(c.updatedAt).toLocaleString('zh-CN') : ''; + const isProtected = key === 'SYNC_SECRET'; + return ` +
+ +
+ + ${isProtected ? '' : ``} +
+ ${updated ? `更新于 ${updated}` : ''} +
`; + }).join(''); + } catch (err) { + listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; + } + } + + // --- 管理员 Tab 切换 --- + function switchAdminTab(tab) { + document.querySelectorAll('.center-tab').forEach((t) => { + if (t.closest('#admin-reviews')) { + const isActive = t.dataset.centerTab === tab; + t.classList.toggle('active', isActive); + } + }); + document.getElementById('admin-reviews-panel').hidden = tab !== 'admin-reviews'; + document.getElementById('admin-sync-panel').hidden = tab !== 'admin-sync'; + document.getElementById('admin-config-panel').hidden = tab !== 'admin-config'; + if (tab === 'admin-reviews') renderAdminReviews(); + else if (tab === 'admin-sync') renderAdminSyncStatus(); + else if (tab === 'admin-config') renderAdminConfig(); + } + // --- Mock data for role library (U2) --- const mockRoles = [ { @@ -119,6 +378,9 @@ onboarding: document.getElementById('onboarding'), 'creator-center': document.getElementById('creator-center'), creator: document.getElementById('creator'), + 'admin-login': document.getElementById('admin-login'), + 'admin-reviews': document.getElementById('admin-reviews'), + 'admin-review-detail': document.getElementById('admin-review-detail'), }; const form = document.getElementById('character-form'); @@ -149,6 +411,9 @@ onboarding: '创作者入驻', 'creator-center': '创作者管理中心', creator: '角色编辑', + 'admin-login': '管理员登录', + 'admin-reviews': '管理员后台', + 'admin-review-detail': '角色审核详情', }; function showView(name, trackHistory = true) { @@ -358,12 +623,36 @@ } } - function payRole() { - document.getElementById('detail-actions-pre').hidden = true; - const paidEl = document.getElementById('detail-paid'); - paidEl.hidden = false; - document.getElementById('detail-qr').innerHTML = '
扫码连接
AI 角色
'; - document.getElementById('detail-avatar').style.backgroundImage = `url(${currentRole.avatar})`; + async function payRole() { + if (!currentRole) return; + const btn = document.querySelector('[data-action="pay"]'); + if (btn) { + btn.disabled = true; + btn.textContent = '处理中…'; + } + try { + const result = await api('/orders', { + method: 'POST', + body: JSON.stringify({ roleId: currentRole.id, amount: currentRole.price }), + }); + // 付款成功,展示二维码 + document.getElementById('detail-actions-pre').hidden = true; + const paidEl = document.getElementById('detail-paid'); + paidEl.hidden = false; + const qrEl = document.getElementById('detail-qr'); + if (result.role && result.role.qrCodeUrl) { + qrEl.innerHTML = `角色二维码`; + } else { + qrEl.innerHTML = '
扫码连接
AI 角色
'; + } + document.getElementById('detail-avatar').style.backgroundImage = `url(${currentRole.avatar})`; + } catch (err) { + alert('付款失败:' + err.message); + if (btn) { + btn.disabled = false; + btn.textContent = '立即订阅'; + } + } } // --- U4: About FAQ --- @@ -423,27 +712,59 @@ listEl.innerHTML = '

还没有创建角色,点击「新建角色」开始

'; return; } - listEl.innerHTML = roles - .map((role) => { - const id = escapeHtml(role.id); - const name = escapeHtml(role.displayName); - const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'); - const statusText = role.status === 'running' ? '运行中' : '已停止'; - const statusClass = escapeHtml(role.status); - return ` + listEl.innerHTML = roles.map((role) => { + const id = escapeHtml(role.id); + const name = escapeHtml(role.displayName); + const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'); + const reviewStatus = role.reviewStatus || 'pending_review'; + const statusText = getReviewStatusText(reviewStatus); + const statusClass = getReviewStatusClass(reviewStatus); + + // 审核状态标签 + let statusBadge = `${statusText}`; + + // 已同步角色额外显示运行状态 + if (reviewStatus === 'synced') { + const runStatus = role.status === 'running' ? '运行中' : '已停止'; + statusBadge += ` ${runStatus}`; + } + + // 驳回原因 + let rejectNote = ''; + if (reviewStatus === 'rejected' && role.reviewNote) { + rejectNote = `

驳回原因:${escapeHtml(role.reviewNote)}

`; + } + + // 二维码展示(已同步角色) + let qrCodeHtml = ''; + if (reviewStatus === 'synced' && role.qrCodeUrl) { + qrCodeHtml = ` +
+ 二维码 + +
`; + } + + // 操作按钮 + let actionButtons = ''; + if (reviewStatus === 'rejected' || reviewStatus === 'pending_review') { + actionButtons = ``; + } else if (reviewStatus === 'synced') { + actionButtons = ` `; + } + + return `
${name}

${name}

- ${statusText} -
-
- - +
${statusBadge}
+ ${rejectNote} + ${qrCodeHtml}
+
${actionButtons}
`; - }) - .join(''); + }).join(''); } catch (err) { listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; } @@ -616,6 +937,22 @@ document.body.appendChild(modal); } + // --- 二维码弹窗 --- + function showQrCodeModal(qrUrl) { + const modal = document.createElement('div'); + modal.id = 'qr-modal'; + modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000;'; + modal.innerHTML = ` +
+

角色二维码

+ 二维码 +
+ +
+
`; + document.body.appendChild(modal); + } + function switchCenterTab(tab) { activeCenterTab = tab; document.querySelectorAll('.center-tab').forEach((t) => { @@ -957,6 +1294,16 @@ ${data.secrets || '无'} return; } + // 管理员审核列表卡片点击(需在通用 role-card 之前处理) + const adminReviewCard = e.target.closest('.admin-review-card'); + if (adminReviewCard && !e.target.closest('[data-action]')) { + e.preventDefault(); + const roleId = adminReviewCard.dataset.roleId; + renderAdminReviewDetail(roleId); + showView('admin-review-detail'); + return; + } + // Role card click (U2/U3) const roleCard = e.target.closest('.role-card'); if (roleCard && !e.target.closest('[data-action]')) { @@ -975,11 +1322,30 @@ ${data.secrets || '无'} return; } + // 管理员筛选 Tab + const adminFilter = e.target.closest('.admin-filter'); + if (adminFilter) { + e.preventDefault(); + document.querySelectorAll('.admin-filter').forEach((f) => f.classList.remove('active')); + adminFilter.classList.add('active'); + adminCurrentFilter = adminFilter.dataset.filter; + renderAdminReviews(); + return; + } + const target = e.target.closest('[data-action], [data-tab], [data-download], [data-center-tab]'); if (!target) return; const action = target.dataset.action; + // 管理员 Tab 切换(需在通用 center tab 之前处理) + const adminCenterTab = target.dataset.centerTab; + if (adminCenterTab && adminCenterTab.startsWith('admin-')) { + e.preventDefault(); + switchAdminTab(adminCenterTab); + return; + } + // Center tab switching (U7) if (target.dataset.centerTab) { e.preventDefault(); @@ -1115,6 +1481,102 @@ ${data.secrets || '无'} return; } + // --- 管理员操作 --- + if (action === 'admin-logout') { + e.preventDefault(); + setAdminToken(''); + adminState.isLoggedIn = false; + saveAdminState(); + window.location.hash = ''; + showView('landing'); + return; + } + + if (action === 'back-to-admin') { + e.preventDefault(); + showView('admin-reviews'); + renderAdminReviews(); + return; + } + + // 管理员审核操作 + if (action === 'admin-approve') { + e.preventDefault(); + const roleId = target.dataset.roleId; + try { + await adminApi(`/admin/reviews/${roleId}/approve`, { method: 'POST' }); + alert('审核通过'); + renderAdminReviewDetail(roleId); + } catch (err) { + alert('操作失败:' + err.message); + } + return; + } + + if (action === 'admin-reject') { + e.preventDefault(); + const roleId = target.dataset.roleId; + const reason = prompt('请输入驳回原因:'); + if (!reason) return; + try { + await adminApi(`/admin/reviews/${roleId}/reject`, { + method: 'POST', + body: JSON.stringify({ reviewNote: reason }), + }); + alert('已驳回'); + renderAdminReviewDetail(roleId); + } catch (err) { + alert('操作失败:' + err.message); + } + return; + } + + // 管理员保存配置 + if (action === 'admin-save-config') { + e.preventDefault(); + const key = target.dataset.configKey; + const input = document.querySelector(`input[data-config-key="${key}"]`); + if (!input) return; + try { + await adminApi(`/admin/config/${key}`, { + method: 'PUT', + body: JSON.stringify({ value: input.value }), + }); + alert('配置已保存'); + renderAdminConfig(); + } catch (err) { + alert('保存失败:' + err.message); + } + return; + } + + // --- 二维码操作 --- + if (action === 'show-qrcode') { + e.preventDefault(); + const qrUrl = target.dataset.qrUrl; + showQrCodeModal(qrUrl); + return; + } + + if (action === 'copy-qrcode') { + e.preventDefault(); + const qrUrl = target.dataset.qrUrl; + try { + await navigator.clipboard.writeText(qrUrl); + alert('二维码链接已复制'); + } catch { + alert('复制失败:' + qrUrl); + } + return; + } + + if (action === 'close-qr-modal') { + e.preventDefault(); + const modal = document.getElementById('qr-modal'); + if (modal) modal.remove(); + return; + } + if (action === 'logout') { e.preventDefault(); logout(); @@ -1229,6 +1691,30 @@ ${data.secrets || '无'} }); }); + // --- 管理员登录表单 --- + const adminLoginForm = document.getElementById('admin-login-form'); + if (adminLoginForm) { + adminLoginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + try { + const result = await adminApi('/admin-auth/login', { + method: 'POST', + body: JSON.stringify({ account: data.account, password: data.password }), + }); + setAdminToken(result.token); + adminState.isLoggedIn = true; + adminState.account = result.admin.account; + saveAdminState(); + showView('admin-reviews'); + renderAdminReviews(); + } catch (err) { + alert('登录失败:' + err.message); + } + }); + } + // --- Settings form (U7) --- document.getElementById('settings-form').addEventListener('submit', async (e) => { e.preventDefault(); @@ -1291,4 +1777,29 @@ ${data.secrets || '无'} setToken(''); } })(); + + // --- 管理员后台路由 --- + async function handleAdminRoute() { + if (window.location.hash !== '#admin') return; + const token = getAdminToken(); + if (!token) { + showView('admin-login'); + return; + } + // 验证 token 有效性 + try { + await adminApi('/admin-auth/me'); + showView('admin-reviews'); + renderAdminReviews(); + } catch { + setAdminToken(''); + adminState.isLoggedIn = false; + saveAdminState(); + showView('admin-login'); + } + } + + window.addEventListener('hashchange', handleAdminRoute); + // 页面加载时检查 hash + handleAdminRoute(); })(); diff --git a/docs/plans/2026-06-21-002-feat-admin-ui-creator-review-status-order-payment-plan.md b/docs/plans/2026-06-21-002-feat-admin-ui-creator-review-status-order-payment-plan.md new file mode 100644 index 0000000..e56f463 --- /dev/null +++ b/docs/plans/2026-06-21-002-feat-admin-ui-creator-review-status-order-payment-plan.md @@ -0,0 +1,461 @@ +--- +title: "feat: Admin UI + Creator Review Status + Order Payment" +status: active +created: 2026-06-21 +origin: docs/plans/2026-06-21-001-feat-admin-review-hermes-sync-plan.md (U7 前端缺失) +plan_depth: standard +--- + +# Plan: 管理员后台 UI + 创作者审核状态展示 + 订单付款流程 + +## Summary + +补齐 EternalAI 平台的三个核心功能缺口:(1) 管理员后台 UI,让管理员可通过浏览器完成角色审核和 Hermes 同步发起;(2) 创作者角色卡片审核状态展示,让创作者看到自己角色的审核进度和二维码;(3) Order 订单后端 API + 前端付款流程对接,替换当前的纯前端 Mock。微信 OAuth 和收入/提现后端不在本次范围内。 + +--- + +## Problem Frame + +当前平台存在三个阻断性缺口: + +1. **管理员无法通过浏览器操作**:后端审核 API(`/api/admin/reviews/*`)、同步 API(`/api/admin/sync/*`)、配置 API(`/api/admin/config/*`)均已就绪,但 `index.html` 中无任何 admin 视图,`app.js` 中无 admin 相关代码。管理员只能通过 curl/Postman 操作,无法实际使用。 + +2. **创作者看不到审核状态**:后端 `GET /api/roles/my/roles` 已返回 `reviewStatus`、`qrCodeUrl`、`syncedAt`、`reviewNote`,但前端 `renderCreatorRoles()` 只显示 `status`(running/stopped),创作者无法知道自己角色处于待审核/已通过/已同步/被驳回哪个状态,也看不到驳回原因和同步后的二维码。 + +3. **付款流程是前端 Mock**:`prisma/schema.prisma` 定义了 Order 模型但无任何路由,`app.js` 的 `payRole()` 仅切换 DOM 显示,无 API 调用、无订单创建。用户"付款"后看不到真实二维码,平台无法记录订阅关系。 + +--- + +## Requirements + +### 功能需求 + +- **R1**: 管理员可通过浏览器登录后台(独立登录入口,与用户登录隔离) +- **R2**: 管理员可查看待审核角色列表,支持按状态筛选 +- **R3**: 管理员可查看角色详情,通过或驳回(驳回需填写原因) +- **R4**: 管理员可对已通过角色发起 Hermes 同步,填写 profile 名、model key、服务商等参数 +- **R5**: 同步成功后管理员可看到二维码和 profileId +- **R6**: 管理员可查看同步状态列表(approved/syncing/synced/failed) +- **R7**: 管理员可配置系统参数(HERMES_WEBHOOK_URL 等) +- **R8**: 创作者角色卡片显示审核状态标签(待审核/已通过/同步中/已同步/同步失败/已驳回) +- **R9**: 被驳回角色卡片显示驳回原因 +- **R10**: 已同步角色卡片显示二维码图片和「转发二维码」按钮 +- **R11**: 后端提供 Order CRUD API(创建/查询) +- **R12**: 前端付款流程调用后端 API 创建订单,付款成功后展示真实二维码 + +### 非功能需求 + +- 管理员 token 与用户 token 隔离存储,不互相干扰 +- 管理员 UI 遵循现有视图路由模式(showView + views 注册) +- Order API 需用户认证(authMiddleware) +- 付款流程暂不对接真实支付网关,创建订单即视为已付款(status=paid),返回角色二维码 + +### 范围边界 + +**在范围内**: +- 管理员后台 UI(登录、审核列表、审核详情、同步表单、同步状态、系统配置) +- 创作者角色卡片审核状态展示 +- Order 后端 API + 前端付款流程对接 + +**不在范围内**(Deferred): +- 微信 OAuth 真实接入(hermes-server/src/routes/bind.js 仍为占位符) +- 收入/提现后端 API(仍为前端 Mock) +- 真实支付网关对接(本次创建订单即视为已付款) +- 角色删除/上下架切换 +- 定时任务调度逻辑(Hermes Agent 侧实现) + +--- + +## Key Technical Decisions + +### KTD1: 管理员 token 独立存储 + +管理员 token 存储在 `localStorage` 的 `eternal_ai_admin_token`,与用户 token(`eternal_ai_token`)隔离。管理员 state 存储在 `eternal_ai_admin_state`,与用户 state(`eternal_ai_state`)隔离。 + +**理由**: 管理员和用户可能是同一人,token 隔离避免互相覆盖。管理员 API 使用独立的 `ADMIN_JWT_SECRET`,token 不可混用。 + +### KTD2: 管理员入口通过 URL hash 访问 + +管理员后台不显示在底部 TabBar 中(普通用户不可见)。入口通过 URL hash `#admin` 直达,或在首页添加隐藏入口(如长按 logo)。 + +**理由**: 管理员后台是内部工具,不应暴露给普通用户。URL hash 方式简单直接,管理员可收藏书签。 + +### KTD3: Order 状态简化为 pending → paid + +本次 Order 状态仅 `pending`(待付款)和 `paid`(已付款)。不对接真实支付网关,创建订单时前端直接标记为 `paid`。后续对接真实支付时扩展 `failed`/`refunded` 状态。 + +**理由**: 本次目标是让付款流程有后端记录,而非实现完整支付系统。简化状态机避免过度设计。 + +### KTD4: 付款后返回角色 qrCodeUrl + +Order 创建(付款)成功后,后端返回角色的 `qrCodeUrl`(来自 Role 表的 `qrCodeUrl` 字段,由 Hermes 同步时写入)。前端展示该二维码供用户扫码绑定微信。 + +**理由**: 用户付款的目的是获取角色二维码以扫码绑定。`qrCodeUrl` 已在同步流程中存储到 Role 表,直接返回即可。若角色未同步(无 qrCodeUrl),则返回错误提示。 + +### KTD5: 创作者角色卡片状态基于 reviewStatus 优先 + +角色卡片状态标签优先显示 `reviewStatus`(pending_review/approved/rejected/syncing/synced/failed),仅当 `reviewStatus` 为 `synced` 时才显示 `status`(running/stopped)作为运行状态。 + +**理由**: `reviewStatus` 是角色的生命周期主状态,`status` 仅在同步完成后才有意义。避免两个状态标签混淆。 + +--- + +## High-Level Technical Design + +### 管理员后台视图架构 + +``` +用户访问 #admin + ↓ +检查 adminToken 是否有效(GET /api/admin-auth/me) + ↓ +有效 → 显示审核列表页 无效 → 显示管理员登录页 + ↓ ↓ +点击角色 → 审核详情页 登录成功 → 存储 adminToken → 显示审核列表页 + ↓ +通过 → 返回列表页(状态更新) +驳回 → 弹窗填写原因 → 返回列表页 +同步 → 同步表单页 → 提交 → 显示二维码 → 返回列表页 +``` + +### Order 付款流程 + +``` +用户在角色详情页点击「立即订阅」 + ↓ +POST /api/orders { roleId, amount } + ↓ +后端校验角色已同步(reviewStatus=synced, qrCodeUrl 非空) + ↓ +创建 Order 记录(status=paid) + ↓ +返回 { order, role: { qrCodeUrl } } + ↓ +前端展示二维码 +``` + +--- + +## Implementation Units + +### U1. 管理员登录页 + 认证状态管理 + +**Goal**: 实现管理员登录页面和独立的 token/state 管理机制。 + +**Requirements**: R1 + +**Dependencies**: 无(后端 API 已就绪) + +**Files**: +- `index.html` — 新增 `#admin-login` 视图和 `#admin-reviews` 视图骨架 +- `app.js` — 新增管理员 state 管理、adminApi 封装、登录逻辑、hash 路由 + +**Approach**: +1. 在 `index.html` 中新增两个视图: + - `#admin-login`:管理员登录表单(账号、密码、登录按钮) + - `#admin-reviews`:管理员后台主视图(包含审核列表、详情、同步等子面板,通过内部 Tab 切换) +2. 在 `app.js` 中新增: + - `adminState` 对象和 `loadAdminState()`/`saveAdminState()` 函数(key: `eternal_ai_admin_state`) + - `getAdminToken()`/`setAdminToken()` 函数(key: `eternal_ai_admin_token`) + - `adminApi(path, options)` 封装(自动添加管理员 Authorization 头) + - `handleHashRoute()` 函数:监听 `hashchange`,当 hash 为 `#admin` 时检查管理员登录状态并显示对应视图 + - `handleAdminLogin(formData)` 函数:调用 `POST /api/admin-auth/login`,成功后存储 token 并跳转审核列表 +3. 在 `views` 对象中注册 `'admin-login'` 和 `'admin-reviews'` +4. 在 `viewLabels` 中添加对应标签 + +**Patterns to follow**: +- 用户登录表单模式(`app.js:1189-1232` 行的 auth 表单提交) +- `api()` 封装模式(`app.js:4-42` 行),adminApi 镜照此模式但读取 adminToken +- 视图注册模式(`app.js:112-122` 行 views 对象) + +**Test scenarios**: +- **Happy path**: 管理员访问 `#admin`,输入正确账号密码,登录成功跳转审核列表页 +- **Error path**: 管理员输入错误密码,显示错误提示 +- **Edge case**: 管理员已登录时访问 `#admin`,直接跳转审核列表页(不显示登录表单) +- **Edge case**: 非管理员用户访问 `#admin`,显示登录页(用户 token 不能用于管理员 API) +- **Integration**: 登录后调用 `GET /api/admin-auth/me` 验证 token 有效 + +**Verification**: 管理员可通过 `#admin` URL 访问登录页,登录成功后进入审核列表页,刷新页面后仍保持登录状态。 + +--- + +### U2. 管理员审核列表页 + 角色详情审核页 + +**Goal**: 实现审核列表(支持状态筛选)和角色详情审核页(通过/驳回)。 + +**Requirements**: R2, R3, R6 + +**Dependencies**: U1 + +**Files**: +- `index.html` — 在 `#admin-reviews` 视图中添加审核列表和详情面板的 HTML 结构 +- `app.js` — 新增 `renderAdminReviews()`、`renderAdminReviewDetail()`、`handleApprove()`、`handleReject()` 函数 +- `e2e/admin-sync-flow.spec.js` — 扩展 E2E 测试覆盖管理员审核 UI 流程(可选,已有 API 级测试) + +**Approach**: +1. 审核列表页结构: + - 顶部状态筛选 Tab(全部/待审核/已通过/已同步/同步失败/已驳回) + - 角色卡片列表(头像、名称、创作者、审核状态标签、创建时间) + - 点击卡片进入详情页 +2. 详情审核页结构: + - 角色完整信息(头像、名称、描述、人设字段) + - 当前审核状态 + - 操作按钮区: + - `pending_review` 状态:显示「通过」和「驳回」按钮 + - `approved` 状态:显示「发起同步」按钮(跳转 U3) + - `synced` 状态:显示二维码图片和 profileId + - `rejected` 状态:显示驳回原因 + - `failed` 状态:显示「重新同步」按钮 +3. 驳回流程:点击「驳回」→ 弹窗输入原因 → 调用 `POST /api/admin/reviews/:roleId/reject` +4. 列表数据通过 `GET /api/admin/reviews?status=xxx` 获取,支持分页 + +**Patterns to follow**: +- 角色库列表渲染模式(`app.js:302-339` 行 `renderRoleLibrary()`) +- 创作者角色卡片渲染模式(`app.js:415-450` 行 `renderCreatorRoles()`) +- Tab 切换模式(`app.js:409-431` 行 creator-center 的 center-tabs) + +**Test scenarios**: +- **Happy path**: 管理员登录后看到待审核角色列表,点击角色进入详情,点击「通过」后角色状态变为 approved +- **Happy path**: 管理员点击「驳回」,输入原因,角色状态变为 rejected,列表中显示驳回原因 +- **Edge case**: 切换状态筛选 Tab,列表正确过滤 +- **Error path**: 对已审核角色再次操作,后端返回 400,前端显示错误提示 +- **Integration**: 审核通过后,角色出现在「已通过」筛选列表中 + +**Verification**: 管理员可查看各状态角色列表,可审核通过或驳回角色,操作后列表实时更新。 + +--- + +### U3. 管理员同步表单 + 二维码展示 + 系统配置 + +**Goal**: 实现同步发起表单、同步成功后二维码展示、系统配置页面。 + +**Requirements**: R4, R5, R7 + +**Dependencies**: U2 + +**Files**: +- `index.html` — 在 `#admin-reviews` 视图中添加同步表单面板和配置面板的 HTML +- `app.js` — 新增 `renderSyncForm(roleId)`、`handleSyncSubmit()`、`renderSyncResult()`、`renderAdminConfig()`、`handleConfigUpdate()` 函数 + +**Approach**: +1. 同步表单(在详情页中 `approved` 状态时显示): + - 字段:profile 名(必填)、主 model key(必填)、主服务商(必填,下拉选择 openrouter/siliconflow 等)、多媒体 model key、多媒体服务商、定时任务开关、webhook URL 覆盖(可选) + - 提交按钮:调用 `POST /api/admin/sync/:roleId` + - 提交后显示 loading 状态 +2. 同步结果展示: + - 成功:显示二维码图片(`qrCodeUrl`)、profileId、「复制二维码链接」按钮 + - 失败:显示错误信息、「重新同步」按钮 +3. 系统配置页(管理员后台第三个 Tab): + - 列出所有系统配置(`GET /api/admin/config`) + - 每项配置可编辑(`PUT /api/admin/config/:key`) + - 敏感配置(SYNC_SECRET)显示 `***` 且不可编辑 + - 重点配置:HERMES_WEBHOOK_URL、HERMES_ADMIN_TOKEN 等 + +**Patterns to follow**: +- 角色创建表单的 stepper 模式(`app.js:633-936` 行) +- 设置表单提交模式(`app.js:1233-1255` 行 settings 表单) +- Hermes 部署指南弹窗模式(`app.js:546-617` 行 `showHermesDeployGuide()`) + +**Test scenarios**: +- **Happy path**: 管理员在 approved 角色详情页填写同步参数,提交后显示二维码和 profileId +- **Error path**: 同步失败(Hermes 不可达),显示错误信息 +- **Edge case**: webhook URL 留空时使用全局配置 +- **Edge case**: 同步中状态(syncing)时按钮禁用 +- **Happy path**: 管理员在配置页修改 HERMES_WEBHOOK_URL,保存成功 +- **Error path**: 尝试修改 SYNC_SECRET,返回 403 禁止操作 + +**Verification**: 管理员可发起同步并看到二维码,可修改系统配置。 + +--- + +### U4. 创作者角色卡片审核状态展示 + +**Goal**: 修改创作者角色卡片,展示审核状态、驳回原因、二维码。 + +**Requirements**: R8, R9, R10 + +**Dependencies**: 无(后端已返回所需字段) + +**Files**: +- `app.js` — 修改 `renderCreatorRoles()` 函数(约 415-450 行) +- `styles.css` — 新增审核状态标签样式 +- `e2e/creator.spec.js` — 扩展测试覆盖审核状态展示 + +**Approach**: +1. 修改 `renderCreatorRoles()` 中的卡片渲染逻辑: + - 根据 `reviewStatus` 渲染状态标签(优先于 `status`): + - `pending_review` → 灰色标签「待审核」 + - `approved` → 蓝色标签「已通过」 + - `rejected` → 红色标签「已驳回」+ 显示 `reviewNote` + - `syncing` → 黄色标签「同步中」 + - `synced` → 绿色标签「已同步」+ 显示 `status`(运行中/已停止) + - `failed` → 橙色标签「同步失败」 + - `synced` 状态角色卡片额外显示: + - 二维码缩略图(`qrCodeUrl`) + - 「查看二维码」按钮(点击放大显示) + - 「转发二维码」按钮(复制链接到剪贴板) + - `rejected` 状态角色卡片额外显示: + - 驳回原因文本(`reviewNote`) + - 「编辑」按钮高亮提示修改 +2. 新增 `showQrCodeModal(qrCodeUrl)` 函数:弹窗显示二维码大图 +3. 新增 `copyQrCodeLink(qrCodeUrl)` 函数:复制二维码链接到剪贴板 + +**Patterns to follow**: +- 现有角色卡片状态标签模式(`role-card__status--${statusClass}`) +- Hermes 部署指南弹窗模式(`app.js:546-617` 行) +- `escapeHtml()` 用于所有用户可控内容 + +**Test scenarios**: +- **Happy path**: 创作者创建角色后,卡片显示「待审核」标签 +- **Happy path**: 管理员审核通过后,创作者卡片显示「已通过」标签 +- **Happy path**: 同步完成后,创作者卡片显示「已同步」+ 二维码缩略图 +- **Happy path**: 被驳回后,创作者卡片显示「已驳回」+ 驳回原因 +- **Edge case**: 点击「查看二维码」弹窗显示大图 +- **Edge case**: 点击「转发二维码」复制链接到剪贴板 + +**Verification**: 创作者可在角色列表看到每个角色的审核状态、驳回原因和二维码。 + +--- + +### U5. Order 订单后端 API + +**Goal**: 实现 Order 模型的 CRUD 后端端点。 + +**Requirements**: R11 + +**Dependencies**: 无(Order 模型已在 schema 中定义) + +**Files**: +- `src/routes/orders.js` — 新建订单路由文件 +- `server.js` — 挂载 `/api/orders` 路由 +- `e2e/orders.spec.js` — 新建订单 E2E 测试 + +**Approach**: +1. 新建 `src/routes/orders.js`,实现以下端点: + - `POST /api/orders` — 创建订单(付款) + - 认证:`authMiddleware` + - 请求体:`{ roleId, amount }` + - 逻辑: + - 校验 roleId 存在且角色 `reviewStatus='synced'` 且 `qrCodeUrl` 非空 + - 校验 amount > 0 + - 创建 Order 记录(status='paid',userId=当前用户) + - 返回 `{ order: { id, roleId, amount, status, createdAt }, role: { qrCodeUrl, displayName } }` + - `GET /api/orders` — 查询当前用户订单列表 + - 认证:`authMiddleware` + - 返回:`{ orders: [{ id, roleId, role: { displayName, avatar }, amount, status, createdAt }] }` + - `GET /api/orders/:id` — 查询订单详情 + - 认证:`authMiddleware` + - 校验:订单属于当前用户 + - 返回:`{ order: { ...完整字段, role: { ...角色信息 } } }` +2. 在 `server.js` 中挂载路由:`app.use('/api/orders', require('./src/routes/orders'));` +3. 错误处理: + - 角色未同步:返回 400 `{ error: '该角色尚未同步,无法订阅' }` + - 角色不存在:返回 404 + - 订单不属于当前用户:返回 403 + +**Patterns to follow**: +- 现有路由模式(`src/routes/roles.js` 的 `authMiddleware` 使用) +- 现有错误处理模式(`res.status(400).json({ error: '...' })`) +- Prisma 查询模式(`prisma.order.findMany({ where: { userId }, include: { role: true } })`) + +**Test scenarios**: +- **Happy path**: 用户对已同步角色发起付款,创建订单成功,返回 qrCodeUrl +- **Error path**: 用户对未同步角色发起付款,返回 400 +- **Error path**: 未认证用户发起付款,返回 401 +- **Edge case**: 用户查询自己的订单列表,只返回自己的订单 +- **Edge case**: 用户查询他人订单,返回 403 +- **Integration**: 创建订单后,订单列表中包含该订单 + +**Verification**: Order API 可创建、查询订单,角色未同步时拒绝创建。 + +--- + +### U6. 前端付款流程对接 + +**Goal**: 修改前端 `payRole()` 函数,调用后端 API 创建订单并展示真实二维码。 + +**Requirements**: R12 + +**Dependencies**: U5 + +**Files**: +- `app.js` — 修改 `payRole()` 函数(约 361-367 行),新增 `renderPaidState()` 函数 +- `index.html` — 修改 `#role-detail` 视图中的付款后区域结构 +- `e2e/roles.spec.js` — 扩展测试覆盖付款流程 + +**Approach**: +1. 修改 `payRole()` 函数: + - 调用 `POST /api/orders` 传入 `{ roleId: currentRole.id, amount: currentRole.price }` + - 成功后调用 `renderPaidState(data.role.qrCodeUrl)` 展示二维码 + - 失败时 `alert` 错误信息 + - 添加 loading 状态(按钮禁用 + 文字变为「处理中…」) +2. 新增 `renderPaidState(qrCodeUrl)` 函数: + - 隐藏「立即订阅」按钮 + - 显示「已订阅」区域 + - 渲染二维码图片(`角色二维码`) + - 添加「保存二维码」按钮(长按或下载) +3. 修改 `#role-detail` 视图 HTML: + - `#detail-qr` 区域改为可容纳 `` 的容器 + - 移除 `qr-placeholder` 占位符 + +**Patterns to follow**: +- 现有 `api()` 调用模式(`app.js:4-42` 行) +- 现有按钮 loading 模式(禁用 + 文字变化) +- `escapeHtml()` 用于所有动态内容 + +**Test scenarios**: +- **Happy path**: 用户点击「立即订阅」,调用 API 成功,显示真实二维码 +- **Error path**: 角色未同步,API 返回 400,显示错误提示 +- **Error path**: 网络错误,显示「无法连接服务器」 +- **Edge case**: 重复点击「立即订阅」按钮,第二次点击被禁用(loading 状态) +- **Integration**: 付款后刷新页面,角色详情页仍显示已订阅状态(基于订单记录) + +**Verification**: 用户付款后看到真实二维码,非占位符。 + +--- + +## Risks & Dependencies + +### 风险 + +1. **管理员 UI 复杂度**: 管理员后台涉及多个面板(审核列表、详情、同步表单、配置),HTML 和 JS 代码量较大。**缓解**: 分三个实现单元(U1/U2/U3)逐步实现,每个单元可独立验证。 + +2. **Order 付款流程简化**: 本次创建订单即视为已付款,不对接真实支付。后续对接支付网关时需重构。**缓解**: Order 模型已预留 `status` 字段,后续扩展状态机即可。 + +3. **创作者卡片状态展示复杂性**: `reviewStatus` 和 `status` 两个状态字段的展示逻辑需仔细设计,避免混淆。**缓解**: KTD5 明确了优先级规则——`reviewStatus` 优先,仅 `synced` 时显示 `status`。 + +### 依赖 + +- 后端管理员 API 已全部就绪(U1-U3 依赖) +- 后端 `GET /api/roles/my/roles` 已返回所需字段(U4 依赖) +- Order 模型已在 Prisma schema 中定义(U5 依赖) +- 前端 `payRole()` 函数已存在(U6 修改对象) + +--- + +## Deferred to Follow-Up Work + +- 微信 OAuth 真实接入(hermes-server/src/routes/bind.js 的 `wechat_oauth_placeholder`) +- 收入/提现后端 API(当前仍为前端 Mock 数据) +- 真实支付网关对接(当前创建订单即视为已付款) +- 角色删除/上下架切换 +- 定时任务调度逻辑(enableSchedule 标志已传递,但 Hermes Server 无实际调度) +- 管理员后台移动端适配优化(本次优先功能完整性) + +--- + +## System-Wide Impact + +- **用户**: 普通用户在角色详情页付款后能看到真实二维码(U6) +- **创作者**: 创作者可在角色列表看到审核状态和二维码(U4) +- **管理员**: 管理员可通过浏览器完成审核和同步操作(U1-U3) +- **数据库**: Order 表开始有数据写入(U5) +- **API**: 新增 `/api/orders` 路由(U5) + +--- + +## Acceptance Examples + +- **AE1**: 管理员访问 `#admin`,登录后看到待审核角色列表,点击「通过」→ 角色状态变为 approved → 点击「发起同步」→ 填写参数 → 显示二维码 +- **AE2**: 创作者创建角色后,在角色列表看到「待审核」标签 → 管理员驳回后,看到「已驳回」+ 驳回原因 → 修改后重新提交,看到「待审核」 +- **AE3**: 用户在角色详情页点击「立即订阅」→ 调用 API 创建订单 → 显示真实二维码 → 用户扫码绑定 diff --git a/e2e/roles.spec.js b/e2e/roles.spec.js index 1cfc028..695bc7a 100644 --- a/e2e/roles.spec.js +++ b/e2e/roles.spec.js @@ -47,6 +47,7 @@ test.describe('角色库与角色详情', () => { status: 'running', reviewStatus: 'synced', avatar: 'https://example.com/avatar.png', + qrCodeUrl: 'https://example.com/qr.png', }, }); }); diff --git a/index.html b/index.html index efe8198..92adb6a 100644 --- a/index.html +++ b/index.html @@ -587,6 +587,73 @@ + + +
+
+ +
+ 管理员登录 +
+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ 管理员后台 +
+ +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + +
+ + +
+
+ +
+ 角色审核 +
+
+
+
diff --git a/server.js b/server.js index 63fd456..b8dae35 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ app.use(express.json()); app.use('/api/auth', require('./src/routes/auth')); app.use('/api/roles', require('./src/routes/roles')); app.use('/api/apikeys', require('./src/routes/apikeys')); +app.use('/api/orders', require('./src/routes/orders')); app.use('/api/hermes', require('./src/routes/hermes')); app.use('/api/admin-auth', require('./src/routes/admin-auth')); app.use('/api/admin', require('./src/routes/admin')); diff --git a/src/routes/orders.js b/src/routes/orders.js new file mode 100644 index 0000000..f8d1e44 --- /dev/null +++ b/src/routes/orders.js @@ -0,0 +1,137 @@ +const express = require('express'); +const prisma = require('../lib/prisma'); +const { authMiddleware } = require('../lib/auth'); + +const router = express.Router(); + +// 创建订单(付款) +router.post('/', authMiddleware, async (req, res) => { + try { + const { roleId, amount } = req.body; + + // 校验 roleId 和 amount 必填,且 amount > 0 + if (!roleId || amount === undefined || amount === null) { + return res.status(400).json({ error: 'roleId 和 amount 为必填字段' }); + } + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum <= 0) { + return res.status(400).json({ error: 'amount 必须为大于 0 的数字' }); + } + + // 查询角色是否存在 + const role = await prisma.role.findUnique({ + where: { id: roleId }, + select: { + id: true, + displayName: true, + qrCodeUrl: true, + reviewStatus: true, + }, + }); + + if (!role) { + return res.status(404).json({ error: '角色不存在' }); + } + + // 角色必须已同步且二维码非空 + if (role.reviewStatus !== 'synced' || !role.qrCodeUrl) { + return res.status(400).json({ error: '该角色尚未同步,无法订阅' }); + } + + // 创建订单(默认 status='paid') + const order = await prisma.order.create({ + data: { + userId: req.userId, + roleId, + amount: amountNum, + status: 'paid', + }, + select: { + id: true, + roleId: true, + amount: true, + status: true, + createdAt: true, + }, + }); + + res.json({ + order, + role: { + qrCodeUrl: role.qrCodeUrl, + displayName: role.displayName, + }, + }); + } catch (err) { + console.error('创建订单失败:', err); + res.status(500).json({ error: '服务器错误' }); + } +}); + +// 查询当前用户订单列表 +router.get('/', authMiddleware, async (req, res) => { + try { + const orders = await prisma.order.findMany({ + where: { userId: req.userId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + roleId: true, + amount: true, + status: true, + createdAt: true, + role: { + select: { + displayName: true, + avatar: true, + }, + }, + }, + }); + res.json({ orders }); + } catch (err) { + console.error('获取订单列表失败:', err); + res.status(500).json({ error: '服务器错误' }); + } +}); + +// 查询订单详情 +router.get('/:id', authMiddleware, async (req, res) => { + try { + const order = await prisma.order.findUnique({ + where: { id: req.params.id }, + select: { + id: true, + userId: true, + roleId: true, + amount: true, + status: true, + createdAt: true, + role: { + select: { + id: true, + displayName: true, + avatar: true, + desc: true, + }, + }, + }, + }); + + if (!order) { + return res.status(404).json({ error: '订单不存在' }); + } + + // 校验订单归属 + if (order.userId !== req.userId) { + return res.status(403).json({ error: '无权查看此订单' }); + } + + res.json({ order }); + } catch (err) { + console.error('获取订单详情失败:', err); + res.status(500).json({ error: '服务器错误' }); + } +}); + +module.exports = router; diff --git a/styles.css b/styles.css index 06b4a69..1202453 100644 --- a/styles.css +++ b/styles.css @@ -1414,3 +1414,20 @@ textarea.field__input--tall { scroll-behavior: auto !important; } } + +/* === 管理员后台样式 === */ +.admin-panel { min-height: 60vh; } + +/* 审核状态标签颜色 */ +.role-card__status--pending { background: #f3f4f6; color: #6b7280; } +.role-card__status--approved { background: #dbeafe; color: #1d4ed8; } +.role-card__status--rejected { background: #fee2e2; color: #dc2626; } +.role-card__status--syncing { background: #fef3c7; color: #d97706; } +.role-card__status--synced { background: #d1fae5; color: #059669; } +.role-card__status--failed { background: #ffedd5; color: #ea580c; } + +/* 管理员筛选按钮 active 状态 */ +.admin-filter.active { background: var(--primary, #667eea); color: #fff; border-color: var(--primary, #667eea); } + +/* 管理员审核卡片 */ +.admin-review-card { cursor: pointer; }