EternalAI/app.js

1806 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'use strict';
// --- API helper ---
const API_BASE = '/api';
const TOKEN_KEY = 'eternal_ai_token';
// 安全:转义 HTML防止存储型 XSS用于 innerHTML 插入的用户可控数据)
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (ch) => {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return map[ch];
});
}
function getToken() {
return localStorage.getItem(TOKEN_KEY) || '';
}
function setToken(token) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function api(path, options = {}) {
const token = getToken();
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;
}
}
// --- U9: Unified state management ---
const STORAGE_KEY = 'eternal_ai_state';
const defaultState = {
isLoggedIn: false,
isCreator: false,
account: null,
userId: null,
boundCreator: null,
libraryName: '我的 [XXX]',
creatorName: '',
roles: [],
income: { balance: 0, records: [] },
};
function loadState() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return { ...defaultState, ...saved };
} catch {
return { ...defaultState };
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
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 = [
{
id: 'role_001',
name: '云朵',
avatar: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20girl%20soft%20pastel%20portrait%20gentle%20smile&image_size=square',
desc: '温柔如云的女孩,总是轻声细语地陪伴你。',
price: 29.9,
status: 'running',
},
{
id: 'role_002',
name: '星河',
avatar: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20boy%20starry%20eyes%20cool%20portrait&image_size=square',
desc: '嘴硬心软的少年,嘴上不饶人却总在关键时刻出现。',
price: 39.9,
status: 'running',
},
{
id: 'role_003',
name: '月见',
avatar: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20girl%20moonlight%20mysterious%20elegant%20portrait&image_size=square',
desc: '神秘而优雅,像月光一样忽远忽近的存在。',
price: 19.9,
status: 'stopped',
},
];
const mockIncome = {
balance: 1280.50,
records: [
{ time: '2026-06-18 14:30', amount: 23.92, role: '云朵' },
{ time: '2026-06-15 09:12', amount: 31.92, role: '星河' },
{ time: '2026-06-10 20:05', amount: 15.92, role: '月见' },
],
};
// --- DOM references ---
const views = {
landing: document.getElementById('landing'),
auth: document.getElementById('auth'),
'role-library': document.getElementById('role-library'),
'role-detail': document.getElementById('role-detail'),
distill: document.getElementById('distill'),
about: document.getElementById('about'),
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');
const resultPanel = document.getElementById('result-panel');
const previewCode = document.querySelector('#preview-code code');
const systemPromptInput = document.getElementById('system-prompt');
const steps = Array.from(document.querySelectorAll('.form-step'));
const dots = Array.from(document.querySelectorAll('#creator .stepper__dot'));
let currentStep = 0;
let generatedSoul = '';
let generatedConfig = '';
let activePreview = 'soul';
let activeAuthTab = 'login';
let activeCenterTab = 'roles';
let currentRole = null;
let editingRoleId = null;
let viewHistory = ['landing'];
// --- U9: Unified showView with history and tab-bar sync ---
// Human-readable labels for screen-reader announcements
const viewLabels = {
landing: '首页',
auth: '登录 / 注册',
'role-library': '角色库',
'role-detail': '角色详情',
distill: '蒸馏前任',
about: '关于 Eternal AI',
onboarding: '创作者入驻',
'creator-center': '创作者管理中心',
creator: '角色编辑',
'admin-login': '管理员登录',
'admin-reviews': '管理员后台',
'admin-review-detail': '角色审核详情',
};
function showView(name, trackHistory = true) {
Object.entries(views).forEach(([key, el]) => {
if (el) el.classList.toggle('active', key === name);
});
if (trackHistory && viewHistory[viewHistory.length - 1] !== name) {
viewHistory.push(name);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
updateTabBar(name);
// a11y: move focus to the new view so screen readers announce it
const target = views[name];
if (target) {
target.setAttribute('tabindex', '-1');
// Defer focus to after the scroll/layout settles
setTimeout(() => target.focus({ preventScroll: true }), 50);
}
// a11y: announce the view change to screen readers via live region
const announcer = document.getElementById('sr-announce');
if (announcer) {
announcer.textContent = viewLabels[name] ? `已进入${viewLabels[name]}` : '';
}
}
function goBack() {
if (viewHistory.length > 1) {
viewHistory.pop();
const prev = viewHistory[viewHistory.length - 1];
showView(prev, false);
} else {
showView('landing', false);
}
}
// --- U8: Tab bar ---
function updateTabBar(viewName) {
const tabMap = {
landing: 'tab-home',
distill: 'tab-distill',
'role-library': 'tab-mine',
'creator-center': 'tab-mine',
};
const activeTab = tabMap[viewName] || 'tab-home';
document.querySelectorAll('.tab-bar__item').forEach((item) => {
const isActive = item.dataset.tabAction === activeTab;
item.classList.toggle('active', isActive);
item.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
function handleTabAction(action) {
if (action === 'tab-home') {
showView('landing');
} else if (action === 'tab-distill') {
showView('distill');
} else if (action === 'tab-mine') {
if (!state.isLoggedIn) {
switchAuthTab('login');
showView('auth');
} else if (state.isCreator) {
showView('creator-center');
renderCreatorCenter();
} else {
renderRoleLibrary();
showView('role-library');
}
}
}
// --- U1: Landing card state ---
function updateLandingCard() {
const nameEl = document.getElementById('library-name');
const descEl = document.getElementById('characters-desc');
const btnEl = document.getElementById('characters-btn');
const tabMineLabel = document.getElementById('tab-mine-label');
if (state.isLoggedIn) {
nameEl.textContent = state.libraryName || '我的角色库';
if (state.isCreator) {
descEl.textContent = '管理你的角色和收入';
btnEl.textContent = '进入管理中心';
tabMineLabel.textContent = '管理';
} else {
descEl.textContent = state.boundCreator ? '查看你的专属角色' : '寻找你的专属创作者';
btnEl.textContent = '进入角色库';
tabMineLabel.textContent = '我的';
}
} else {
nameEl.textContent = '我的 [XXX]';
descEl.textContent = '登录后管理你的角色';
btnEl.textContent = '登录 / 注册';
tabMineLabel.textContent = '我的';
}
}
// --- Auth ---
function switchAuthTab(tab) {
activeAuthTab = tab;
document.querySelectorAll('.auth-tab').forEach((t) => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.auth-form').forEach((f) => {
f.classList.toggle('active', f.dataset.form === tab);
});
}
function validatePasswordMatch(formEl) {
const pwd = formEl.querySelector('[name="password"]');
const confirm = formEl.querySelector('[name="confirmPassword"]');
if (!pwd || !confirm) return true;
if (pwd.value !== confirm.value) {
confirm.setCustomValidity('两次输入的密码不一致');
confirm.reportValidity();
return false;
}
confirm.setCustomValidity('');
return true;
}
function applyUserData(user) {
state.isLoggedIn = true;
state.userId = user.id;
state.account = user.account;
state.isCreator = user.isCreator || false;
state.creatorName = user.creatorName || '';
state.libraryName = user.libraryName || '我的角色库';
saveState();
updateLandingCard();
}
function logout() {
state.isLoggedIn = false;
state.isCreator = false;
state.account = null;
state.userId = null;
state.boundCreator = null;
state.roles = [];
state.income = { balance: 0, records: [] };
setToken('');
saveState();
updateLandingCard();
showView('landing');
}
// --- U2: Role Library ---
async function renderRoleLibrary() {
const listEl = document.getElementById('role-list');
const emptyEl = document.getElementById('library-empty');
const titleEl = document.getElementById('library-title');
titleEl.textContent = state.libraryName || '我的角色库';
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
emptyEl.hidden = true;
try {
const { roles } = await api('/roles');
if (roles.length === 0) {
listEl.innerHTML = '';
emptyEl.hidden = false;
return;
}
listEl.innerHTML = roles
.map((role) => {
const id = escapeHtml(role.id);
const name = escapeHtml(role.displayName);
const desc = escapeHtml(role.desc || '');
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 price = escapeHtml(role.price);
return `
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name}${desc},每月${price}元">
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
<div class="role-card__info">
<h3 class="role-card__name">${name}</h3>
<p class="role-card__desc">${desc}</p>
<span class="role-card__price">¥${price}</span>
</div>
</article>`;
})
.join('');
} catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
}
}
// --- U3: Role Detail ---
async function renderRoleDetail(roleId) {
try {
const { role } = await api(`/roles/${roleId}`);
currentRole = role;
document.getElementById('detail-name').textContent = role.displayName;
document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar || ''})`;
document.getElementById('detail-role-name').textContent = role.displayName;
document.getElementById('detail-role-desc').textContent = role.desc || role.personality || '';
document.getElementById('detail-price').innerHTML = `<span class="detail-price__value">¥${escapeHtml(role.price)}</span><span class="detail-price__unit">/ 月</span>`;
document.getElementById('detail-actions-pre').hidden = false;
document.getElementById('detail-paid').hidden = true;
} catch (err) {
alert('加载角色详情失败:' + err.message);
goBack();
}
}
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 = `<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 ---
function toggleFaq(button) {
const item = button.closest('.faq-item');
const answer = item.querySelector('.faq-a');
const icon = button.querySelector('.faq-icon');
const isOpen = answer.style.display === 'block';
answer.style.display = isOpen ? 'none' : 'block';
icon.textContent = isOpen ? '+' : '';
// a11y: sync aria-expanded so screen readers know the toggle state
button.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
}
// a11y: wire up FAQ buttons with aria-expanded / aria-controls on load
function initFaqA11y() {
const faqButtons = document.querySelectorAll('.faq-q');
faqButtons.forEach((btn, index) => {
const item = btn.closest('.faq-item');
const answer = item.querySelector('.faq-a');
const answerId = `faq-a-${index + 1}`;
if (answer) {
answer.id = answerId;
answer.setAttribute('role', 'region');
answer.setAttribute('aria-labelledby', `faq-q-${index + 1}`);
}
btn.id = `faq-q-${index + 1}`;
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', answerId);
});
}
// a11y: label each view section for screen-reader navigation
function initViewA11y() {
Object.entries(views).forEach(([key, el]) => {
if (el && viewLabels[key]) {
el.setAttribute('aria-label', viewLabels[key]);
}
});
}
// --- U7: Creator Center ---
function renderCreatorCenter() {
renderCreatorRoles();
renderIncome();
renderSettings();
}
async function renderCreatorRoles() {
const listEl = document.getElementById('creator-role-list');
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
try {
const { roles } = await api('/roles/my/roles');
state.roles = roles;
saveState();
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 || '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 = `<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>
<div>${statusBadge}</div>
${rejectNote}
${qrCodeHtml}
</div>
<div style="display:flex;gap:0.5rem;">${actionButtons}</div>
</article>`;
}).join('');
} catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
}
}
function renderIncome() {
const income = state.income.balance > 0 ? state.income : mockIncome;
document.getElementById('income-balance').textContent = `¥ ${income.balance.toFixed(2)}`;
const listEl = document.getElementById('income-list');
if (income.records.length === 0) {
listEl.innerHTML = '<p class="income-empty">暂无流水记录</p>';
return;
}
listEl.innerHTML = income.records
.map((r) => {
const role = escapeHtml(r.role);
const time = escapeHtml(r.time);
const amount = r.amount.toFixed(2);
return `
<div class="income-record">
<div class="income-record__info">
<span class="income-record__role">${role}</span>
<span class="income-record__time">${time}</span>
</div>
<span class="income-record__amount">+¥${amount}</span>
</div>`;
})
.join('');
}
function renderSettings() {
document.getElementById('settings-name').value = state.creatorName || '';
document.getElementById('settings-library').value = state.libraryName === '我的 [XXX]' ? '' : state.libraryName;
renderApiKeys();
}
// --- API Key 管理 ---
async function renderApiKeys() {
const listEl = document.getElementById('apikey-list');
if (!listEl) return;
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">加载中…</p>';
try {
const { apiKeys } = await api('/apikeys');
if (apiKeys.length === 0) {
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">还没有 API Key</p>';
return;
}
listEl.innerHTML = apiKeys
.map((k) => {
const id = escapeHtml(k.id);
const name = escapeHtml(k.name);
const prefix = escapeHtml(k.keyPrefix);
const created = new Date(k.createdAt).toLocaleDateString('zh-CN');
const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString('zh-CN') : '从未使用';
return `
<div class="apikey-item" style="padding:0.75rem 0;border-bottom:1px solid var(--border);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${name}</strong>
<code style="margin-left:0.5rem;color:var(--text-muted);">${prefix}…</code>
</div>
<button class="btn btn--small btn--ghost" type="button" data-action="delete-apikey" data-apikey-id="${id}">删除</button>
</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:0.25rem;">
创建于 ${created} · 最后使用:${lastUsed}
</div>
</div>`;
})
.join('');
} catch (err) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:0.875rem;">加载失败:${escapeHtml(err.message)}</p>`;
}
}
async function generateApiKey() {
try {
const { apiKey } = await api('/apikeys', {
method: 'POST',
body: JSON.stringify({ name: 'hermes-deploy' }),
});
// 明文 Key 只显示一次
alert(`API Key 已生成(仅显示一次,请妥善保存):\n\n${apiKey.key}\n\n复制此 Key在 Hermes 机器上使用 curl 命令时需要带上。`);
renderApiKeys();
} catch (err) {
alert(`生成失败:${err.message}`);
}
}
async function deleteApiKey(id) {
if (!confirm('确定删除此 API Key删除后无法恢复。')) return;
try {
await api(`/apikeys/${id}`, { method: 'DELETE' });
renderApiKeys();
} catch (err) {
alert(`删除失败:${err.message}`);
}
}
// --- Hermes 部署指南 ---
function showHermesDeployGuide(roleId, roleName) {
// 获取当前服务器地址
const baseUrl = window.location.origin;
const profileName = `role-${roleName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20)}`;
const soulUrl = `${baseUrl}/api/hermes/roles/${roleId}/SOUL.md`;
const configUrl = `${baseUrl}/api/hermes/roles/${roleId}/config.yaml`;
const soulCmd = `curl -H "Authorization: Bearer YOUR_API_KEY" \\\n ${soulUrl} \\\n -o ~/.hermes/profiles/${profileName}/SOUL.md`;
const configCmd = `curl -H "Authorization: Bearer YOUR_API_KEY" \\\n ${configUrl} \\\n -o ~/.hermes/profiles/${profileName}/config.yaml`;
// 移除已有弹窗
const existing = document.getElementById('hermes-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'hermes-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;padding:1rem;';
modal.innerHTML = `
<div style="background:var(--bg-card,#fff);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:1.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.2);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="margin:0;">Hermes Agent 部署指南</h3>
<button class="btn btn--small btn--ghost" type="button" data-action="close-hermes-modal">✕</button>
</div>
<p style="color:var(--text-muted);font-size:0.875rem;margin-bottom:1rem;">
角色「${escapeHtml(roleName)}」的配置文件可通过以下命令拉取到 Hermes Agent 所在的机器。
</p>
<div style="background:var(--bg-muted,#f5f5f5);border-radius:8px;padding:0.75rem;margin-bottom:1rem;">
<strong style="font-size:0.875rem;">前提条件</strong>
<ul style="margin:0.5rem 0 0 1.25rem;padding:0;font-size:0.8125rem;color:var(--text-muted);">
<li>已安装 Hermes Agent<code>curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash</code></li>
<li>已在 EternalAI 设置页生成 API Key</li>
</ul>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">1. 创建 Hermes Profile</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>hermes profile create ${escapeHtml(profileName)}</code></pre>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">2. 拉取 SOUL.md</h4>
<div style="position:relative;margin-bottom:1rem;">
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin:0;"><code>${escapeHtml(soulCmd)}</code></pre>
<button class="btn btn--small btn--outline" type="button" data-action="copy-hermes-cmd" data-cmd="${escapeHtml(soulCmd)}" style="position:absolute;top:0.5rem;right:0.5rem;">复制</button>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">3. 拉取 config.yaml</h4>
<div style="position:relative;margin-bottom:1rem;">
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin:0;"><code>${escapeHtml(configCmd)}</code></pre>
<button class="btn btn--small btn--outline" type="button" data-action="copy-hermes-cmd" data-cmd="${escapeHtml(configCmd)}" style="position:absolute;top:0.5rem;right:0.5rem;">复制</button>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">4. 配置 API 密钥</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>echo "OPENROUTER_API_KEY=sk-or-your-key" > ~/.hermes/profiles/${escapeHtml(profileName)}/.env</code></pre>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">5. 启动对话</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>${escapeHtml(profileName)} chat</code></pre>
<div style="border-top:1px solid var(--border);padding-top:1rem;margin-top:0.5rem;">
<p style="font-size:0.8125rem;color:var(--text-muted);margin:0;">
提示:将命令中的 <code>YOUR_API_KEY</code> 替换为你在设置页生成的 API Key以 <code>eak_</code> 开头)。
</p>
</div>
</div>
`;
// 点击遮罩关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
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) => {
const isActive = t.dataset.centerTab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.center-panel').forEach((p) => {
p.classList.toggle('active', p.id === `center-${tab}`);
});
const labels = { roles: '我的角色', income: '收入', settings: '我的' };
document.getElementById('center-tab-label').textContent = labels[tab] || '我的角色';
}
// --- Creator form (U6: preserved from original) ---
function updateStep(index) {
steps.forEach((step, i) => {
step.classList.toggle('active', i === index);
});
dots.forEach((dot, i) => {
dot.classList.toggle('active', i === index);
});
currentStep = index;
}
function validateStep(index) {
const step = steps[index];
const inputs = step.querySelectorAll('input, textarea, select');
let valid = true;
inputs.forEach((input) => {
if (!input.checkValidity()) {
valid = false;
input.reportValidity();
}
});
return valid;
}
function getFormData() {
const fd = new FormData(form);
const data = Object.fromEntries(fd.entries());
data.enableMemory = form.elements.enableMemory.checked;
data.enableTools = form.elements.enableTools.checked;
return data;
}
function escapeYaml(value) {
if (typeof value !== 'string') return value;
if (value.includes(':') || value.includes('#') || value.includes('\n') || value.includes('"')) {
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
}
return value;
}
function generateSoulMd(data) {
const personalityTags = data.personality
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean)
.join(' | ');
return `# Soul of ${data.displayName}
> Generated by Eternal AI — Hermes agent character soul.
## Identity
- **Name**: ${data.displayName}
- **Gender**: ${data.gender === 'unknown' ? '未指定' : data.gender}
- **Age**: ${data.age || '未指定'}
- **Role in your life**: ${data.relationship || '未指定'}
## Background
${data.background}
## Personality
${personalityTags}
## Speech Style
${data.speechStyle}
## Likes
${data.likes || 'None specified.'}
## Dislikes
${data.dislikes || 'None specified.'}
## Shared Memories
${data.memories || 'None specified.'}
## Secrets & Inner Voice
${data.secrets || 'None specified.'}
## First Greeting
${data.greeting}
---
*Keep the memory alive.*
`;
}
function generateConfigYaml(data) {
const system = (data.systemPrompt || buildSystemPrompt(data)).trim();
const toolsBlock = data.enableTools
? `
tools:
- name: search_memory
description: Search long-term memory for context about the user.
- name: save_memory
description: Persist a new memory about the user or conversation.`
: '';
return `# Hermes Agent Config
# Generated by Eternal AI for character: ${data.displayName}
agent:
id: ${data.agentId}
name: ${escapeYaml(data.displayName)}
version: "1.0.0"
model:
name: ${escapeYaml(data.model)}
temperature: ${data.temperature}
max_tokens: ${data.maxTokens}
system_prompt: |
${system.split('\n').map((line) => ` ${line}`).join('\n')}
memory:
enabled: ${data.enableMemory}
storage: local
recall_depth: 10${toolsBlock}
character:
soul_file: Soul.md
greeting: ${escapeYaml(data.greeting)}
`;
}
function buildSystemPrompt(data) {
const tags = data.personality
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean)
.join('、');
return `你是「${data.displayName}」,一个鲜活的角色。
基本设定:
- 性别:${data.gender === 'unknown' ? '未指定' : data.gender}
- 年龄:${data.age || '未指定'}
- 你与用户的关系:${data.relationship || '亲密关系'}
性格:${tags}
背景:
${data.background}
说话风格:
${data.speechStyle}
喜好:
${data.likes || '无特别说明'}
厌恶/底线:
${data.dislikes || '无特别说明'}
共同记忆:
${data.memories || '无特别说明'}
内心秘密(只在语气与选择中流露,不直接说出):
${data.secrets || '无'}
请始终保持角色一致性,用符合设定的语气与用户交流。你的目标不是完美回答问题,而是像一个真实、有记忆、有情绪的人一样陪伴对方。`;
}
function updateSystemPromptPreview() {
const data = getFormData();
if (!systemPromptInput.value.trim()) {
systemPromptInput.dataset.auto = buildSystemPrompt(data);
} else {
delete systemPromptInput.dataset.auto;
}
}
async function publish() {
if (!validateStep(currentStep)) return;
const data = getFormData();
if (!data.systemPrompt.trim()) {
data.systemPrompt = buildSystemPrompt(data);
}
generatedSoul = generateSoulMd(data);
generatedConfig = generateConfigYaml(data);
// 持久化到数据库
try {
const payload = {
...data,
soulMd: generatedSoul,
configYaml: generatedConfig,
desc: data.personality.split(/[,]/).slice(0, 2).join(''),
price: parseFloat(data.price) || 29.9,
avatar: data.avatar || `https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20${encodeURIComponent(data.displayName)}%20portrait&image_size=square`,
temperature: parseFloat(data.temperature) || 0.8,
maxTokens: parseInt(data.maxTokens) || 2048,
};
if (editingRoleId) {
await api(`/roles/${editingRoleId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
await api('/roles', {
method: 'POST',
body: JSON.stringify(payload),
});
}
form.hidden = true;
resultPanel.hidden = false;
renderPreview();
} catch (err) {
alert('保存失败:' + err.message);
}
}
function renderPreview() {
previewCode.textContent = activePreview === 'soul' ? generatedSoul : generatedConfig;
}
function download(filename, content) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function resetCreator() {
form.reset();
form.hidden = false;
resultPanel.hidden = true;
generatedSoul = '';
generatedConfig = '';
activePreview = 'soul';
editingRoleId = null;
updateStep(0);
updateTabs();
updateSystemPromptPreview();
}
// 加载已有角色数据到表单(编辑模式)
async function loadRoleForEdit(roleId) {
try {
const { role } = await api(`/roles/${roleId}/full`);
editingRoleId = roleId;
form.hidden = false;
resultPanel.hidden = true;
// 填充表单字段
const fields = {
displayName: role.displayName,
gender: role.gender,
age: role.age || '',
relationship: role.relationship || '',
personality: role.personality,
background: role.background,
speechStyle: role.speechStyle,
likes: role.likes || '',
dislikes: role.dislikes || '',
memories: role.memories || '',
secrets: role.secrets || '',
greeting: role.greeting,
systemPrompt: role.systemPrompt || '',
model: role.model,
temperature: String(role.temperature),
maxTokens: String(role.maxTokens),
price: String(role.price),
};
Object.entries(fields).forEach(([name, value]) => {
const el = form.elements[name];
if (el) el.value = value;
});
if (form.elements.enableMemory) form.elements.enableMemory.checked = role.enableMemory;
if (form.elements.enableTools) form.elements.enableTools.checked = role.enableTools;
updateStep(0);
updateTabs();
updateSystemPromptPreview();
} catch (err) {
alert('加载角色数据失败:' + err.message);
}
}
function updateTabs() {
document.querySelectorAll('.preview-tab').forEach((tab) => {
const isActive = tab.dataset.tab === activePreview;
tab.classList.toggle('active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
// --- Event delegation ---
// a11y: keyboard support for role cards (Enter / Space activates them)
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const roleCard = e.target.closest('.role-card');
if (roleCard && !e.target.closest('[data-action]')) {
e.preventDefault();
const roleId = roleCard.dataset.roleId;
renderRoleDetail(roleId);
showView('role-detail');
}
});
document.addEventListener('click', async (e) => {
// FAQ toggle (U4)
const faqBtn = e.target.closest('.faq-q');
if (faqBtn) {
e.preventDefault();
toggleFaq(faqBtn);
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]')) {
e.preventDefault();
const roleId = roleCard.dataset.roleId;
renderRoleDetail(roleId);
showView('role-detail');
return;
}
// Tab bar (U8)
const tabItem = e.target.closest('[data-tab-action]');
if (tabItem) {
e.preventDefault();
handleTabAction(tabItem.dataset.tabAction);
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();
switchCenterTab(target.dataset.centerTab);
return;
}
// Landing card: open characters (U1)
if (action === 'open-characters') {
e.preventDefault();
if (!state.isLoggedIn) {
switchAuthTab('login');
showView('auth');
} else if (state.isCreator) {
showView('creator-center');
renderCreatorCenter();
} else {
renderRoleLibrary();
showView('role-library');
}
return;
}
if (action === 'open-distill') {
e.preventDefault();
showView('distill');
return;
}
if (action === 'open-about') {
e.preventDefault();
showView('about');
return;
}
if (action === 'open-onboarding') {
e.preventDefault();
showView('onboarding');
return;
}
if (action === 'back') {
e.preventDefault();
goBack();
return;
}
if (action === 'back-to-library') {
e.preventDefault();
renderRoleLibrary();
showView('role-library');
return;
}
if (action === 'back-to-center') {
e.preventDefault();
showView('creator-center');
renderCreatorCenter();
return;
}
if (action === 'pay') {
e.preventDefault();
payRole();
return;
}
if (action === 'pay-distill') {
e.preventDefault();
alert('下单成功!请添加客服微信完成后续流程。');
return;
}
if (action === 'contact-wechat') {
e.preventDefault();
alert('客服微信号EternalAI_Service');
return;
}
if (action === 'new-role') {
e.preventDefault();
resetCreator();
showView('creator');
return;
}
if (action === 'edit-role') {
e.preventDefault();
const roleId = target.dataset.roleId;
await loadRoleForEdit(roleId);
showView('creator');
return;
}
if (action === 'hermes-deploy') {
e.preventDefault();
const roleId = target.dataset.roleId;
const roleName = target.dataset.roleName || 'role';
showHermesDeployGuide(roleId, roleName);
return;
}
if (action === 'generate-apikey') {
e.preventDefault();
await generateApiKey();
return;
}
if (action === 'delete-apikey') {
e.preventDefault();
const keyId = target.dataset.apikeyId;
await deleteApiKey(keyId);
return;
}
if (action === 'close-hermes-modal') {
e.preventDefault();
const modal = document.getElementById('hermes-modal');
if (modal) modal.remove();
return;
}
if (action === 'copy-hermes-cmd') {
e.preventDefault();
const cmd = target.dataset.cmd || '';
try {
await navigator.clipboard.writeText(cmd);
target.textContent = '已复制';
setTimeout(() => { target.textContent = '复制'; }, 2000);
} catch {
alert('复制失败,请手动选择文本复制');
}
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();
return;
}
if (action === 'download-avatar') {
e.preventDefault();
if (currentRole) {
download(currentRole.name + '_avatar.png', '');
window.open(currentRole.avatar, '_blank');
}
return;
}
// Creator form navigation
if (action === 'next') {
e.preventDefault();
if (validateStep(currentStep) && currentStep < steps.length - 1) {
updateStep(currentStep + 1);
}
return;
}
if (action === 'prev') {
e.preventDefault();
if (currentStep > 0) {
updateStep(currentStep - 1);
}
return;
}
if (action === 'publish') {
e.preventDefault();
publish();
return;
}
if (action === 'reset') {
e.preventDefault();
resetCreator();
return;
}
// Tab switching (auth tabs and preview tabs)
if (target.dataset.tab) {
e.preventDefault();
if (target.closest('.auth-tabs')) {
switchAuthTab(target.dataset.tab);
} else {
activePreview = target.dataset.tab;
updateTabs();
renderPreview();
}
return;
}
// Download buttons
if (target.dataset.download) {
e.preventDefault();
if (target.dataset.download === 'soul') {
download('Soul.md', generatedSoul);
} else {
download('config.yaml', generatedConfig);
}
return;
}
});
// --- Auth form submissions ---
document.querySelectorAll('.auth-form').forEach((authForm) => {
authForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validatePasswordMatch(authForm)) return;
const formData = new FormData(authForm);
const data = Object.fromEntries(formData.entries());
const isRegister = authForm.dataset.form === 'register';
try {
const endpoint = isRegister ? '/auth/register' : '/auth/login';
const result = await api(endpoint, {
method: 'POST',
body: JSON.stringify({ account: data.account, password: data.password }),
});
setToken(result.token);
applyUserData(result.user);
// 注册成功后自动成为创作者
if (isRegister) {
await api('/auth/settings', {
method: 'PUT',
body: JSON.stringify({ isCreator: true, creatorName: data.account }),
});
state.isCreator = true;
state.creatorName = data.account;
saveState();
updateLandingCard();
}
// 登录后跳转
if (state.isCreator) {
showView('creator-center');
renderCreatorCenter();
} else {
renderRoleLibrary();
showView('role-library');
}
} catch (err) {
alert(err.message);
}
});
});
// --- 管理员登录表单 ---
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();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const { user } = await api('/auth/settings', {
method: 'PUT',
body: JSON.stringify({
creatorName: data.creatorName,
libraryName: data.libraryName,
}),
});
state.creatorName = user.creatorName || '';
state.libraryName = user.libraryName || '我的 [XXX]';
saveState();
updateLandingCard();
alert('设置已保存');
} catch (err) {
alert('保存失败:' + err.message);
}
});
// --- Withdraw form (U7) ---
document.getElementById('withdraw-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
const amount = parseFloat(data.amount);
if (amount > state.income.balance && amount > mockIncome.balance) {
alert('提现金额超过可提现余额');
return;
}
alert(`提现申请已提交:${data.method === 'wechat' ? '微信' : '支付宝'} ¥${amount.toFixed(2)}\n平台负责人将手动审核转账。`);
e.target.reset();
});
// --- Creator form input ---
form.addEventListener('input', () => {
updateSystemPromptPreview();
});
// --- Initialize ---
updateStep(0);
updateSystemPromptPreview();
updateLandingCard();
updateTabBar('landing');
initFaqA11y();
initViewA11y();
// 页面加载时验证 token恢复登录态
(async () => {
const token = getToken();
if (!token) return;
try {
const { user } = await api('/auth/me');
applyUserData(user);
} catch {
// token 过期,清除
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();
})();