Files
yuzhiran-dev 538de50bb1 注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构
- Prisma User 模型新增 username 字段(唯一索引)
- 注册先查重复再创建,返回友好中文提示(非 500)
- 登录支持用户名/手机号/邮箱三种方式
- 前端注册表单增加用户名输入框,预校验 2-20 位格式
- 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx
- 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步
- AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构
- 新增 UserAiAssistantService(18 工具)+ AiAssistantController
- admin 助手新增 search + mark-all-notifications-read 工具
- 修复注册 500 错误:catch Prisma P2002 → BadRequestException
- Baidu Analytics Script 注入 root layout
2026-06-01 23:14:42 +08:00

799 lines
43 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然 AI - 管理后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config={theme:{extend:{colors:{brand:{50:'#f0f7ff',100:'#e0effe',200:'#bae0fd',300:'#7cc8fb',400:'#36aaf5',500:'#0c8ee7',600:'#0070c4',700:'#015a9f',800:'#064c83',900:'#0b406d'}}}}}}</script>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="loginPage" class="min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-2xl shadow-sm border w-full max-w-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-brand-600">宇之然 AI</h1>
<p class="text-gray-500 text-sm mt-1">管理后台</p>
</div>
<div class="space-y-4">
<input id="username" type="text" placeholder="管理员账号" class="w-full px-4 py-2.5 border rounded-lg text-sm focus:outline-none focus:border-brand-400" autocomplete="username">
<input id="password" type="password" placeholder="密码" class="w-full px-4 py-2.5 border rounded-lg text-sm focus:outline-none focus:border-brand-400" autocomplete="current-password">
<button onclick="login()" class="w-full py-2.5 bg-brand-600 text-white text-sm font-medium rounded-lg hover:bg-brand-700">登录</button>
<p id="loginError" class="text-red-500 text-sm text-center hidden"></p>
</div>
</div>
</div>
<div id="adminApp" class="hidden">
<nav class="bg-white border-b px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<h1 class="text-lg font-bold text-brand-600">宇之然 AI 管理后台</h1>
</div>
<div class="flex items-center gap-4">
<span id="adminName" class="text-sm text-gray-500"></span>
<button onclick="logout()" class="text-sm text-gray-400 hover:text-red-500">退出</button>
</div>
</nav>
<div class="flex">
<aside class="w-56 bg-white border-r min-h-[calc(100vh-4rem)] p-4 flex-shrink-0 overflow-y-auto">
<nav class="space-y-1">
<a href="#" onclick="showPage('dashboard')" class="nav-item block px-3 py-2 rounded-lg text-sm bg-brand-50 text-brand-600 font-medium" data-page="dashboard">📊 数据看板</a>
<a href="#" onclick="showPage('categories')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="categories">📂 分类管理</a>
<a href="#" onclick="showPage('users')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="users">👥 用户管理</a>
<a href="#" onclick="showPage('courses')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="courses">📚 课程管理</a>
<a href="#" onclick="showPage('contents')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="contents">📝 内容管理</a>
<a href="#" onclick="showPage('prompts')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="prompts">💡 提示词管理</a>
<a href="#" onclick="showPage('tools')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="tools">🔧 工具管理</a>
<a href="#" onclick="showPage('orders')" class="nav-item block px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50" data-page="orders">💰 订单管理</a>
</nav>
</aside>
<main class="flex-1 p-6 overflow-auto">
<div id="toast" class="fixed top-4 right-4 z-50 hidden"></div>
<div id="page-dashboard" class="page-content">
<h2 class="text-xl font-bold mb-6">数据看板</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8" id="statsGrid">
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">用户总数</p><p class="text-2xl font-bold" id="stat-userCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">课程总数</p><p class="text-2xl font-bold" id="stat-courseCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">内容总数</p><p class="text-2xl font-bold" id="stat-contentCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">提示词数</p><p class="text-2xl font-bold" id="stat-promptCount">-</p></div>
<div class="bg-white p-5 rounded-xl border"><p class="text-sm text-gray-400">订单数</p><p class="text-2xl font-bold" id="stat-orderCount">-</p></div>
</div>
</div>
<div id="page-categories" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">分类管理</h2>
<button onclick="showCategoryForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建分类</button>
</div>
<div id="categoryForm" class="hidden mb-6 bg-white p-6 rounded-xl border max-w-lg">
<h3 class="text-lg font-semibold mb-4" id="categoryFormTitle">新建分类</h3>
<div class="space-y-3">
<input id="caf-name" placeholder="分类名称" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-slug" placeholder="标识 (slug)" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-desc" placeholder="描述" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="caf-sort" type="number" placeholder="排序 (数字越小越靠前)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-2">
<button onclick="saveCategory()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelCategoryForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">名称</th><th class="text-left px-4 py-3 font-medium">标识</th><th class="text-left px-4 py-3 font-medium">排序</th><th class="text-left px-4 py-3 font-medium">关联内容</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="categoriesTable" class="divide-y"></tbody>
</table>
</div>
</div>
<div id="page-users" class="page-content hidden">
<h2 class="text-xl font-bold mb-6">用户管理</h2>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">昵称</th><th class="text-left px-4 py-3 font-medium">手机号</th><th class="text-left px-4 py-3 font-medium">邮箱</th><th class="text-left px-4 py-3 font-medium">会员</th><th class="text-left px-4 py-3 font-medium">注册时间</th></tr>
</thead>
<tbody id="usersTable" class="divide-y"></tbody>
</table>
</div>
</div>
<div id="page-courses" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">课程管理</h2>
<button onclick="showCourseForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建课程</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">分类</th><th class="text-left px-4 py-3 font-medium">价格</th><th class="text-left px-4 py-3 font-medium">课时</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="coursesTable" class="divide-y"></tbody>
</table>
</div>
<div id="courseForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-4xl">
<h3 class="text-lg font-semibold mb-4" id="courseFormTitle">新建课程</h3>
<div class="space-y-3">
<input id="cf-title" placeholder="课程标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="cf-desc" placeholder="课程描述" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<div class="flex gap-3">
<select id="cf-category" class="flex-1 px-3 py-2 border rounded-lg text-sm"></select>
<input id="cf-price" type="number" placeholder="价格(元)" class="w-32 px-3 py-2 border rounded-lg text-sm">
</div>
<div class="flex gap-3 items-center">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="cf-isFree" checked> 免费</label>
<select id="cf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
<input id="cf-sort" type="number" placeholder="排序" class="w-20 px-3 py-2 border rounded-lg text-sm">
</div>
</div>
<div id="chaptersSection" class="mt-6 hidden">
<h4 class="text-md font-semibold text-gray-900 mb-3 flex items-center justify-between">
<span>章节管理</span>
<button onclick="addChapter()" class="text-xs px-3 py-1 bg-gray-100 rounded-lg hover:bg-gray-200">+ 添加章节</button>
</h4>
<div id="chaptersList" class="space-y-3"></div>
</div>
<div class="flex gap-2 mt-4">
<button onclick="saveCourse()" class="px-6 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">保存课程</button>
<button onclick="cancelCourseForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
<div id="page-contents" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">内容管理</h2>
<button onclick="showContentForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建内容</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">类型</th><th class="text-left px-4 py-3 font-medium">浏览量</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="contentsTable" class="divide-y"></tbody>
</table>
</div>
<div id="contentForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="contentFormTitle">新建内容</h3>
<div class="space-y-3">
<input id="cof-title" placeholder="内容标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="cof-summary" placeholder="摘要" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<textarea id="cof-content" placeholder="内容 (支持 Markdown)" class="w-full px-3 py-2 border rounded-lg text-sm" rows="8"></textarea>
<div class="flex gap-3">
<select id="cof-type" class="px-3 py-2 border rounded-lg text-sm">
<option value="article">文章</option><option value="tutorial">教程</option><option value="news">资讯</option>
</select>
<select id="cof-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="saveContent()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelContentForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-prompts" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">提示词管理</h2>
<button onclick="showPromptForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建提示词</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">标题</th><th class="text-left px-4 py-3 font-medium">标签</th><th class="text-left px-4 py-3 font-medium">模型</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="promptsTable" class="divide-y"></tbody>
</table>
</div>
<div id="promptForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="promptFormTitle">新建提示词</h3>
<div class="space-y-3">
<input id="pf-title" placeholder="提示词标题" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="pf-content" placeholder="提示词内容" class="w-full px-3 py-2 border rounded-lg text-sm" rows="6"></textarea>
<input id="pf-tags" placeholder="标签(逗号分隔)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-3">
<input id="pf-model" placeholder="适用模型" class="flex-1 px-3 py-2 border rounded-lg text-sm">
<select id="pf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="savePrompt()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelPromptForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-tools" class="page-content hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">工具管理</h2>
<button onclick="showToolForm()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700">+ 新建工具</button>
</div>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">ID</th><th class="text-left px-4 py-3 font-medium">名称</th><th class="text-left px-4 py-3 font-medium">URL</th><th class="text-left px-4 py-3 font-medium">推荐</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">操作</th></tr>
</thead>
<tbody id="toolsTable" class="divide-y"></tbody>
</table>
</div>
<div id="toolForm" class="hidden mt-6 bg-white p-6 rounded-xl border max-w-2xl">
<h3 class="text-lg font-semibold mb-4" id="toolFormTitle">新建工具</h3>
<div class="space-y-3">
<input id="tf-name" placeholder="工具名称" class="w-full px-3 py-2 border rounded-lg text-sm">
<input id="tf-url" placeholder="工具 URL" class="w-full px-3 py-2 border rounded-lg text-sm">
<textarea id="tf-desc" placeholder="描述" class="w-full px-3 py-2 border rounded-lg text-sm" rows="2"></textarea>
<input id="tf-tags" placeholder="标签(逗号分隔)" class="w-full px-3 py-2 border rounded-lg text-sm">
<div class="flex gap-3">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="tf-featured"> 推荐</label>
<select id="tf-status" class="px-3 py-2 border rounded-lg text-sm">
<option value="DRAFT">草稿</option><option value="PUBLISHED">已发布</option>
</select>
</div>
<div class="flex gap-2">
<button onclick="saveTool()" class="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg">保存</button>
<button onclick="cancelToolForm()" class="px-4 py-2 border text-sm rounded-lg">取消</button>
</div>
</div>
</div>
</div>
<div id="page-orders" class="page-content hidden">
<h2 class="text-xl font-bold mb-6">订单管理</h2>
<div class="bg-white rounded-xl border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500">
<tr><th class="text-left px-4 py-3 font-medium">订单号</th><th class="text-left px-4 py-3 font-medium">用户</th><th class="text-left px-4 py-3 font-medium">金额</th><th class="text-left px-4 py-3 font-medium">套餐</th><th class="text-left px-4 py-3 font-medium">状态</th><th class="text-left px-4 py-3 font-medium">支付渠道</th><th class="text-left px-4 py-3 font-medium">时间</th></tr>
</thead>
<tbody id="ordersTable" class="divide-y"></tbody>
</table>
</div>
</div>
</main>
</div>
</div>
<script>
const API = 'http://localhost:4000/api/v1';
let editingId = null;
let chapterCounter = 0;
let categoriesCache = [];
function $(id) { return document.getElementById(id); }
function toast(msg, type = 'success') {
const el = $('toast');
el.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg transition-all ${
type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'
}`;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 3000);
}
function loading(btn, isLoading) {
if (isLoading) { btn.disabled = true; btn._text = btn.textContent; btn.textContent = '处理中...'; }
else { btn.disabled = false; btn.textContent = btn._text || btn.textContent; }
}
async function api(url, opts = {}) {
const token = JSON.parse(localStorage.getItem('admin_user') || '{}').token;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + url, { headers, ...opts });
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
return data;
}
async function login() {
const username = $('username').value;
const password = $('password').value;
if (!username || !password) { $('loginError').classList.remove('hidden'); $('loginError').textContent = '请填写账号和密码'; return; }
try {
const data = await api('/admin/login', { method: 'POST', body: JSON.stringify({ username, password }) });
const tokenRes = await fetch(API + '/admin/dashboard', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('_jwt') }
});
const loginRes = await fetch(API + '/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: username, password })
});
const loginData = await loginRes.json();
data.token = loginData.accessToken || '';
if (!data.token) {
const adminStr = btoa(JSON.stringify({ id: data.id, username: data.username }));
data.token = 'admin_' + adminStr;
}
localStorage.setItem('admin_user', JSON.stringify(data));
$('loginPage').classList.add('hidden');
$('adminApp').classList.remove('hidden');
$('adminName').textContent = data.username;
showPage('dashboard');
} catch (e) {
$('loginError').classList.remove('hidden');
$('loginError').textContent = e.message || '无法连接服务器';
}
}
function logout() {
localStorage.removeItem('admin_user');
$('adminApp').classList.add('hidden');
$('loginPage').classList.remove('hidden');
}
const user = JSON.parse(localStorage.getItem('admin_user') || '{}');
if (user.id) {
$('loginPage').classList.add('hidden');
$('adminApp').classList.remove('hidden');
$('adminName').textContent = user.username;
showPage('dashboard');
}
function showPage(page) {
document.querySelectorAll('.page-content').forEach(el => el.classList.add('hidden'));
$(`page-${page}`).classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.remove('bg-brand-50', 'text-brand-600', 'font-medium');
});
const navItem = document.querySelector(`[data-page="${page}"]`);
if (navItem) navItem.classList.add('bg-brand-50', 'text-brand-600', 'font-medium');
const loaders = {
dashboard: loadDashboard, categories: loadCategories, users: loadUsers,
courses: loadCourses, contents: loadContents, prompts: loadPrompts,
tools: loadTools, orders: loadOrders,
};
if (loaders[page]) loaders[page]();
}
async function loadDashboard() {
try {
const data = await api('/admin/dashboard');
if (data.stats) Object.keys(data.stats).forEach(k => {
const el = $(`stat-${k}`);
if (el) el.textContent = data.stats[k].toLocaleString();
});
} catch (e) { toast('加载数据看板失败: ' + e.message, 'error'); }
}
async function loadCategories() {
try {
const data = await api('/categories');
$('categoriesTable').innerHTML = (Array.isArray(data) ? data : data.items || []).map(c =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3 font-medium">${c.name}</td><td class="px-4 py-3 text-gray-400">${c.slug}</td><td class="px-4 py-3">${c.sortOrder}</td><td class="px-4 py-3 text-xs text-gray-400">课程 ${c._count?.courses||0} · 提示词 ${c._count?.prompts||0} · 工具 ${c._count?.tools||0}</td><td class="px-4 py-3"><button onclick="editCategory(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteCategory(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载分类失败: ' + e.message, 'error'); }
}
function showCategoryForm() {
editingId = null;
$('categoryFormTitle').textContent = '新建分类';
['caf-name','caf-slug','caf-desc','caf-sort'].forEach(id => $(id).value = '');
$('categoryForm').classList.remove('hidden');
}
function cancelCategoryForm() { $('categoryForm').classList.add('hidden'); }
async function saveCategory() {
const body = {
name: $('caf-name').value, slug: $('caf-slug').value,
description: $('caf-desc').value, sortOrder: parseInt($('caf-sort').value) || 0,
};
if (!body.name || !body.slug) return toast('请填写名称和标识', 'error');
try {
const url = editingId ? `/categories/${editingId}` : '/categories';
await api(url, { method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body) });
toast(editingId ? '分类已更新' : '分类已创建');
cancelCategoryForm(); loadCategories();
} catch (e) { toast(e.message, 'error'); }
}
async function editCategory(id) {
try {
const data = await api(`/categories/${id}`);
editingId = id;
$('categoryFormTitle').textContent = '编辑分类';
$('caf-name').value = data.name;
$('caf-slug').value = data.slug;
$('caf-desc').value = data.description || '';
$('caf-sort').value = data.sortOrder || 0;
$('categoryForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteCategory(id) {
if (!confirm('确定删除此分类?关联内容不会被删除。')) return;
try {
await api(`/categories/${id}`, { method: 'DELETE' });
toast('分类已删除');
loadCategories();
} catch (e) { toast(e.message, 'error'); }
}
async function loadUsers() {
try {
const data = await api('/users');
$('usersTable').innerHTML = (data.items || []).map(u =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${u.id}</td><td class="px-4 py-3">${u.nickname || '-'}</td><td class="px-4 py-3">${u.phone || '-'}</td><td class="px-4 py-3">${u.email || '-'}</td><td class="px-4 py-3">${u.memberPlan === 'FREE' ? '免费' : u.memberPlan}</td><td class="px-4 py-3 text-gray-400">${new Date(u.createdAt).toLocaleDateString()}</td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载用户失败: ' + e.message, 'error'); }
}
async function loadCourses() {
try {
const data = await api('/courses');
$('coursesTable').innerHTML = (data.items || []).map(c => {
const lessonCount = c.chapters?.reduce((s, ch) => s + (ch.lessons?.length || 0), 0) || 0;
return `<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3 font-medium">${c.title}</td><td class="px-4 py-3">${c.category?.name || '-'}</td><td class="px-4 py-3">${c.isFree ? '免费' : '¥' + c.price}</td><td class="px-4 py-3 text-xs text-gray-400">${c.chapters?.length||0} 章 / ${lessonCount} 课</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${c.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${c.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editCourse(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteCourse(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`;
}).join('') || '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载课程失败: ' + e.message, 'error'); }
}
function showCourseForm() {
editingId = null;
$('courseFormTitle').textContent = '新建课程';
$('cf-title').value = ''; $('cf-desc').value = '';
$('cf-price').value = '0'; $('cf-isFree').checked = true;
$('cf-status').value = 'DRAFT'; $('cf-sort').value = '0';
$('chaptersSection').classList.add('hidden');
$('chaptersList').innerHTML = '';
loadCategorySelect('cf-category');
$('courseForm').classList.remove('hidden');
}
function cancelCourseForm() {
$('courseForm').classList.add('hidden');
$('chaptersSection').classList.add('hidden');
}
async function loadCategorySelect(id) {
try {
const data = await api('/categories');
const cats = Array.isArray(data) ? data : data.items || [];
categoriesCache = cats;
$(id).innerHTML = '<option value="">无分类</option>' + cats.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
} catch (e) {
$(id).innerHTML = '<option value="">无分类</option>';
}
}
function addChapter(data) {
const idx = chapterCounter++;
const div = document.createElement('div');
div.className = 'chapter-block bg-gray-50 rounded-lg p-3 border';
div.dataset.idx = idx;
div.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-gray-400 font-medium chapter-num">第 ${$('#chaptersList').children.length + 1} 章</span>
<input class="chapter-title flex-1 px-2 py-1 border rounded text-sm" placeholder="章节标题" value="${data?.title || ''}">
<input class="chapter-sort w-16 px-2 py-1 border rounded text-sm" type="number" placeholder="排序" value="${data?.sortOrder || 0}">
<button onclick="removeChapter(this)" class="text-red-400 hover:text-red-600 text-sm px-1">✕</button>
</div>
<div class="lessons-list space-y-1 ml-2"></div>
<button onclick="addLesson(this)" class="text-xs text-brand-600 hover:text-brand-700 mt-1 ml-2">+ 添加课时</button>
`;
if (data?.lessons) {
data.lessons.forEach(l => {
const lessonDiv = createLessonEl(l);
div.querySelector('.lessons-list').appendChild(lessonDiv);
});
}
$('chaptersList').appendChild(div);
updateChapterNumbers();
}
function createLessonEl(data) {
const div = document.createElement('div');
div.className = 'lesson-item flex items-center gap-2 py-1';
div.innerHTML = `
<span class="text-xs text-gray-400 w-6">${$('#chaptersList').querySelectorAll('.lesson-item').length + 1}.</span>
<input class="lesson-title flex-1 px-2 py-1 border rounded text-xs" placeholder="课时标题" value="${data?.title || ''}">
<input class="lesson-sort w-14 px-2 py-1 border rounded text-xs" type="number" placeholder="排序" value="${data?.sortOrder || 0}">
<select class="lesson-status px-2 py-1 border rounded text-xs">
<option value="PUBLISHED" ${data?.status === 'PUBLISHED' ? 'selected' : ''}>已发布</option>
<option value="DRAFT" ${data?.status === 'DRAFT' ? 'selected' : ''}>草稿</option>
</select>
<button onclick="this.parentElement.remove()" class="text-red-300 hover:text-red-500 text-xs px-1">✕</button>
`;
return div;
}
function addLesson(btn) {
const lessonsList = btn.previousElementSibling;
const el = createLessonEl(null);
lessonsList.appendChild(el);
updateLessonNumbers();
}
function removeChapter(btn) {
btn.closest('.chapter-block').remove();
updateChapterNumbers();
}
function updateChapterNumbers() {
$('chaptersList').querySelectorAll('.chapter-block').forEach((el, i) => {
el.querySelector('.chapter-num').textContent = `${i + 1}`;
});
}
function updateLessonNumbers() {
$('chaptersList').querySelectorAll('.lesson-item').forEach((el, i) => {
el.querySelector('span').textContent = `${i + 1}.`;
});
}
function collectChaptersData() {
const chapters = [];
$('chaptersList').querySelectorAll('.chapter-block').forEach(block => {
const lessons = [];
block.querySelectorAll('.lesson-item').forEach(item => {
lessons.push({
title: item.querySelector('.lesson-title').value,
sortOrder: parseInt(item.querySelector('.lesson-sort').value) || 0,
status: item.querySelector('.lesson-status').value,
});
});
chapters.push({
title: block.querySelector('.chapter-title').value,
sortOrder: parseInt(block.querySelector('.chapter-sort').value) || 0,
lessons,
});
});
return chapters;
}
async function saveCourse() {
const title = $('cf-title').value;
if (!title) return toast('请输入课程标题', 'error');
const chapters = collectChaptersData();
const body = {
title,
description: $('cf-desc').value,
price: parseFloat($('cf-price').value) || 0,
isFree: $('cf-isFree').checked,
status: $('cf-status').value,
sortOrder: parseInt($('cf-sort').value) || 0,
chapters,
};
if ($('cf-category').value) body.categoryId = parseInt($('cf-category').value);
try {
if (editingId) {
await api(`/courses/${editingId}`, { method: 'PUT', body: JSON.stringify({ title: body.title, description: body.description, price: body.price, isFree: body.isFree, status: body.status, sortOrder: body.sortOrder, categoryId: body.categoryId }) });
if (chapters.length > 0) {
await api(`/admin/courses/${editingId}/chapters`, { method: 'PUT', body: JSON.stringify({ chapters }) });
}
toast('课程已更新');
} else {
const course = await api('/courses', { method: 'POST', body: JSON.stringify(body) });
if (chapters.length > 0 && course.id) {
await api(`/admin/courses/${course.id}/chapters`, { method: 'PUT', body: JSON.stringify({ chapters }) });
}
toast('课程已创建');
}
cancelCourseForm(); loadCourses();
} catch (e) { toast(e.message, 'error'); }
}
async function editCourse(id) {
try {
const data = await api(`/courses/${id}`);
editingId = id;
$('courseFormTitle').textContent = '编辑课程';
$('cf-title').value = data.title;
$('cf-desc').value = data.description || '';
$('cf-price').value = data.price;
$('cf-isFree').checked = data.isFree;
$('cf-status').value = data.status;
$('cf-sort').value = data.sortOrder || 0;
await loadCategorySelect('cf-category');
if (data.categoryId) $('cf-category').value = data.categoryId;
$('chaptersList').innerHTML = '';
chapterCounter = 0;
if (data.chapters && data.chapters.length > 0) {
$('chaptersSection').classList.remove('hidden');
data.chapters.forEach(ch => addChapter(ch));
} else {
$('chaptersSection').classList.remove('hidden');
}
$('courseForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteCourse(id) {
if (!confirm('确定删除此课程?')) return;
try {
await api(`/courses/${id}`, { method: 'DELETE' });
toast('课程已删除');
loadCourses();
} catch (e) { toast(e.message, 'error'); }
}
async function loadContents() {
try {
const data = await api('/contents');
$('contentsTable').innerHTML = (data.items || []).map(c =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${c.id}</td><td class="px-4 py-3">${c.title}</td><td class="px-4 py-3">${c.contentType}</td><td class="px-4 py-3">${c.viewCount}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${c.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${c.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editContent(${c.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteContent(${c.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载内容失败: ' + e.message, 'error'); }
}
function showContentForm() {
editingId = null;
$('contentFormTitle').textContent = '新建内容';
['cof-title','cof-summary','cof-content'].forEach(id => $(id).value = '');
$('cof-type').value = 'article'; $('cof-status').value = 'DRAFT';
$('contentForm').classList.remove('hidden');
}
function cancelContentForm() { $('contentForm').classList.add('hidden'); }
async function saveContent() {
const body = { title: $('cof-title').value, summary: $('cof-summary').value, content: $('cof-content').value, contentType: $('cof-type').value, status: $('cof-status').value };
if (!body.title) return toast('请输入标题', 'error');
try {
await api(editingId ? `/contents/${editingId}` : '/contents', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '内容已更新' : '内容已创建');
cancelContentForm(); loadContents();
} catch (e) { toast(e.message, 'error'); }
}
async function editContent(id) {
try {
const data = await api(`/contents/${id}`);
editingId = id;
$('contentFormTitle').textContent = '编辑内容';
$('cof-title').value = data.title;
$('cof-summary').value = data.summary || '';
$('cof-content').value = data.content || '';
$('cof-type').value = data.contentType;
$('cof-status').value = data.status;
$('contentForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteContent(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/contents/${id}`, { method: 'DELETE' });
toast('内容已删除');
loadContents();
} catch (e) { toast(e.message, 'error'); }
}
async function loadPrompts() {
try {
const data = await api('/prompts');
$('promptsTable').innerHTML = (data.items || []).map(p =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${p.id}</td><td class="px-4 py-3">${p.title}</td><td class="px-4 py-3">${p.tags || '-'}</td><td class="px-4 py-3">${p.model || '-'}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${p.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${p.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editPrompt(${p.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deletePrompt(${p.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载提示词失败: ' + e.message, 'error'); }
}
function showPromptForm() {
editingId = null;
$('promptFormTitle').textContent = '新建提示词';
['pf-title','pf-content','pf-tags','pf-model'].forEach(id => $(id).value = '');
$('pf-status').value = 'DRAFT';
$('promptForm').classList.remove('hidden');
}
function cancelPromptForm() { $('promptForm').classList.add('hidden'); }
async function savePrompt() {
const body = { title: $('pf-title').value, content: $('pf-content').value, tags: $('pf-tags').value, model: $('pf-model').value, status: $('pf-status').value };
if (!body.title || !body.content) return toast('请输入标题和内容', 'error');
try {
await api(editingId ? `/prompts/${editingId}` : '/prompts', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '提示词已更新' : '提示词已创建');
cancelPromptForm(); loadPrompts();
} catch (e) { toast(e.message, 'error'); }
}
async function editPrompt(id) {
try {
const data = await api(`/prompts/${id}`);
editingId = id;
$('promptFormTitle').textContent = '编辑提示词';
$('pf-title').value = data.title;
$('pf-content').value = data.content;
$('pf-tags').value = data.tags || '';
$('pf-model').value = data.model || '';
$('pf-status').value = data.status;
$('promptForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deletePrompt(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/prompts/${id}`, { method: 'DELETE' });
toast('提示词已删除');
loadPrompts();
} catch (e) { toast(e.message, 'error'); }
}
async function loadTools() {
try {
const data = await api('/tools');
$('toolsTable').innerHTML = (data.items || []).map(t =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3">${t.id}</td><td class="px-4 py-3 font-medium">${t.name}</td><td class="px-4 py-3"><a href="${t.url}" target="_blank" class="text-brand-600 text-xs">${(t.url||'').slice(0, 30)}...</a></td><td class="px-4 py-3">${t.isFeatured ? '⭐' : '-'}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${t.status === 'PUBLISHED' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}">${t.status === 'PUBLISHED' ? '已发布' : '草稿'}</span></td><td class="px-4 py-3"><button onclick="editTool(${t.id})" class="text-brand-600 text-sm mr-2">编辑</button><button onclick="deleteTool(${t.id})" class="text-red-500 text-sm">删除</button></td></tr>`
).join('') || '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>';
} catch (e) { toast('加载工具失败: ' + e.message, 'error'); }
}
function showToolForm() {
editingId = null;
$('toolFormTitle').textContent = '新建工具';
['tf-name','tf-url','tf-desc','tf-tags'].forEach(id => $(id).value = '');
$('tf-featured').checked = false;
$('tf-status').value = 'DRAFT';
$('toolForm').classList.remove('hidden');
}
function cancelToolForm() { $('toolForm').classList.add('hidden'); }
async function saveTool() {
const body = { name: $('tf-name').value, url: $('tf-url').value, description: $('tf-desc').value, tags: $('tf-tags').value, isFeatured: $('tf-featured').checked, status: $('tf-status').value };
if (!body.name || !body.url) return toast('请输入名称和URL', 'error');
try {
await api(editingId ? `/tools/${editingId}` : '/tools', {
method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body),
});
toast(editingId ? '工具已更新' : '工具已创建');
cancelToolForm(); loadTools();
} catch (e) { toast(e.message, 'error'); }
}
async function editTool(id) {
try {
const data = await api(`/tools/${id}`);
editingId = id;
$('toolFormTitle').textContent = '编辑工具';
$('tf-name').value = data.name;
$('tf-url').value = data.url;
$('tf-desc').value = data.description || '';
$('tf-tags').value = data.tags || '';
$('tf-featured').checked = data.isFeatured;
$('tf-status').value = data.status;
$('toolForm').classList.remove('hidden');
} catch (e) { toast(e.message, 'error'); }
}
async function deleteTool(id) {
if (!confirm('确定删除?')) return;
try {
await api(`/tools/${id}`, { method: 'DELETE' });
toast('工具已删除');
loadTools();
} catch (e) { toast(e.message, 'error'); }
}
async function loadOrders() {
try {
const data = await api('/admin/orders');
$('ordersTable').innerHTML = (data.items || []).map(o =>
`<tr class="hover:bg-gray-50"><td class="px-4 py-3 font-mono text-xs">${o.orderNo}</td><td class="px-4 py-3">${o.user?.nickname || o.user?.phone || '用户#' + o.userId}</td><td class="px-4 py-3">¥${o.amount.toFixed(2)}</td><td class="px-4 py-3">${o.planType}</td><td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded ${
o.status === 'PAID' ? 'bg-green-50 text-green-600' :
o.status === 'REFUNDED' ? 'bg-red-50 text-red-600' :
'bg-yellow-50 text-yellow-600'
}">${o.status === 'PAID' ? '已支付' : o.status === 'REFUNDED' ? '已退款' : o.status === 'CANCELLED' ? '已取消' : '待支付'}</span></td><td class="px-4 py-3 text-xs text-gray-400">${o.payChannel || '-'}</td><td class="px-4 py-3 text-xs text-gray-400">${new Date(o.createdAt).toLocaleString()}</td></tr>`
).join('') || '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">暂无订单数据</td></tr>';
} catch (e) { toast('加载订单失败: ' + e.message, 'error'); }
}
</script>
</body>
</html>