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
This commit is contained in:
parent
c470ef8f3a
commit
cc0ced9858
537
app.js
537
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 = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||
try {
|
||||
const query = adminCurrentFilter ? `?status=${adminCurrentFilter}` : '';
|
||||
const { roles } = await adminApi(`/admin/reviews${query}`);
|
||||
if (roles.length === 0) {
|
||||
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">暂无角色</p>';
|
||||
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 `
|
||||
<article class="role-card admin-review-card" data-role-id="${id}" role="button" tabindex="0">
|
||||
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
|
||||
<div class="role-card__info">
|
||||
<h3 class="role-card__name">${name}</h3>
|
||||
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);display:block;margin-top:0.25rem;">${created}</span>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||
try {
|
||||
const { role } = await adminApi(`/admin/reviews/${roleId}`);
|
||||
const status = role.reviewStatus;
|
||||
let actionsHtml = '';
|
||||
if (status === 'pending_review') {
|
||||
actionsHtml = `
|
||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
||||
<button class="btn btn--primary" type="button" data-action="admin-approve" data-role-id="${escapeHtml(roleId)}">通过审核</button>
|
||||
<button class="btn btn--outline" type="button" data-action="admin-reject" data-role-id="${escapeHtml(roleId)}">驳回</button>
|
||||
</div>`;
|
||||
} else if (status === 'approved') {
|
||||
actionsHtml = `<h3 style="margin-top:1.5rem;">发起 Hermes 同步</h3>
|
||||
<form id="admin-sync-form" style="margin-top:1rem;">
|
||||
<div class="field"><label class="field__label">Profile 名称</label><input class="field__input" name="profileName" type="text" placeholder="如 star-spirit" required /></div>
|
||||
<div class="field"><label class="field__label">主 Model Key</label><input class="field__input" name="modelKey" type="text" placeholder="sk-xxx" required /></div>
|
||||
<div class="field"><label class="field__label">主服务商</label><select class="field__input" name="provider"><option value="openrouter">OpenRouter</option><option value="siliconflow">SiliconFlow</option></select></div>
|
||||
<div class="field"><label class="field__label">多媒体 Model Key</label><input class="field__input" name="multimediaModelKey" type="text" placeholder="sk-xxx" /></div>
|
||||
<div class="field"><label class="field__label">多媒体服务商</label><select class="field__input" name="multimediaProvider"><option value="openrouter">OpenRouter</option><option value="siliconflow">SiliconFlow</option></select></div>
|
||||
<div class="field"><label class="field__label"><input type="checkbox" name="enableSchedule" /> 启用定时任务</label></div>
|
||||
<div class="field"><label class="field__label">Webhook URL(可选,留空用全局配置)</label><input class="field__input" name="webhookUrl" type="url" placeholder="https://hermes.example.com/api/sync" /></div>
|
||||
<button class="btn btn--primary" type="submit" style="width:100%;">发起同步</button>
|
||||
</form>`;
|
||||
} else if (status === 'synced') {
|
||||
actionsHtml = `
|
||||
<div style="margin-top:1.5rem;text-align:center;">
|
||||
<h3>同步成功</h3>
|
||||
${role.qrCodeUrl ? `<img src="${escapeHtml(role.qrCodeUrl)}" alt="二维码" style="max-width:200px;margin:1rem auto;display:block;" />` : ''}
|
||||
<p>Profile ID: ${escapeHtml(role.id)}</p>
|
||||
<p>同步时间: ${role.syncedAt ? new Date(role.syncedAt).toLocaleString('zh-CN') : '未知'}</p>
|
||||
</div>`;
|
||||
} else if (status === 'rejected') {
|
||||
actionsHtml = `<div style="margin-top:1.5rem;padding:1rem;background:#fef2f2;border-radius:8px;"><strong>驳回原因:</strong>${escapeHtml(role.reviewNote || '未提供')}</div>`;
|
||||
} else if (status === 'failed') {
|
||||
actionsHtml = `<div style="margin-top:1.5rem;padding:1rem;background:#fff7ed;border-radius:8px;"><strong>同步失败</strong><p>角色状态已标记为失败,可重新发起同步。</p></div>`;
|
||||
}
|
||||
|
||||
contentEl.innerHTML = `
|
||||
<div style="display:flex;gap:1rem;align-items:center;margin-bottom:1.5rem;">
|
||||
<img src="${escapeHtml(role.avatar || '')}" alt="${escapeHtml(role.displayName)}" style="width:64px;height:64px;border-radius:50%;object-fit:cover;" />
|
||||
<div>
|
||||
<h2 style="margin:0;">${escapeHtml(role.displayName)}</h2>
|
||||
<span class="role-card__status role-card__status--${getReviewStatusClass(status)}">${getReviewStatusText(status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:1rem;">
|
||||
<p><strong>描述:</strong>${escapeHtml(role.desc || role.personality || '无')}</p>
|
||||
<p><strong>性格:</strong>${escapeHtml(role.personality || '无')}</p>
|
||||
<p><strong>背景:</strong>${escapeHtml(role.background || '无')}</p>
|
||||
<p><strong>问候语:</strong>${escapeHtml(role.greeting || '无')}</p>
|
||||
<p><strong>价格:</strong>¥${escapeHtml(String(role.price || 0))}</p>
|
||||
</div>
|
||||
${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 = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 管理员同步状态列表 ---
|
||||
async function renderAdminSyncStatus() {
|
||||
const listEl = document.getElementById('admin-sync-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||
try {
|
||||
const { roles } = await adminApi('/admin/sync-status');
|
||||
if (roles.length === 0) {
|
||||
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">暂无同步记录</p>';
|
||||
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 `
|
||||
<article class="role-card" data-role-id="${id}" role="button" tabindex="0">
|
||||
<div class="role-card__info">
|
||||
<h3 class="role-card__name">${name}</h3>
|
||||
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);display:block;margin-top:0.25rem;">同步时间:${syncedAt}</span>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 管理员系统配置 ---
|
||||
async function renderAdminConfig() {
|
||||
const listEl = document.getElementById('admin-config-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||
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 `
|
||||
<div class="field" style="margin-bottom:1rem;">
|
||||
<label class="field__label">${key}</label>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<input class="field__input" type="text" value="${value}" data-config-key="${key}" ${isProtected ? 'disabled' : ''} />
|
||||
${isProtected ? '' : `<button class="btn btn--small btn--primary" type="button" data-action="admin-save-config" data-config-key="${key}">保存</button>`}
|
||||
</div>
|
||||
${updated ? `<span style="font-size:0.75rem;color:var(--text-muted);">更新于 ${updated}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 管理员 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() {
|
||||
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;
|
||||
document.getElementById('detail-qr').innerHTML = '<div class="qr-placeholder">扫码连接<br/>AI 角色</div>';
|
||||
const qrEl = document.getElementById('detail-qr');
|
||||
if (result.role && result.role.qrCodeUrl) {
|
||||
qrEl.innerHTML = `<img src="${escapeHtml(result.role.qrCodeUrl)}" alt="角色二维码" style="max-width:200px;width:100%;border-radius:8px;" />`;
|
||||
} else {
|
||||
qrEl.innerHTML = '<div class="qr-placeholder">扫码连接<br/>AI 角色</div>';
|
||||
}
|
||||
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 = '<p style="text-align:center;color:var(--text-muted)">还没有创建角色,点击「新建角色」开始</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = roles
|
||||
.map((role) => {
|
||||
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);
|
||||
const reviewStatus = role.reviewStatus || 'pending_review';
|
||||
const statusText = getReviewStatusText(reviewStatus);
|
||||
const statusClass = getReviewStatusClass(reviewStatus);
|
||||
|
||||
// 审核状态标签
|
||||
let statusBadge = `<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>`;
|
||||
|
||||
// 已同步角色额外显示运行状态
|
||||
if (reviewStatus === 'synced') {
|
||||
const runStatus = role.status === 'running' ? '运行中' : '已停止';
|
||||
statusBadge += ` <span class="role-card__status role-card__status--${escapeHtml(role.status)}" style="margin-left:0.25rem;">${runStatus}</span>`;
|
||||
}
|
||||
|
||||
// 驳回原因
|
||||
let rejectNote = '';
|
||||
if (reviewStatus === 'rejected' && role.reviewNote) {
|
||||
rejectNote = `<p style="font-size:0.75rem;color:#dc2626;margin-top:0.25rem;">驳回原因:${escapeHtml(role.reviewNote)}</p>`;
|
||||
}
|
||||
|
||||
// 二维码展示(已同步角色)
|
||||
let qrCodeHtml = '';
|
||||
if (reviewStatus === 'synced' && role.qrCodeUrl) {
|
||||
qrCodeHtml = `
|
||||
<div style="margin-top:0.5rem;">
|
||||
<img src="${escapeHtml(role.qrCodeUrl)}" alt="二维码" style="width:48px;height:48px;cursor:pointer;" data-action="show-qrcode" data-qr-url="${escapeHtml(role.qrCodeUrl)}" />
|
||||
<button class="btn btn--small btn--outline" type="button" data-action="copy-qrcode" data-qr-url="${escapeHtml(role.qrCodeUrl)}" style="margin-left:0.5rem;">复制链接</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
let actionButtons = '';
|
||||
if (reviewStatus === 'rejected' || reviewStatus === 'pending_review') {
|
||||
actionButtons = `<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>`;
|
||||
} else if (reviewStatus === 'synced') {
|
||||
actionButtons = `<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button> <button class="btn btn--small btn--outline" type="button" data-action="hermes-deploy" data-role-id="${id}" data-role-name="${name}">Hermes 部署</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name},${statusText}">
|
||||
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
|
||||
<div class="role-card__info">
|
||||
<h3 class="role-card__name">${name}</h3>
|
||||
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>
|
||||
<button class="btn btn--small btn--outline" type="button" data-action="hermes-deploy" data-role-id="${id}" data-role-name="${name}">Hermes 部署</button>
|
||||
<div>${statusBadge}</div>
|
||||
${rejectNote}
|
||||
${qrCodeHtml}
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">${actionButtons}</div>
|
||||
</article>`;
|
||||
})
|
||||
.join('');
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<div style="background:#fff;border-radius:16px;padding:2rem;text-align:center;max-width:90vw;">
|
||||
<h3 style="margin-bottom:1rem;">角色二维码</h3>
|
||||
<img src="${escapeHtml(qrUrl)}" alt="二维码" style="max-width:300px;width:100%;" />
|
||||
<div style="margin-top:1rem;">
|
||||
<button class="btn btn--outline" type="button" data-action="close-qr-modal">关闭</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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)` 函数:
|
||||
- 隐藏「立即订阅」按钮
|
||||
- 显示「已订阅」区域
|
||||
- 渲染二维码图片(`<img src="${qrCodeUrl}" alt="角色二维码" />`)
|
||||
- 添加「保存二维码」按钮(长按或下载)
|
||||
3. 修改 `#role-detail` 视图 HTML:
|
||||
- `#detail-qr` 区域改为可容纳 `<img>` 的容器
|
||||
- 移除 `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 创建订单 → 显示真实二维码 → 用户扫码绑定
|
||||
|
|
@ -47,6 +47,7 @@ test.describe('角色库与角色详情', () => {
|
|||
status: 'running',
|
||||
reviewStatus: 'synced',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
qrCodeUrl: 'https://example.com/qr.png',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
index.html
67
index.html
|
|
@ -587,6 +587,73 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Login View -->
|
||||
<section id="admin-login" class="view view--admin-login">
|
||||
<header class="creator-header">
|
||||
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||
<div class="creator-brand">
|
||||
<span class="creator-brand__name">管理员登录</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="auth-page" style="padding:2rem 1.5rem;">
|
||||
<form id="admin-login-form" class="auth-form active" data-form="admin-login">
|
||||
<div class="field">
|
||||
<label class="field__label" for="admin-account">管理员账号</label>
|
||||
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="管理员账号" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="admin-password">密码</label>
|
||||
<input class="field__input" name="password" type="password" autocomplete="current-password" placeholder="密码" required />
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit" style="width:100%;">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Reviews View -->
|
||||
<section id="admin-reviews" class="view view--admin-reviews">
|
||||
<header class="creator-header">
|
||||
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||
<div class="creator-brand">
|
||||
<span class="creator-brand__name">管理员后台</span>
|
||||
</div>
|
||||
<button class="btn btn--small btn--ghost" type="button" data-action="admin-logout">退出</button>
|
||||
</header>
|
||||
<div class="center-tabs" style="padding:0.5rem 1rem;border-bottom:1px solid var(--border);">
|
||||
<button class="center-tab active" type="button" data-center-tab="admin-reviews">审核列表</button>
|
||||
<button class="center-tab" type="button" data-center-tab="admin-sync">同步状态</button>
|
||||
<button class="center-tab" type="button" data-center-tab="admin-config">系统配置</button>
|
||||
</div>
|
||||
<div class="admin-panel" id="admin-reviews-panel">
|
||||
<div style="padding:0.5rem 1rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||
<button class="btn btn--small btn--outline admin-filter active" data-filter="pending_review">待审核</button>
|
||||
<button class="btn btn--small btn--outline admin-filter" data-filter="approved">已通过</button>
|
||||
<button class="btn btn--small btn--outline admin-filter" data-filter="synced">已同步</button>
|
||||
<button class="btn btn--small btn--outline admin-filter" data-filter="failed">同步失败</button>
|
||||
<button class="btn btn--small btn--outline admin-filter" data-filter="rejected">已驳回</button>
|
||||
<button class="btn btn--small btn--outline admin-filter" data-filter="">全部</button>
|
||||
</div>
|
||||
<div id="admin-review-list" style="padding:1rem;"></div>
|
||||
</div>
|
||||
<div class="admin-panel" id="admin-sync-panel" hidden>
|
||||
<div id="admin-sync-list" style="padding:1rem;"></div>
|
||||
</div>
|
||||
<div class="admin-panel" id="admin-config-panel" hidden>
|
||||
<div id="admin-config-list" style="padding:1rem;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Review Detail View -->
|
||||
<section id="admin-review-detail" class="view view--admin-review-detail">
|
||||
<header class="creator-header">
|
||||
<button class="icon-btn" type="button" data-action="back-to-admin" aria-label="返回">←</button>
|
||||
<div class="creator-brand">
|
||||
<span class="creator-brand__name">角色审核</span>
|
||||
</div>
|
||||
</header>
|
||||
<div id="admin-detail-content" style="padding:1rem;"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Tab Bar (U8) -->
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
17
styles.css
17
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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue