P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善

- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
yuzhiran-dev
2026-06-18 18:14:07 +08:00
parent fb8152401b
commit 0f215d2aad
103 changed files with 5240 additions and 778 deletions
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { toast } from 'sonner';
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
import { API_BASE } from '@/lib/config';
@@ -195,7 +196,7 @@ export default function AnalyticsPage() {
<QuickAction
label="导出报告"
desc="下载数据报表"
onClick={() => alert('导出功能开发中')}
onClick={() => toast.info('导出功能开发中')}
/>
</div>
</div>
+4 -3
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface PendingComment {
id: number;
@@ -37,7 +38,7 @@ export default function AdminCommentsPage() {
const data = await res.json();
setComments(data.items || []);
}
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
setLoading(false);
}, [tab, base]);
@@ -49,7 +50,7 @@ export default function AdminCommentsPage() {
method: 'PUT', headers: headers(),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function reject(id: number) {
@@ -60,7 +61,7 @@ export default function AdminCommentsPage() {
method: 'PUT', headers: headers(), body: JSON.stringify({ reason }),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
return (
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function EditModel() {
const router = useRouter();
const params = useParams();
const [form, setForm] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setForm({
name: data.name, provider: data.provider, description: data.description || '',
capabilities: data.capabilities || '', contextWindow: data.contextWindow || 4096,
maxTokens: data.maxTokens || 2048, pricing: data.pricing || '',
isFree: data.isFree ?? true, isFeatured: data.isFeatured ?? false,
icon: data.icon || '', sortOrder: data.sortOrder || 0, status: data.status || 'ACTIVE',
});
}
} catch {}
setLoading(false);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, contextWindow: Number(form.contextWindow), maxTokens: Number(form.maxTokens), sortOrder: Number(form.sortOrder) }),
});
if (res.ok) {
toast.success('更新成功');
router.push('/admin/models');
} else {
const err = await res.json();
toast.error(err.message || '更新失败');
}
} catch { toast.error('网络错误'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
if (!form) return <div className="p-6 text-muted-foreground"></div>;
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.capabilities} onChange={e => setForm({ ...form, capabilities: e.target.value })} />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.contextWindow} onChange={e => setForm({ ...form, contextWindow: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Max Tokens</label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.maxTokens} onChange={e => setForm({ ...form, maxTokens: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> Emoji</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.icon} onChange={e => setForm({ ...form, icon: e.target.value })} />
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFree}
onChange={e => setForm({ ...form, isFree: e.target.checked })} />
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFeatured}
onChange={e => setForm({ ...form, isFeatured: e.target.checked })} />
</label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/models?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
export default function NewModel() {
const router = useRouter();
const [form, setForm] = useState({
name: '', provider: '', description: '', capabilities: '',
contextWindow: 4096, maxTokens: 2048, pricing: '',
isFree: true, isFeatured: false, icon: '', sortOrder: 0, status: 'ACTIVE',
});
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, contextWindow: Number(form.contextWindow), maxTokens: Number(form.maxTokens), sortOrder: Number(form.sortOrder) }),
});
if (res.ok) {
toast.success('创建成功');
router.push('/admin/models');
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch { toast.error('网络错误'); }
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="chat,code,reasoning"
value={form.capabilities} onChange={e => setForm({ ...form, capabilities: e.target.value })} />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.contextWindow} onChange={e => setForm({ ...form, contextWindow: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Max Tokens</label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.maxTokens} onChange={e => setForm({ ...form, maxTokens: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.pricing} onChange={e => setForm({ ...form, pricing: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> Emoji</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.icon} onChange={e => setForm({ ...form, icon: e.target.value })} />
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFree}
onChange={e => setForm({ ...form, isFree: e.target.checked })} />
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFeatured}
onChange={e => setForm({ ...form, isFeatured: e.target.checked })} />
</label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface AiModel {
id: number;
name: string;
provider: string;
isFree: boolean;
isFeatured: boolean;
status: string;
sortOrder: number;
}
export default function AdminModels() {
const router = useRouter();
const [items, setItems] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models?pageSize=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleFeatured(id: number, current: boolean) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ isFeatured: !current }),
});
load();
} catch { toast.error('操作失败'); }
}
async function del(id: number) {
if (!confirm('确定删除此模型?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/models/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
load();
} catch { toast.error('删除失败'); }
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"> AI </p>
</div>
<Link href="/admin/models/new"
className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90">
</Link>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium">{item.name}</td>
<td className="px-4 py-3 text-muted-foreground">{item.provider}</td>
<td className="px-4 py-3">{item.isFree ? '✅' : '💰'}</td>
<td className="px-4 py-3">
<button onClick={() => toggleFeatured(item.id, item.isFeatured)}
className={`text-xs px-2 py-0.5 rounded ${item.isFeatured ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'}`}>
{item.isFeatured ? '推荐' : '普通'}
</button>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded ${
item.status === 'ACTIVE' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}>{item.status}</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.sortOrder}</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/models/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Banner {
id: number;
@@ -37,7 +38,7 @@ export default function BannersPage() {
}
async function createBanner() {
if (!form.title || !form.image) return alert('请填写标题和图片');
if (!form.title || !form.image) return toast.error('请填写标题和图片');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/banners`, {
method: 'POST',
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Notification {
id: number;
@@ -37,7 +38,7 @@ export default function NotificationsPage() {
}
async function sendNotification() {
if (!form.title || !form.content) return alert('请填写标题和内容');
if (!form.title || !form.content) return toast.error('请填写标题和内容');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/notifications`, {
method: 'POST',
+11 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Order {
id: number;
@@ -80,15 +81,15 @@ export default function AdminOrders() {
body: JSON.stringify({ amount: order.amount, reason: refundModal.reason }),
});
if (res.ok) {
alert('退款成功');
toast.success('退款成功');
setRefundModal({ open: false, order: null, reason: '', loading: false });
loadOrders();
} else {
const err = await res.json();
alert(err.message || '退款失败');
toast.error(err.message || '退款失败');
}
} catch {
alert('退款失败,请重试');
toast.error('退款失败,请重试');
}
}
@@ -101,16 +102,16 @@ export default function AdminOrders() {
if (res.ok) {
const data = await res.json();
if (data.updated) {
alert('订单状态已同步更新为已支付');
toast.success('订单状态已同步更新为已支付');
} else {
alert(`状态同步完成:当前状态 ${data.status}`);
toast.info(`状态同步完成:当前状态 ${data.status}`);
}
loadOrders();
} else {
const err = await res.json();
alert(err.message || '同步失败');
toast.error(err.message || '同步失败');
}
} catch { alert('同步失败,请重试'); }
} catch { toast.error('同步失败,请重试'); }
}
async function handleMarkPaid(orderNo: string) {
@@ -121,14 +122,14 @@ export default function AdminOrders() {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
alert('已标记为已支付');
toast.success('已标记为已支付');
loadOrders();
} else {
const err = await res.json();
alert(err.message || '操作失败');
toast.error(err.message || '操作失败');
}
} catch {
alert('操作失败');
toast.error('操作失败');
}
}
+3
View File
@@ -24,6 +24,9 @@ const links = [
{ href: '/admin/tools', title: '工具管理', desc: '管理AI工具库', color: 'from-cyan-500 to-cyan-600' },
{ href: '/admin/orders', title: '订单管理', desc: '查看支付订单', color: 'from-rose-500 to-rose-600' },
{ href: '/admin/enterprise', title: '企业版管理', desc: '管理组织、成员和学习报告', color: 'from-indigo-500 to-indigo-600' },
{ href: '/admin/practices', title: '练习管理', desc: '管理练习题库', color: 'from-teal-500 to-teal-600' },
{ href: '/admin/posts', title: '帖子管理', desc: '管理社区帖子', color: 'from-sky-500 to-sky-600' },
{ href: '/admin/models', title: '模型管理', desc: '管理AI模型', color: 'from-violet-500 to-violet-600' },
{ href: '/admin/comments', title: '评论审核', desc: '审核社区评论', color: 'from-pink-500 to-pink-600' },
{ href: '/admin/operations/banners', title: 'Banner管理', desc: '管理首页横幅', color: 'from-emerald-500 to-emerald-600' },
{ href: '/admin/operations/notifications', title: '推送管理', desc: '系统推送通知', color: 'from-red-500 to-red-600' },
@@ -0,0 +1,90 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function AdminPostDetail() {
const params = useParams();
const router = useRouter();
const [post, setPost] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/posts/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) setPost(await res.json());
} catch {}
setLoading(false);
}
async function updateStatus(status: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${params.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
});
toast.success('状态已更新');
load();
} catch { toast.error('操作失败'); }
}
async function del() {
if (!confirm('确定删除此帖子?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${params.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
router.push('/admin/posts');
} catch { toast.error('删除失败'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-40 w-full" /></div>;
if (!post) return <div className="p-6 text-muted-foreground"></div>;
return (
<div className="p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex gap-2">
{post.status === 'PUBLISHED' ? (
<button onClick={() => updateStatus('HIDDEN')}
className="px-3 py-1.5 rounded-lg bg-orange-500 text-white text-sm hover:bg-orange-600"></button>
) : (
<button onClick={() => updateStatus('PUBLISHED')}
className="px-3 py-1.5 rounded-lg bg-green-500 text-white text-sm hover:bg-green-600"></button>
)}
<button onClick={del}
className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600"></button>
</div>
</div>
<div className="bg-card rounded-xl border border-border p-6 space-y-4">
<div>
<div className="text-xs text-muted-foreground mb-1">
{post.user?.nickname || '匿名'} ·
{new Date(post.createdAt).toLocaleString('zh-CN')} ·
👁 {post.viewCount} · 💬 {post._count?.comments || 0} · {post._count?.likes || 0}
</div>
<h2 className="text-xl font-bold text-foreground">{post.title}</h2>
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{post.content}</div>
</div>
<button onClick={() => router.back()}
className="mt-4 text-sm text-muted-foreground hover:text-foreground">&larr; </button>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/posts?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Post {
id: number;
title: string;
status: string;
viewCount: number;
createdAt: string;
user: { id: number; nickname: string };
_count: { comments: number; likes: number };
}
export default function AdminPosts() {
const [items, setItems] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => { load(); }, [statusFilter]);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const params = new URLSearchParams({ pageSize: '100' });
if (statusFilter) params.set('status', statusFilter);
const res = await fetch(`${API_BASE}/admin/posts?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function updateStatus(id: number, status: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
});
toast.success('状态已更新');
load();
} catch { toast.error('操作失败'); }
}
async function del(id: number) {
if (!confirm('确定删除此帖子?此操作不可恢复。')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
load();
} catch { toast.error('删除失败'); }
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="p-6">
<div className="flex gap-2 mb-4">
{['', 'PUBLISHED', 'HIDDEN'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === s ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground'
}`}>
{s || '全部'}
</button>
))}
</div>
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium max-w-xs truncate">{item.title}</td>
<td className="px-4 py-3 text-muted-foreground">{item.user?.nickname || '匿名'}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
item.status === 'PUBLISHED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
item.status === 'HIDDEN' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}>{item.status}</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
💬 {item._count?.comments || 0} · {item._count?.likes || 0} · 👁 {item.viewCount}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/posts/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
{item.status === 'PUBLISHED' ? (
<button onClick={() => updateStatus(item.id, 'HIDDEN')}
className="text-xs text-orange-600 hover:text-orange-700 dark:text-orange-400"></button>
) : (
<button onClick={() => updateStatus(item.id, 'PUBLISHED')}
className="text-xs text-green-600 hover:text-green-700 dark:text-green-400"></button>
)}
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function EditPractice() {
const router = useRouter();
const params = useParams();
const [form, setForm] = useState({ title: '', description: '', difficulty: 'BEGINNER', category: '', sortOrder: 0, isActive: true });
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setForm({ title: data.title, description: data.description || '', difficulty: data.difficulty || 'BEGINNER', category: data.category || '', sortOrder: data.sortOrder || 0, isActive: data.isActive ?? true });
}
} catch {}
setLoading(false);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) {
toast.success('更新成功');
router.push('/admin/practices');
} else {
const err = await res.json();
toast.error(err.message || '更新失败');
}
} catch { toast.error('网络错误'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={4}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.difficulty} onChange={e => setForm({ ...form, difficulty: e.target.value })}>
<option value="BEGINNER"></option>
<option value="INTERMEDIATE"></option>
<option value="ADVANCED"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="isActive" checked={form.isActive}
onChange={e => setForm({ ...form, isActive: e.target.checked })} />
<label htmlFor="isActive" className="text-sm text-foreground"></label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/practices?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
export default function NewPractice() {
const router = useRouter();
const [form, setForm] = useState({ title: '', description: '', difficulty: 'BEGINNER', category: '', sortOrder: 0 });
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) {
toast.success('创建成功');
router.push('/admin/practices');
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch {
toast.error('网络错误');
}
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={4}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.difficulty} onChange={e => setForm({ ...form, difficulty: e.target.value })}>
<option value="BEGINNER"></option>
<option value="INTERMEDIATE"></option>
<option value="ADVANCED"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
interface Practice {
id: number;
title: string;
description?: string;
difficulty: string;
category?: string;
sortOrder: number;
isActive: boolean;
}
export default function AdminPractices() {
const router = useRouter();
const [items, setItems] = useState<Practice[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices?pageSize=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleActive(id: number, current: boolean) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/practices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ isActive: !current }),
});
load();
} catch {}
}
async function del(id: number) {
if (!confirm('确定删除此练习?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/practices/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
load();
} catch {}
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<Link href="/admin/practices/new"
className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90">
</Link>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium">{item.title}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
item.difficulty === 'BEGINNER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
item.difficulty === 'INTERMEDIATE' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{item.difficulty}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.category || '-'}</td>
<td className="px-4 py-3 text-muted-foreground">{item.sortOrder}</td>
<td className="px-4 py-3">
<button onClick={() => toggleActive(item.id, item.isActive)}
className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'}`}>
{item.isActive ? '启用' : '禁用'}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/practices/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import * as Dialog from '@/components/ui/dialog';
import { toast } from 'sonner';
interface Role {
id: string;
@@ -53,13 +54,13 @@ export default function SettingsRolesPage() {
]);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
if (permsRes.ok) setPermissions((await permsRes.json()).items || []);
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
const categories = [...new Set(permissions.map(p => p.category))];
const categories = Array.from(new Set(permissions.map(p => p.category)));
if (loading) {
return <div className="p-6 text-center text-muted-foreground">...</div>;
@@ -195,7 +196,7 @@ function RoleDialog({ role, permissions, categories, open, onOpenChange, onSaved
});
}
onSaved();
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setSaving(false);
}
@@ -276,7 +277,7 @@ function AdminsTab({ onReload }: { onReload: () => void }) {
]);
if (adminsRes.ok) setAdmins((await adminsRes.json()).items || []);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setLoading(false);
}, []);
@@ -379,7 +380,7 @@ function AdminDialog({ admin, roles, open, onOpenChange, onSaved }: {
});
}
onSaved();
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setSaving(false);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface User {
id: number;
@@ -40,7 +41,7 @@ export default function UsersPage() {
}
async function createUser() {
if (!form.phone || !form.password) return alert('手机号和密码必填');
if (!form.phone || !form.password) return toast.error('手机号和密码必填');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/users`, {
method: 'POST',
+9 -6
View File
@@ -4,7 +4,9 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { getToken } from '@/lib/auth';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Post {
id: number;
@@ -32,7 +34,7 @@ export default function CircleDetail() {
async function loadData() {
try {
const token = localStorage.getItem('token');
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -56,7 +58,7 @@ export default function CircleDetail() {
setIsMember(memData.isMember);
}
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -64,21 +66,20 @@ export default function CircleDetail() {
const token = localStorage.getItem('token');
if (!token) return;
const method = isMember ? 'POST' : 'POST';
const url = isMember
? `${API_BASE}/circles/${circleId}/leave`
: `${API_BASE}/circles/${circleId}/join`;
try {
const res = await fetch(url, {
method,
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (res.ok) {
setIsMember(!isMember);
loadData();
}
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function handleCreatePost() {
@@ -96,8 +97,10 @@ export default function CircleDetail() {
setFormContent('');
setShowCreateForm(false);
loadData();
} else {
toast.error("发帖失败");
}
} catch (e) { console.error(e) }
} catch { toast.error("发帖失败") }
}
if (loading) return (
+9 -6
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
import { useT } from '@/i18n';
interface Circle {
id: number;
@@ -15,6 +17,7 @@ interface Circle {
}
export default function CirclesPage() {
const t = useT();
const [circles, setCircles] = useState<Circle[]>([]);
const [loading, setLoading] = useState(true);
@@ -27,7 +30,7 @@ export default function CirclesPage() {
const data = await res.json();
setCircles(data || []);
}
} catch (e) { console.error(e) }
} catch { toast.error("加载圈子失败") }
setLoading(false);
}
@@ -43,9 +46,9 @@ export default function CirclesPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; {t.circles.back}</Link>
<h1 className="text-3xl font-bold text-foreground">{t.circles.title}</h1>
<p className="mt-2 text-muted-foreground">{t.circles.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -60,14 +63,14 @@ export default function CirclesPage() {
<span key={tag} className="text-xs px-2 py-0.5 bg-muted rounded">{tag.trim()}</span>
))}
</div>
<span className="text-xs text-muted-foreground">{circle._count?.members || 0} · {circle._count?.posts || 0} </span>
<span className="text-xs text-muted-foreground">{circle._count?.members || 0} {t.circles.members} · {circle._count?.posts || 0} {t.circles.posts}</span>
</div>
</Link>
))}
</div>
{circles.length === 0 && !loading && (
<div className="text-center py-20 text-muted-foreground"><p></p></div>
<div className="text-center py-20 text-muted-foreground"><p>{t.circles.empty}</p></div>
)}
</div>
);
+4 -3
View File
@@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { toast } from 'sonner';
interface PostDetail {
id: number; title: string; content: string; tags?: string;
@@ -37,7 +38,7 @@ export default function CommunityPostDetail() {
try {
const res = await apiFetch(`/community/posts/${params.id}`);
if (res.ok) setPost(await res.json());
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -51,7 +52,7 @@ export default function CommunityPostDetail() {
body: JSON.stringify({ content: commentText }),
});
if (res.ok) { setCommentText(''); loadPost(); }
} catch (e) { console.error(e) }
} catch { toast.error("评论失败") }
setSubmitting(false);
}
@@ -59,7 +60,7 @@ export default function CommunityPostDetail() {
try {
await apiFetch(`/community/posts/${params.id}/like`, { method: 'POST' });
loadPost();
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
if (loading) return (
+9 -8
View File
@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../lib/auth";
import { apiFetch, getToken } from "../../lib/auth";
import { useAuth } from "@/lib/auth-context";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Post { id: number; title: string; content: string; tags?: string | null; viewCount: number; likeCount: number; commentCount: number; createdAt: string; user: { id: number; nickname: string; avatar: string | null }; comments?: Comment[] }
interface Comment { id: number; content: string; createdAt: string; user: { id: number; nickname: string; avatar: string | null } }
@@ -29,12 +30,12 @@ export default function CommunityPage() {
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('token');
const token = getToken();
const url = activeTab === 'feed' && token ? '/community/feed' : '/community/posts';
const res = await apiFetch(url);
const data = await res.json();
setPosts(data.items || []);
} catch (e) { console.error(e) }
} catch { toast.error("数据加载失败") }
setLoading(false);
}, [activeTab]);
@@ -50,7 +51,7 @@ export default function CommunityPage() {
try {
const res = await apiFetch(`/community/users/${post.user.id}/follow`);
if (res.ok) { const d = await res.json(); if (d.followed) followed.add(post.user.id); }
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
}
setFollowedUsers(followed);
@@ -64,12 +65,12 @@ export default function CommunityPage() {
await apiFetch("/community/posts", { method: "POST", body: JSON.stringify({ title, content, tags: tags || undefined }) });
setTitle(""); setContent(""); setTags(""); setShowForm(false);
loadPosts();
} catch (e) { console.error(e) }
} catch { toast.error("发帖失败") }
setSubmitting(false);
}
async function handleLike(postId: number) {
try { await apiFetch(`/community/posts/${postId}/like`, { method: "POST" }); loadPosts(); } catch (e) { console.error(e) }
try { await apiFetch(`/community/posts/${postId}/like`, { method: "POST" }); loadPosts(); } catch { toast.error("操作失败") }
}
async function handleFollow(userId: number) {
@@ -78,7 +79,7 @@ export default function CommunityPage() {
const isFollowed = followedUsers.has(userId);
await apiFetch(`/community/users/${userId}/follow`, { method: isFollowed ? 'DELETE' : 'POST' });
setFollowedUsers(prev => { const next = new Set(prev); isFollowed ? next.delete(userId) : next.add(userId); return next; });
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
function PostCard({ post }: { post: Post }) {
@@ -93,7 +94,7 @@ export default function CommunityPage() {
try {
await apiFetch(`/community/posts/${post.id}/comments`, { method: "POST", body: JSON.stringify({ content: comment }) });
setComment(""); loadPosts();
} catch (e) { console.error(e) }
} catch { toast.error("评论失败") }
setSubmittingComment(false);
}
+2 -10
View File
@@ -81,7 +81,7 @@ export default function CourseDetailClient() {
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-2xl p-8 max-w-md w-full text-center">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
{qrUrl && qrUrl !== 'mock://pay' ? (
{qrUrl ? (
<>
<div className="bg-muted/50 rounded-xl p-6 mb-4 inline-block">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`} alt="支付二维码" />
@@ -89,7 +89,7 @@ export default function CourseDetailClient() {
<p className="text-sm text-muted-foreground mb-4">使</p>
</>
) : (
<p className="text-muted-foreground mb-4"></p>
<p className="text-sm text-muted-foreground mb-4">...</p>
)}
<div className="flex gap-3 justify-center">
<button
@@ -98,14 +98,6 @@ export default function CourseDetailClient() {
>
</button>
{qrUrl === 'mock://pay' && (
<button
onClick={() => { setPaying(false); setQrCodeUrl(''); alert('模拟支付成功!'); }}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"
>
</button>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -1,10 +1,10 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
interface DailyItem {
id: number;
@@ -41,7 +41,7 @@ export default function DailyPage() {
];
setItems(items);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+2 -2
View File
@@ -1,10 +1,10 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
interface HotItem {
id: number;
@@ -45,7 +45,7 @@ export default function HotPage() {
}).slice(0, 20);
setItems(items);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface HotItem { id: number; title: string; viewCount: number; likeCount: number; _type: 'course' | 'prompt' | 'post' }
@@ -25,7 +26,7 @@ export default function DiscoverPage() {
setHotCourses((await coursesRes.json()).items?.map((c: any) => ({ ...c, _type: 'course' as const })) || []);
setHotPrompts((await promptsRes.json()).items?.map((p: any) => ({ ...p, _type: 'prompt' as const })) || []);
setHotPosts((await postsRes.json()).items?.map((p: any) => ({ ...p, _type: 'post' as const })) || []);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+6 -4
View File
@@ -1,10 +1,11 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
import { useT } from '@/i18n';
interface Tool {
id: number;
@@ -16,6 +17,7 @@ interface Tool {
}
export default function ToolsRecommendPage() {
const t = useT();
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
@@ -28,7 +30,7 @@ export default function ToolsRecommendPage() {
const res = await apiFetch('/tools?pageSize=10');
const data = await res.json();
setTools(data.items || []);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -49,8 +51,8 @@ export default function ToolsRecommendPage() {
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground">AI工具</p>
<h1 className="text-3xl font-bold text-foreground">{t.discover.toolsTitle}</h1>
<p className="mt-2 text-muted-foreground">{t.discover.toolsDesc}</p>
</div>
<div className="grid gap-4">
@@ -0,0 +1,342 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth';
import { Building2, Users, BarChart3, Plus, X, LogIn } from 'lucide-react';
import { toast } from 'sonner';
interface Org {
id: number;
name: string;
description: string | null;
contactName: string | null;
memberCount: number;
status: string;
createdAt: string;
members?: OrgMember[];
_count?: { members: number; assignments: number };
}
interface OrgMember {
id: number;
userId: number;
role: string;
user: { id: number; nickname: string | null; email: string | null; phone: string | null; avatar: string | null };
}
interface OrgReport {
organization: Org;
totalMembers: number;
totalCourses: number;
completedLessons: number;
completionRate: number;
memberProgress: { userId: number; nickname: string; completed: number }[];
}
export default function EnterpriseDashboardPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [orgs, setOrgs] = useState<Org[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<Org | null>(null);
const [orgMembers, setOrgMembers] = useState<OrgMember[]>([]);
const [orgReport, setOrgReport] = useState<OrgReport | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const [newOrgDesc, setNewOrgDesc] = useState('');
const [creating, setCreating] = useState(false);
const [inviteUserId, setInviteUserId] = useState('');
const [inviting, setInviting] = useState(false);
const [activeTab, setActiveTab] = useState<'members' | 'report'>('members');
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return; }
loadOrgs();
}, [isLoggedIn]);
async function loadOrgs() {
try {
const res = await apiFetch('/enterprise/my');
if (res.ok) {
const data = await res.json();
setOrgs(data || []);
}
} catch { /* ignore */ }
setLoading(false);
}
async function selectOrg(org: Org) {
setSelectedOrg(org);
setActiveTab('members');
try {
const [detailRes, reportRes] = await Promise.all([
apiFetch(`/enterprise/organizations/${org.id}`),
apiFetch(`/enterprise/organizations/${org.id}/report`),
]);
if (detailRes.ok) {
const data = await detailRes.json();
setOrgMembers(data.members || []);
}
if (reportRes.ok) {
const data = await reportRes.json();
setOrgReport(data);
}
} catch { toast.error("加载组织信息失败") }
}
async function createOrg() {
if (!newOrgName.trim()) return;
setCreating(true);
try {
const res = await apiFetch('/enterprise/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newOrgName.trim(), description: newOrgDesc.trim() || undefined }),
});
if (res.ok) {
setShowCreateForm(false);
setNewOrgName('');
setNewOrgDesc('');
loadOrgs();
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch { toast.error("创建组织失败") }
setCreating(false);
}
async function inviteMember() {
if (!inviteUserId.trim() || !selectedOrg) return;
setInviting(true);
try {
const res = await apiFetch(`/enterprise/organizations/${selectedOrg.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: parseInt(inviteUserId) }),
});
if (res.ok) {
setInviteUserId('');
selectOrg(selectedOrg);
} else {
const err = await res.json();
toast.error(err.message || '邀请失败');
}
} catch { toast.error("邀请失败") }
setInviting(false);
}
async function removeMember(userId: number) {
if (!selectedOrg || !confirm(t.enterprise.removeConfirm)) return;
try {
await apiFetch(`/enterprise/organizations/${selectedOrg.id}/members/${userId}`, {
method: 'DELETE',
});
selectOrg(selectedOrg);
} catch { toast.error("移除成员失败") }
}
if (!isLoggedIn) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<Building2 className="h-12 w-12 text-brand-600 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-foreground mb-2">{t.enterprise.dashboard}</h1>
<p className="text-muted-foreground mb-6">{t.enterprise.desc}</p>
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
</div>
);
}
if (loading) return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-40 rounded-2xl" />
</div>
);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">{t.enterprise.dashboard}</h1>
<p className="text-muted-foreground mt-1">{t.enterprise.desc}</p>
</div>
<Button onClick={() => setShowCreateForm(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> {t.enterprise.createOrg}
</Button>
</div>
{showCreateForm && (
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-foreground">{t.enterprise.createOrg}</h2>
<button onClick={() => setShowCreateForm(false)}><X className="h-5 w-5 text-muted-foreground" /></button>
</div>
<div className="space-y-3">
<Input value={newOrgName} onChange={e => setNewOrgName(e.target.value)}
placeholder={t.enterprise.orgName} />
<Input value={newOrgDesc} onChange={e => setNewOrgDesc(e.target.value)}
placeholder={t.enterprise.orgDesc} />
<Button onClick={createOrg} disabled={creating || !newOrgName.trim()}>
{creating ? t.common.loading : t.enterprise.createOrg}
</Button>
</div>
</div>
)}
{orgs.length === 0 ? (
<div className="bg-card rounded-2xl border border-border p-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">{t.enterprise.noOrg}</h2>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.createOrgHint}</p>
<Button onClick={() => setShowCreateForm(true)}>{t.enterprise.createOrg}</Button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-3">
<h2 className="font-semibold text-foreground mb-2">{t.enterprise.myTeam}</h2>
{orgs.map(org => (
<button key={org.id} onClick={() => selectOrg(org)}
className={`w-full text-left bg-card rounded-xl border p-4 transition-all hover:shadow-sm ${
selectedOrg?.id === org.id ? 'border-brand-600' : 'border-border'
}`}>
<div className="font-medium text-foreground text-sm">{org.name}</div>
<div className="text-xs text-muted-foreground mt-1">
{t.enterprise.memberCount.replace('{n}', String(org.memberCount || org._count?.members || 0))}
</div>
</button>
))}
</div>
<div className="lg:col-span-2">
{selectedOrg ? (
<div>
<div className="flex items-center gap-2 mb-4">
<button onClick={() => setActiveTab('members')}
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
activeTab === 'members' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border'
}`}>
<Users className="h-3.5 w-3.5 inline mr-1" />{t.enterprise.members}
</button>
<button onClick={() => setActiveTab('report')}
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
activeTab === 'report' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border'
}`}>
<BarChart3 className="h-3.5 w-3.5 inline mr-1" />{t.enterprise.usageReport}
</button>
</div>
{activeTab === 'members' && (
<div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground">{t.enterprise.members} ({orgMembers.length})</h3>
</div>
<div className="flex gap-2 mb-4">
<Input value={inviteUserId} onChange={e => setInviteUserId(e.target.value)}
placeholder={t.enterprise.memberId} className="w-40" type="number" />
<Button onClick={inviteMember} disabled={inviting || !inviteUserId.trim()} size="sm">
{inviting ? t.common.loading : t.enterprise.invite}
</Button>
</div>
{orgMembers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">{t.enterprise.noMembers}</p>
) : (
<div className="space-y-2">
{orgMembers.map(m => (
<div key={m.id} className="flex items-center justify-between p-3 bg-muted/30 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center text-xs font-medium text-foreground">
{m.user.nickname?.[0] || '?'}
</div>
<div>
<div className="text-sm font-medium text-foreground">{m.user.nickname || `用户 ${m.userId}`}</div>
<div className="text-xs text-muted-foreground">
{m.user.email || m.user.phone || `#${m.userId}`}
{m.role === 'ADMIN' && <span className="ml-2 text-brand-600"></span>}
</div>
</div>
</div>
{m.role !== 'ADMIN' && (
<button onClick={() => removeMember(m.userId)}
className="text-xs text-red-500 hover:text-red-700"></button>
)}
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'report' && orgReport && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{[
{ label: t.enterprise.totalMembers, value: orgReport.totalMembers },
{ label: t.enterprise.totalUsage, value: orgReport.completedLessons },
{ label: t.enterprise.completionRate, value: `${Math.round(orgReport.completionRate)}%` },
].map((s, i) => (
<div key={i} className="bg-card rounded-xl border border-border p-4 text-center">
<div className="text-2xl font-bold text-foreground">{s.value}</div>
<div className="text-xs text-muted-foreground mt-1">{s.label}</div>
</div>
))}
</div>
{orgReport.memberProgress.length > 0 && (
<div className="bg-card rounded-2xl border border-border p-6">
<h3 className="font-semibold text-foreground mb-3">{t.enterprise.members} </h3>
<div className="space-y-3">
{orgReport.memberProgress.map((mp, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-sm text-foreground w-24 truncate">{mp.nickname || `用户 ${mp.userId}`}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div className="bg-brand-600 h-2 rounded-full" style={{ width: `${Math.min(100, mp.completed * 10)}%` }} />
</div>
<span className="text-xs text-muted-foreground">{mp.completed} </span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="bg-card rounded-2xl border border-border p-12 text-center">
<p className="text-muted-foreground">{t.enterprise.selectOrg || '选择一个团队查看详情'}</p>
</div>
)}
</div>
</div>
)}
<div className="mt-12 bg-card rounded-2xl border border-border p-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-xl font-bold text-foreground mb-3">{t.enterprise.pricingTitle}</h2>
<p className="text-muted-foreground mb-6">{t.enterprise.pricingDesc}</p>
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-foreground">{t.enterprise.pricingBizPrice}</div>
<div className="text-xs text-muted-foreground">{t.enterprise.pricingBizPerUser}</div>
</div>
<div className="h-12 w-px bg-border" />
<div className="text-left text-sm text-muted-foreground">
<div> {t.enterprise.pricingBizFeature1}</div>
<div> {t.enterprise.pricingBizFeature3}</div>
<div> {t.enterprise.pricingBizFeature4}</div>
</div>
</div>
</div>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import Link from 'next/link';
import { useT } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Shield, Users, BarChart3, Cloud, ClipboardCheck, Target } from 'lucide-react';
const features = [
{ icon: Shield, key: 'featureIsolation' },
{ icon: Users, key: 'featureManage' },
{ icon: BarChart3, key: 'featureAnalytics' },
{ icon: Cloud, key: 'featureSandbox' },
{ icon: ClipboardCheck, key: 'featureReport' },
{ icon: Target, key: 'featureCustom' },
];
const faqs = ['faq1', 'faq2', 'faq3', 'faq4', 'faq5'];
export default function EnterprisePage() {
const t = useT();
return (
<div>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-28">
<div className="text-center max-w-3xl mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/10 border border-brand-200 dark:border-brand-800 rounded-full text-sm text-brand-600 mb-6">
<Shield className="h-4 w-4" />
{t.enterprise.title}
</div>
<h1 className="text-4xl md:text-5xl font-bold text-foreground leading-tight mb-6">
{t.enterprise.heroTitle}
</h1>
<p className="text-lg text-muted-foreground mb-8 leading-relaxed">
{t.enterprise.heroDesc}
</p>
<div className="flex items-center justify-center gap-4">
<Button size="lg" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.heroCta}</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/practices">{t.enterprise.learnMore}</Link>
</Button>
</div>
<p className="text-xs text-muted-foreground mt-3">{t.enterprise.heroCtaSub}</p>
</div>
</section>
<section className="bg-muted/30 border-y border-border py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.problemTitle}</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">{t.enterprise.problemDesc}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ stat: 'statShadowAi', desc: 'statShadowAiDesc' },
{ stat: 'statDataLeak', desc: 'statDataLeakDesc' },
{ stat: 'statIpLeak', desc: 'statIpLeakDesc' },
{ stat: 'statTrainingGap', desc: 'statTrainingGapDesc' },
].map((s, i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-6 text-center">
<div className="text-3xl font-bold text-brand-600 mb-1">{t.enterprise[s.stat as keyof typeof t.enterprise] as string}</div>
<div className="text-xs text-muted-foreground">{t.enterprise[s.desc as keyof typeof t.enterprise] as string}</div>
</div>
))}
</div>
</div>
</section>
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.solutionTitle}</h2>
<p className="text-muted-foreground">{t.enterprise.solutionDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.map((feat, i) => {
const Icon = feat.icon;
return (
<div key={i} className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all">
<div className="w-10 h-10 bg-brand-600/10 rounded-xl flex items-center justify-center mb-4">
<Icon className="h-5 w-5 text-brand-600" />
</div>
<h3 className="font-semibold text-foreground mb-2">{t.enterprise[feat.key as keyof typeof t.enterprise]}</h3>
<p className="text-sm text-muted-foreground">{t.enterprise[(feat.key + 'Desc') as keyof typeof t.enterprise] as string}</p>
</div>
);
})}
</div>
</div>
</section>
<section className="bg-muted/30 border-y border-border py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.pricingTitle}</h2>
<p className="text-muted-foreground">{t.enterprise.pricingDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border p-8">
<h3 className="text-lg font-semibold text-foreground mb-1">{t.enterprise.pricingFree}</h3>
<div className="text-3xl font-bold text-foreground mb-1">{t.enterprise.pricingFreePrice}</div>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.pricingFreeDesc}</p>
<ul className="space-y-2 mb-8">
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature1}</li>
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature2}</li>
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature3}</li>
</ul>
<Button variant="outline" className="w-full" asChild>
<Link href="/practices">{t.enterprise.pricingCtaFree}</Link>
</Button>
</div>
<div className="bg-card rounded-2xl border-2 border-brand-600 p-8 relative">
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-medium px-3 py-0.5 rounded-full">{t.packages.popular}</span>
<h3 className="text-lg font-semibold text-foreground mb-1">{t.enterprise.pricingBiz}</h3>
<div className="flex items-baseline gap-1 mb-1">
<span className="text-3xl font-bold text-foreground">{t.enterprise.pricingBizPrice}</span>
<span className="text-sm text-muted-foreground">{t.enterprise.pricingBizPerUser}</span>
</div>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.pricingBizDesc}</p>
<ul className="space-y-2 mb-8">
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature1}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature2}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature3}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature4}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature5}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature6}</li>
</ul>
<Button className="w-full" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.pricingCta}</Link>
</Button>
</div>
</div>
</div>
</section>
<section className="py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-foreground text-center mb-12">{t.enterprise.faqTitle}</h2>
<div className="space-y-4">
{faqs.map((faq, i) => (
<details key={i} className="bg-card rounded-2xl border border-border p-4 group">
<summary className="text-sm font-medium text-foreground cursor-pointer list-none flex items-center justify-between">
{t.enterprise[(faq + 'q') as keyof typeof t.enterprise]}
<span className="text-muted-foreground group-open:rotate-180 transition-transform"></span>
</summary>
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t border-border">{t.enterprise[(faq + 'a') as keyof typeof t.enterprise]}</p>
</details>
))}
</div>
</div>
</section>
<section className="bg-brand-600 py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-2xl font-bold text-white mb-4">{t.enterprise.heroTitle}</h2>
<p className="text-brand-100 mb-8">{t.enterprise.heroDesc}</p>
<Button size="lg" variant="secondary" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.heroCta}</Link>
</Button>
</div>
</section>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
interface MarketplaceSkill {
id: string; name: string; description: string; icon: string;
category: string; difficulty: string; tags: string[];
price: number | null; purchased: boolean; sortOrder: number;
}
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
export default function MarketplacePage() {
const t = useT();
const router = useRouter();
const { isLoggedIn } = useAuth();
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<FilterMode>('all');
const [payLoading, setPayLoading] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
useEffect(() => {
const url = `${API_BASE}/skills/marketplace`;
fetch(url, { credentials: 'include' })
.then(r => r.json())
.then(data => {
setSkills(Array.isArray(data) ? data : []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = skills.filter(s => {
if (filter === 'free') return !s.price;
if (filter === 'premium') return !!s.price;
if (filter === 'purchased') return s.purchased;
return true;
});
async function handleBuy(skill: MarketplaceSkill) {
if (!isLoggedIn) { router.push('/auth'); return; }
setPayLoading(skill.id);
setSuccess(null);
try {
const res = await apiFetch('/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: skill.price,
planType: 'SKILL',
skillId: skill.id,
payChannel: 'alipay',
}),
});
const data = await res.json();
if (data.order && data.payResult) {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if (payUrl || qrCode) {
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel: 'alipay' });
}
}
} catch (e) {
console.error(e);
}
setPayLoading(null);
}
function handlePaymentPaid() {
setPaymentModal(prev => ({ ...prev, open: false }));
setSuccess(t.marketplace.purchaseSuccess);
}
const filters: { key: FilterMode; label: string }[] = [
{ key: 'all', label: t.common.viewAll },
{ key: 'free', label: t.marketplace.free },
{ key: 'premium', label: t.marketplace.locked },
{ key: 'purchased', label: t.marketplace.purchased },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
</div>
<div className="flex flex-wrap gap-2 mb-8">
{filters.map(f => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === f.key
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
</div>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-52 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map(skill => {
const isPremium = !!skill.price;
const isOwned = skill.purchased;
return (
<div key={skill.id}
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
}`}
>
{isPremium && !isOwned && (
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
<Sparkles className="h-3.5 w-3.5" />
<span>{t.marketplace.locked}</span>
</div>
)}
<div className="flex items-start gap-3 mb-3">
<span className="text-3xl">{skill.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{skill.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{(t.skills as any)[skill.difficulty]}
</span>
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
))}
</div>
<div className="mt-auto flex items-center gap-2">
{isOwned ? (
<>
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3.5 w-3.5" />
{t.marketplace.purchased}
</Badge>
<Button asChild size="sm" variant="default" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>
{t.skills.apply}
</Link>
</Button>
</>
) : isPremium ? (
<>
<span className="text-lg font-bold text-foreground">¥{skill.price}</span>
<Button
size="sm"
onClick={() => handleBuy(skill)}
disabled={payLoading === skill.id}
className="ml-auto"
>
{payLoading === skill.id ? (
<span className="flex items-center gap-1">
<span className="animate-spin h-3 w-3 border-2 border-current border-t-transparent rounded-full" />
{t.marketplace.buying}
</span>
) : (
<span className="flex items-center gap-1">
<ShoppingBag className="h-3.5 w-3.5" />
{t.marketplace.buy.replace('{price}', String(skill.price))}
</span>
)}
</Button>
</>
) : (
<Button asChild size="sm" variant="outline" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>
{t.skills.apply}
</Link>
</Button>
)}
</div>
</div>
);
})}
</div>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-20">
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">{t.common.noData}</p>
</div>
)}
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</div>
);
}
+33 -85
View File
@@ -1,29 +1,22 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import Link from 'next/link';
import { MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag } from 'lucide-react';
import { useT } from '@/i18n';
interface AiModel { id: number; name: string; provider: string; description: string | null; capabilities: string | null; contextWindow: number | null; maxTokens: number | null; pricing: string | null; isFree: boolean; isFeatured: boolean; icon: string | null }
const icons = [MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag];
export default function ModelsPage() {
const t = useT();
const [models, setModels] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/models`).then(r => r.json()).then(setModels).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12"><Skeleton className="h-9 w-48 mx-auto mb-3" /><Skeleton className="h-5 w-96 mx-auto" /></div>
<div className="space-y-3">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}</div>
</div>
);
}
const capabilities = [
{ key: 'capChat', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capCode', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capData', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capPrompt', link: '/prompts', cta: t.models.goPrompts },
{ key: 'capPractice', link: '/practices', cta: t.models.goPractice },
{ key: 'capMarketplace', link: '/marketplace', cta: t.models.goMarketplace },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
@@ -32,74 +25,29 @@ export default function ModelsPage() {
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.models.desc}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-4 px-4 font-semibold text-foreground">{t.models.tableName}</th>
<th className="text-left py-4 px-4 font-semibold text-foreground">{t.models.tableProvider}</th>
<th className="text-left py-4 px-4 font-semibold text-foreground hidden md:table-cell">{t.models.tableCapabilities}</th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell">{t.models.tableContext}</th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell">{t.models.tableMaxOutput}</th>
<th className="text-center py-4 px-4 font-semibold text-foreground hidden sm:table-cell">{t.models.tablePricing}</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id} className="border-b border-border hover:bg-accent/50 transition-colors">
<td className="py-4 px-4">
<div className="font-semibold text-foreground">{model.name}</div>
{model.isFree && <span className="inline-block mt-1 text-xs px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{t.models.free}</span>}
{model.isFeatured && !model.isFree && <span className="inline-block mt-1 text-xs px-1.5 py-0.5 bg-brand-100 text-brand-700 rounded">{t.models.recommended}</span>}
</td>
<td className="py-4 px-4 text-muted-foreground">{model.provider}</td>
<td className="py-4 px-4 text-muted-foreground hidden md:table-cell max-w-xs">
<div className="flex flex-wrap gap-1">
{model.capabilities?.split(',').map(cap => (
<span key={cap} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{cap.trim()}</span>
))}
</div>
</td>
<td className="py-4 px-4 text-right text-muted-foreground hidden lg:table-cell">
{model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K` : '-'}
</td>
<td className="py-4 px-4 text-right text-muted-foreground hidden lg:table-cell">
{model.maxTokens ? `${(model.maxTokens / 1024).toFixed(0)}K` : '-'}
</td>
<td className="py-4 px-4 text-center hidden sm:table-cell">
<span className={`text-xs px-2 py-1 rounded ${model.isFree ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'}`}>
{model.isFree ? t.models.pricingFree : model.pricing?.includes('免费') ? t.models.pricingMixed : t.models.pricingPaid}
</span>
</td>
</tr>
))}
</tbody>
</table>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{capabilities.map((cap, i) => {
const Icon = icons[i];
const title = t.models[cap.key as keyof typeof t.models] as string;
const desc = t.models[`${cap.key}Desc` as keyof typeof t.models] as string;
return (
<Link
key={cap.key}
href={cap.link}
className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all"
>
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{desc}</p>
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
{cap.cta}
</span>
</Link>
);
})}
</div>
{models.length > 0 && (
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{models.filter(m => m.isFeatured).map((model) => (
<div key={`card-${model.id}`} className="bg-card rounded-xl border border-border p-6 hover:border-brand-200 transition-colors">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-foreground">{model.name}</h3>
{model.isFree && <span className="text-xs px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{t.models.free}</span>}
</div>
<p className="text-sm text-muted-foreground mb-3">{model.provider}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{model.description}</p>
<div className="mt-4 flex flex-wrap gap-1">
{model.capabilities?.split(',').slice(0, 4).map(cap => (
<span key={cap} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-600 rounded">{cap.trim()}</span>
))}
</div>
<div className="mt-4 pt-4 border-t border-border grid grid-cols-2 gap-3 text-xs text-muted-foreground">
<div><span className="block text-muted-foreground">{t.models.contextWindow}</span>{model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K tokens` : '-'}</div>
<div><span className="block text-muted-foreground">{t.models.maxOutput}</span>{model.maxTokens ? `${(model.maxTokens / 1024).toFixed(0)}K tokens` : '-'}</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
interface Stats {
totalCourses: number;
@@ -27,7 +28,7 @@ export default function DashboardPage() {
const res = await apiFetch('/dashboard/stats');
const data = await res.json();
setStats(data);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface FavoritePrompt { id: number; promptId: number; prompt: { id: number; title: string; description: string; likeCount: number } }
@@ -16,12 +17,12 @@ export default function FavoritesPage() {
useEffect(() => { loadData(); }, []);
async function loadData() {
try { const res = await apiFetch('/prompts/favorites'); const data = await res.json(); setItems(data.items || []); } catch (e) { console.error(e) }
try { const res = await apiFetch('/prompts/favorites'); const data = await res.json(); setItems(data.items || []); } catch { /* ignore */ }
setLoading(false);
}
async function removeFavorite(promptId: number) {
try { await apiFetch(`/prompts/${promptId}/favorite`, { method: 'POST' }); setItems(items.filter(i => i.promptId !== promptId)); } catch (e) { console.error(e) }
try { await apiFetch(`/prompts/${promptId}/favorite`, { method: 'POST' }); setItems(items.filter(i => i.promptId !== promptId)); } catch { toast.error("操作失败") }
}
if (loading) return (
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
interface LearningItem {
courseId: number;
@@ -26,7 +27,7 @@ export default function LearningPage() {
const res = await apiFetch('/courses/my-learning');
const data = await res.json();
setItems(data.items || []);
} catch (e) { console.error(e) }
} catch { toast.error("加载失败") }
setLoading(false);
}
+10 -11
View File
@@ -6,6 +6,7 @@ import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { toast } from 'sonner';
import { isWeChatBrowser } from '@/lib/wechat';
interface PayResult {
@@ -26,10 +27,10 @@ interface Order {
}
const PLANS = [
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
];
{ id: 'FREE', nameKey: 'planFree', price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] },
{ id: 'MONTHLY', nameKey: 'planMonthly', price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] },
{ id: 'YEARLY', nameKey: 'planYearly', price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] },
] as const;
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
@@ -54,12 +55,12 @@ export default function MemberPage() {
apiFetch('/orders'),
apiFetch('/sandbox/quota'),
]);
if (subRes.ok) setSubscription(await subRes.json());
if (subRes.ok) setSubscription(await (subRes as Response).json());
const ordersData = await ordersRes.json();
setOrders(ordersData.items || []);
const quotaData = await quotaRes.json();
if (quotaData.remaining !== undefined) setQuota(quotaData);
} catch (e) { console.error(e) }
} catch { toast.error("加载失败") }
setLoading(false);
}
@@ -82,9 +83,7 @@ export default function MemberPage() {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if ((payUrl === 'mock://pay' || qrCode === 'mock://pay') || data.message === '模拟支付完成') {
loadData();
} else if (payUrl || qrCode) {
if (payUrl || qrCode) {
setPaymentModal({
open: true,
orderNo: data.order.orderNo,
@@ -93,7 +92,7 @@ export default function MemberPage() {
});
}
}
} catch (e) { console.error(e) }
} catch { toast.error("加载失败") }
setPayLoading(null);
}
@@ -146,7 +145,7 @@ export default function MemberPage() {
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
<div className="mb-4">
{plan.price > 0 ? (
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{t.member[plan.period]}</span></span>
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{plan.price > 0 ? t.member[plan.period as keyof typeof t.member] : ''}</span></span>
) : (
<span className="text-2xl font-bold text-foreground">¥0</span>
)}
+4 -3
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Profile { nickname: string; email: string; phone: string; avatar: string }
@@ -24,7 +25,7 @@ export default function SettingsPage() {
const res = await apiFetch('/dashboard/profile');
const data = await res.json();
setProfile(data); setNickname(data.nickname || ''); setEmail(data.email || '');
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -33,8 +34,8 @@ export default function SettingsPage() {
setSaving(true);
try {
await apiFetch('/dashboard/profile', { method: 'PUT', body: JSON.stringify({ nickname, email }) });
alert(t.settings.saveSuccess);
} catch (e) { console.error(e) }
toast.success(t.settings.saveSuccess);
} catch { toast.error("保存失败") }
setSaving(false);
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/auth';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Notification { id: number; type: 'like' | 'comment' | 'follow' | 'system'; title: string; content?: string; link?: string; relatedId?: number; isRead: boolean; createdAt: string }
@@ -24,7 +25,7 @@ export default function NotificationsPage() {
try {
const res = await apiFetch('/notifications');
if (res.ok) { const data = await res.json(); setNotifications(data.items || []); setUnreadCount(data.unread || 0); }
} catch (e) { console.error(e) }
} catch { toast.error("加载通知失败") }
setLoading(false);
}, []);
@@ -35,7 +36,7 @@ export default function NotificationsPage() {
await apiFetch(`/notifications/${id}/read`, { method: 'PATCH' });
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function markAllRead() {
@@ -43,7 +44,7 @@ export default function NotificationsPage() {
await apiFetch('/notifications/read-all', { method: 'PATCH' });
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setUnreadCount(0);
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
if (loading) return (
+49 -24
View File
@@ -3,25 +3,34 @@
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap } from 'lucide-react';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap, Wrench } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
export default function HomePage() {
const t = useT();
const [courses, setCourses] = useState<any[]>([]);
const [featuredTools, setFeaturedTools] = useState<any[]>([]);
const [stats, setStats] = useState<{ courses: number; prompts: number; tools: number; users: number } | null>(null);
useEffect(() => {
fetch(`${API_BASE}/courses?pageSize=3`)
.then(r => r.json()).then(data => setCourses(data.items || []))
.catch(() => {});
Promise.all([
fetch(`${API_BASE}/courses?pageSize=3`).then(r => r.json()),
fetch(`${API_BASE}/public/stats`).then(r => r.json()),
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6`).then(r => r.json()),
]).then(([courseData, statsData, toolsData]) => {
setCourses(courseData.items || []);
setFeaturedTools(toolsData.items || []);
setStats(statsData);
}).catch(() => {});
}, []);
const stats = [
{ value: '50+', label: t.home.statTopics },
{ value: '200+', label: t.home.statPrompts },
{ value: '30+', label: t.home.statTools },
{ value: '10,000+', label: t.home.statExplorers },
const statItems = [
{ value: stats ? `${stats.courses}+` : '50+', label: t.home.statTopics },
{ value: stats ? `${stats.prompts}+` : '200+', label: t.home.statPrompts },
{ value: stats ? `${stats.tools}+` : '30+', label: t.home.statTools },
{ value: stats ? `${stats.users / 1000 > 1 ? Math.round(stats.users / 100) * 100 + '+' : stats.users + '+'}` : '10,000+', label: t.home.statExplorers },
];
const features = [
{ icon: Compass, title: t.home.featureGuide, desc: t.home.featureGuideDesc },
@@ -71,7 +80,7 @@ export default function HomePage() {
<section className="py-12 md:py-16 border-y border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((s) => (
{statItems.map((s) => (
<div key={s.label} className="text-center group">
<div className="text-3xl md:text-4xl font-bold bg-gradient-to-b from-brand-600 to-brand-400 bg-clip-text text-transparent group-hover:scale-110 transition-transform">
{s.value}
@@ -83,6 +92,35 @@ export default function HomePage() {
</div>
</section>
{/* Featured Tools */}
{featuredTools.length > 0 && (
<section className="py-16 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold">{t.home.featuredToolsTitle}</h2>
<p className="mt-2 text-muted-foreground">{t.home.featuredToolsDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{featuredTools.map((tool: any) => (
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
<Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-1">{tool.description}</p>
</div>
</div>
</Card>
</a>
))}
</div>
</div>
</section>
)}
{/* Features */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -131,20 +169,7 @@ export default function HomePage() {
</div>
</Link>
)) : (
<>
{[{ title: 'AI 通识:零基础入门', tag: t.courses.free }, { title: '提示词工程从入门到精通', tag: '热门' }, { title: '用 AI 提升 10 倍办公效率', tag: '推荐' }].map((course) => (
<div key={course.title} className="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-xl transition-all hover:-translate-y-1">
<div className="h-2 bg-gradient-to-r from-brand-500 to-blue-500" />
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 px-2 py-1 rounded-full">{course.tag}</span>
</div>
<h3 className="text-lg font-semibold mb-2 group-hover:text-brand-600 transition-colors">{course.title}</h3>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2"> AI </p>
</div>
</div>
))}
</>
<div className="col-span-3 text-center py-12 text-muted-foreground">{t.common.loading}</div>
)}
</div>
</div>
+251
View File
@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth';
import { Lightbulb, CheckCircle, Clock, BarChart3 } from 'lucide-react';
interface PracticeQuestion {
id: number;
skillId: string | null;
scenario: string;
title: string;
description: string;
instructions: string;
difficulty: string;
category: string;
expectedCriteria: string;
starterCode: string | null;
hint: string | null;
}
interface CriteriaScore {
name: string;
score: number;
maxScore: number;
reason: string;
}
interface Submission {
id: number;
questionId: number;
answer: string;
score: number | null;
maxScore: number;
feedback: string | null;
criteriaScores: string | null;
duration: number;
status: string;
submittedAt: string;
scoredAt: string | null;
question: PracticeQuestion;
}
export default function PracticeDetailPage() {
const params = useParams();
const t = useT();
const { isLoggedIn } = useAuth();
const [question, setQuestion] = useState<PracticeQuestion | null>(null);
const [loading, setLoading] = useState(true);
const [answer, setAnswer] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submission, setSubmission] = useState<Submission | null>(null);
const [showHint, setShowHint] = useState(false);
const [startTime] = useState(Date.now());
useEffect(() => {
if (!params.id) return;
fetch(`${API_BASE}/practices/${params.id}`)
.then(r => r.json())
.then(data => {
if (data.id) setQuestion(data);
})
.finally(() => setLoading(false));
}, [params.id]);
useEffect(() => {
if (!isLoggedIn || !params.id) return;
apiFetch(`/practices/submissions?limit=50`)
.then(r => r.json())
.then(data => {
const found = (data.items || []).find((s: any) => s.questionId === Number(params.id));
if (found) setSubmission(found);
})
.catch(() => {});
}, [isLoggedIn, params.id]);
const handleSubmit = async () => {
if (!answer.trim() || !isLoggedIn) return;
setSubmitting(true);
try {
const duration = Math.floor((Date.now() - startTime) / 1000);
const res = await apiFetch(`/practices/${params.id}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer.trim(), duration }),
});
if (res.ok) {
const data = await res.json();
setSubmission(data);
} else {
const err = await res.json();
alert(err.message || '提交失败');
}
} catch (err: any) {
alert(err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const difficultyColor = question?.difficulty === 'BEGINNER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: question?.difficulty === 'INTERMEDIATE' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
if (loading) return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-64 rounded-2xl mb-6" />
<Skeleton className="h-48 rounded-2xl" />
</div>
);
if (!question) return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
<p className="text-muted-foreground"></p>
<Link href="/practices" className="text-brand-600 hover:underline text-sm mt-4 inline-block">&larr; {t.practices.title}</Link>
</div>
);
const parsedCriteria: CriteriaScore[] = submission?.criteriaScores ? JSON.parse(submission.criteriaScores) : [];
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/practices" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block">
&larr; {t.practices.title}
</Link>
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${difficultyColor}`}>
{(t.practices as any)[question.difficulty.toLowerCase()] || question.difficulty}
</span>
<span className="text-xs text-muted-foreground">{(t.practices.categories as any)[question.category] || question.category}</span>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">{question.title}</h1>
<p className="text-muted-foreground mb-4">{question.description}</p>
<div className="bg-muted/50 rounded-xl p-4 border border-border">
<h3 className="text-sm font-medium text-foreground mb-2">{t.practices.title} </h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{question.scenario}</p>
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.title} </h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{question.instructions}</p>
{question.hint && (
<div className="mt-3">
<button onClick={() => setShowHint(!showHint)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700">
<Lightbulb className="h-4 w-4" />
{t.practices.hint}
</button>
{showHint && <p className="mt-2 text-sm text-muted-foreground bg-muted/50 rounded-xl p-3">{question.hint}</p>}
</div>
)}
</div>
{submission?.status === 'SCORED' ? (
<div className="space-y-6">
<div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-foreground">{t.practices.score}</h2>
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-brand-600">{submission.score}</span>
<span className="text-muted-foreground">/ {submission.maxScore}</span>
</div>
</div>
<div className="space-y-3 mb-6">
{parsedCriteria.map((c, i) => (
<div key={i} className="bg-muted/50 rounded-xl p-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-foreground">{c.name}</span>
<span className="text-sm text-muted-foreground">{c.score}/{c.maxScore}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mb-1">
<div className="bg-brand-600 h-2 rounded-full transition-all" style={{ width: `${(c.score / c.maxScore) * 100}%` }} />
</div>
<p className="text-xs text-muted-foreground">{c.reason}</p>
</div>
))}
</div>
{submission.feedback && (
<div className="bg-muted/50 rounded-xl p-4 border border-border">
<h3 className="text-sm font-medium text-foreground mb-2">{t.practices.feedback}</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{submission.feedback}</p>
</div>
)}
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.yourAnswer}</h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{submission.answer}</p>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t.practices.duration}: {submission.duration}{t.practices.seconds}</span>
</div>
</div>
) : (
<div className="space-y-4">
{submission?.status === 'SUBMITTED' ? (
<div className="bg-card rounded-2xl border border-border p-6 text-center">
<Clock className="h-8 w-8 text-yellow-500 mx-auto mb-2" />
<p className="text-muted-foreground">AI ...</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => window.location.reload()}>
</Button>
</div>
) : (
<>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.yourAnswer}</h2>
<textarea value={answer} onChange={e => setAnswer(e.target.value)}
placeholder={t.practices.answerPlaceholder}
className="w-full min-h-[200px] p-4 border border-border rounded-xl bg-background text-foreground text-sm resize-y focus:outline-none focus:ring-2 focus:ring-brand-600"
/>
</div>
{isLoggedIn ? (
<Button onClick={handleSubmit} disabled={submitting || !answer.trim()}
className="w-full sm:w-auto">
{submitting ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
{t.practices.submitting}
</span>
) : t.practices.submit}
</Button>
) : (
<div className="bg-muted/50 rounded-xl p-4 text-center border border-border">
<p className="text-muted-foreground mb-3">{t.practices.loginRequired}</p>
<Button asChild>
<Link href="/auth">{t.common.login}</Link>
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import PracticeDetailClient from './client';
export function generateStaticParams() {
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
return ids.map(id => ({ id: String(id) }));
}
export default function PracticeDetailPage() {
return <PracticeDetailClient />;
}
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { Zap, CheckCircle } from 'lucide-react';
interface Quota {
dailyLimit: number;
used: number;
dailyRemaining: number;
extra: number;
totalRemaining: number;
}
const PACKAGES = [
{ id: 'PACKAGE_10', planType: 'PACKAGE', amount: 4.9, quotaAmount: 10, labelKey: 'package10' as const, descKey: 'package10Desc' as const },
{ id: 'PACKAGE_50', planType: 'PACKAGE', amount: 9.9, quotaAmount: 50, labelKey: 'package50' as const, descKey: 'package50Desc' as const, popular: true },
{ id: 'PACKAGE_300', planType: 'PACKAGE', amount: 49, quotaAmount: 300, labelKey: 'package300' as const, descKey: 'package300Desc' as const },
];
export default function PackagesPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [quota, setQuota] = useState<Quota | null>(null);
const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState<string | null>(null);
const [payChannel] = useState<'wxpay' | 'alipay'>('alipay');
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return; }
apiFetch('/sandbox/quota')
.then(r => r.json())
.then(data => setQuota(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [isLoggedIn]);
async function handleBuy(pkg: typeof PACKAGES[0]) {
setPayLoading(pkg.id);
setSuccess(null);
try {
const res = await apiFetch('/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: pkg.amount, planType: pkg.planType, payChannel }),
});
const data = await res.json();
if (data.order && data.payResult) {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if (payUrl || qrCode) {
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel });
}
}
} catch (e) {
console.error(e);
}
setPayLoading(null);
}
function handlePaymentPaid() {
setPaymentModal(prev => ({ ...prev, open: false }));
setSuccess(t.packages.purchaseSuccess.replace('{n}', '50'));
apiFetch('/sandbox/quota').then(r => r.ok && r.json()).then(d => d && setQuota(d));
}
if (!isLoggedIn) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<Zap className="h-12 w-12 text-brand-600 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-foreground mb-2">{t.packages.title}</h1>
<p className="text-muted-foreground mb-6">{t.packages.desc}</p>
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/practices" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block">
&larr; {t.practices.title}
</Link>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.packages.title}</h1>
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
</div>
{quota && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
<div className="flex flex-wrap gap-6">
<div>
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span>
</div>
<div>
<span className="text-sm text-muted-foreground">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>
</div>
</div>
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PACKAGES.map(pkg => (
<div key={pkg.id} className={`bg-card rounded-2xl border-2 p-6 relative transition-all hover:shadow-md ${
pkg.popular ? 'border-brand-600' : 'border-border'
}`}>
{pkg.popular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-medium px-3 py-0.5 rounded-full">
{t.packages.popular}
</span>
)}
<div className="text-center mb-4">
<div className="text-3xl font-bold text-foreground">{t.packages.price.replace('{price}', String(pkg.amount))}</div>
<div className="text-sm text-muted-foreground mt-1">{t.packages.quota.replace('{n}', String(pkg.quotaAmount))}</div>
</div>
<div className="text-center mb-6">
<div className="font-medium text-foreground">{t.packages[pkg.labelKey]}</div>
<div className="text-xs text-muted-foreground mt-1">{t.packages[pkg.descKey]}</div>
</div>
<Button
onClick={() => handleBuy(pkg)}
disabled={payLoading === pkg.id}
className={`w-full ${pkg.popular ? '' : 'variant-outline'}`}
variant={pkg.popular ? 'default' : 'outline'}
>
{payLoading === pkg.id ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
{t.packages.buying}
</span>
) : t.packages.buy}
</Button>
</div>
))}
</div>
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
{t.packages.orUpgrade}{' '}
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link>
</p>
</div>
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch } from '@/lib/auth';
interface Practice {
id: number;
title: string;
scenario: string;
description: string;
difficulty: string;
category: string;
attemptCount: number;
sortOrder: number;
createdAt: string;
}
interface Submission {
id: number;
questionId: number;
score: number | null;
status: string;
submittedAt: string;
}
const difficultyColors: Record<string, string> = {
BEGINNER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
INTERMEDIATE: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
ADVANCED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
export default function PracticesPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [practices, setPractices] = useState<Practice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState('');
const [difficulty, setDifficulty] = useState('');
const [search, setSearch] = useState('');
const [submissions, setSubmissions] = useState<Record<number, Submission>>({});
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/practices`).then(r => r.json()),
fetch(`${API_BASE}/practices/categories`).then(r => r.json()),
]).then(([data, cats]) => {
setPractices(data.items || []);
setCategories(cats || []);
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!isLoggedIn) return;
apiFetch('/practices/submissions?limit=100')
.then(r => r.json())
.then(data => {
const map: Record<number, Submission> = {};
(data.items || []).forEach((s: Submission) => { map[s.questionId] = s; });
setSubmissions(map);
})
.catch(() => {});
}, [isLoggedIn]);
useEffect(() => {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (difficulty) params.set('difficulty', difficulty);
if (search) params.set('search', search);
fetch(`${API_BASE}/practices?${params}`)
.then(r => r.json())
.then(data => setPractices(data.items || []));
}, [category, difficulty, search]);
const difficulties = [
{ id: 'BEGINNER', name: t.practices.beginner },
{ id: 'INTERMEDIATE', name: t.practices.intermediate },
{ id: 'ADVANCED', name: t.practices.advanced },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.practices.title}</h1>
<p className="mt-2 text-muted-foreground">{t.practices.desc}</p>
</div>
<div className="flex flex-wrap gap-3 mb-8">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder={t.common.search}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground w-48" />
<select value={category} onChange={e => setCategory(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allCategories}</option>
{categories.map(c => <option key={c} value={c}>{(t.practices.categories as any)[c] || c}</option>)}
</select>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allDifficulties}</option>
{difficulties.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-44 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{practices.map(p => {
const sub = submissions[p.id];
return (
<Link key={p.id} href={`/practices/${p.id}`}
className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all hover:-translate-y-0.5 group">
<div className="mb-2 flex items-start justify-between gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${difficultyColors[p.difficulty] || ''}`}>
{(t.practices as any)[p.difficulty.toLowerCase()] || p.difficulty}
</span>
{sub && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
sub.status === 'SCORED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}>
{sub.status === 'SCORED' ? t.practices.scoreRange.replace('{score}', String(sub.score)).replace('{max}', '100') : (t.practices.status as any)[sub.status]}
</span>
)}
</div>
<h3 className="font-semibold text-foreground group-hover:text-brand-600 transition-colors mb-1">{p.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{p.description}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{(t.practices.categories as any)[p.category] || p.category}</span>
<span>·</span>
<span>{p.attemptCount} </span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
+8 -4
View File
@@ -42,7 +42,9 @@ export default function CodeSandboxPage() {
try {
setCode(atob(initialCode));
setTemplate('');
} catch {}
} catch (err) {
console.error('Failed to decode code param:', err);
}
}
}, []);
@@ -66,7 +68,7 @@ export default function CodeSandboxPage() {
try {
const iframe = iframeRef.current;
if (!iframe || !iframe.contentWindow) return;
const win = iframe.contentWindow;
const win = iframe.contentWindow as unknown as { console: Console };
const origLog = win.console.log;
const origError = win.console.error;
const newLogs: string[] = [];
@@ -82,7 +84,9 @@ export default function CodeSandboxPage() {
setTimeout(() => {
setLogs(newLogs);
}, 500);
} catch {}
} catch (err) {
console.error('Failed to setup iframe console:', err);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
@@ -137,7 +141,7 @@ export default function CodeSandboxPage() {
onLoad={handleIframeLoad}
className="w-full h-full border-0"
title="预览"
sandbox="allow-scripts allow-modals allow-same-origin"
sandbox="allow-scripts allow-modals"
/>
</div>
{logs.length > 0 && (
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ComparePage() {
const router = useRouter();
const [prompt, setPrompt] = useState('');
const [results, setResults] = useState<ModelResult[]>(
MODELS.map(m => ({ ...m, reply: '', loading: false }))
MODELS.map(m => ({ model: m.id, label: m.label, reply: '', loading: false }))
);
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
+12 -7
View File
@@ -104,7 +104,7 @@ function SandboxPage() {
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(1);
const [maxTokens, setMaxTokens] = useState(2000);
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
const [quota, setQuota] = useState<{ used: number; dailyLimit: number; dailyRemaining: number; extra: number; totalRemaining: number; remaining: number } | null>(null);
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsOpen, setSessionsOpen] = useState(false);
@@ -144,6 +144,7 @@ function SandboxPage() {
{ id: 'coding', name: '编程助手', icon: '💻', systemPrompt: '你是一个编程专家,擅长解答编程问题和编写代码', starters: ['用 Python 写一个斐波那契数列', '解释 RESTful API 设计原则', '帮我调试这段代码', '什么是闭包?'] },
{ id: 'writing', name: '写作助手', icon: '✍️', systemPrompt: '你是一个专业的写作助手,擅长润色和创作各类文本', starters: ['帮我润色这段文字', '写一篇产品介绍', '如何写好工作总结?', '帮我拟一份会议邀请'] },
{ id: 'study', name: '学习辅导', icon: '📚', systemPrompt: '你是一个耐心的学习辅导员,善于解释复杂概念', starters: ['什么是机器学习?', '解释 HTTP 与 HTTPS 的区别', '帮我理解微积分', '英语单词记忆技巧'] },
{ id: 'tutor', name: '编程导师', icon: '🧑‍🏫', systemPrompt: '你是一位编程导师,采用苏格拉底式教学法。核心理念是「不直接给答案,引导对方自己找到答案」。当学习者提出编程问题时:1) 先通过提问理解他们的水平和困惑点;2) 给出思路提示而非完整代码;3) 如果对方卡住了,逐步增加提示的明确度;4) 只有在对方已经尝试并仍然困惑时,才给出带解释的参考代码。目标是培养独立思考和问题解决能力。', starters: ['我想学编程,从哪开始?', '变量和常量有什么区别?', '这段代码为啥报错?帮我看下', 'for 循环和 while 循环有什么区别?'] },
];
fetch(`${API_BASE}/skills`)
@@ -772,22 +773,26 @@ function SandboxPage() {
</div>
<div className="border-t border-border p-4">
{quota && quota.remaining <= 0 ? (
{quota && quota.totalRemaining <= 0 ? (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.sandbox.quotaUpgradeHint}</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.packages.buyMore}</div>
</div>
<Link href="/my/member"
<Link href="/practices/packages"
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
{t.sandbox.upgradeNow}
{t.packages.title}
</Link>
</div>
</div>
) : quota && (
<div className="text-xs text-muted-foreground mb-2">
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
{quota.dailyRemaining <= 0 && quota.extra > 0 && (
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
)}
</div>
)}
{uploadedImages.length > 0 && (
+2 -1
View File
@@ -8,7 +8,7 @@ import { Wrench, ExternalLink, Star } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface Tool { id: number; name: string; description: string; url: string; icon: string | null; isFeatured: boolean; tags: string | null }
interface Tool { id: number; name: string; description: string; url: string; icon: string | null; affiliateLink: string | null; isFeatured: boolean; tags: string | null }
function ToolSkeleton() {
return (
@@ -51,6 +51,7 @@ export default function ToolsPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{tool.name}</h3>
{tool.affiliateLink && <Badge className="bg-green-600 hover:bg-green-700 text-white text-[10px] px-1.5 py-0"></Badge>}
{tool.isFeatured && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{tool.description}</p>
+8 -6
View File
@@ -4,7 +4,9 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { getToken } from '@/lib/auth';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface UserProfile {
id: number; nickname: string; avatar?: string; bio?: string;
@@ -30,10 +32,10 @@ export default function UserProfilePage() {
useEffect(() => { loadData(); }, [userId]);
function getToken() { return localStorage.getItem('token'); }
function getAuthToken() { return getToken(); }
function apiHeaders() {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
const t = getToken();
const t = getAuthToken();
if (t) h['Authorization'] = `Bearer ${t}`;
return h;
}
@@ -60,7 +62,7 @@ export default function UserProfilePage() {
setIsFollowing(d.followed);
}
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function toggleFollow() {
@@ -78,7 +80,7 @@ export default function UserProfilePage() {
followerCount: prev.followerCount + (isFollowing ? -1 : 1),
} : prev);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function loadFollowers() {
@@ -88,7 +90,7 @@ export default function UserProfilePage() {
const d = await res.json();
setFollowers(d.items || []);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function loadFollowing() {
@@ -98,7 +100,7 @@ export default function UserProfilePage() {
const d = await res.json();
setFollowing(d.items || []);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
useEffect(() => {
+84 -12
View File
@@ -1,12 +1,12 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Search, Menu, X, Bell, Languages } from 'lucide-react';
import { Search, Menu, X, Bell, Languages, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import { useLang, useT } from '@/i18n';
@@ -53,18 +53,35 @@ export function Header() {
const { lang, setLang } = useLang();
const t = useT();
const navItems = [
const mainNavItems = [
{ href: '/', label: t.nav.home },
{ href: '/courses', label: t.nav.courses },
{ href: '/marketplace', label: t.nav.marketplace },
{ href: '/sandbox', label: t.nav.sandbox },
{ href: '/skills', label: t.nav.skills },
{ href: '/models', label: t.nav.models },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/contents', label: t.nav.articles },
{ href: '/tools', label: t.nav.tools },
{ href: '/community', label: t.nav.community },
{ href: '/practices', label: t.nav.practices },
// { href: '/community', label: t.nav.community },
// { href: '/enterprise', label: t.nav.enterprise },
];
const secondaryNavItems = [
{ href: '/courses', label: t.nav.courses },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/models', label: t.nav.models },
{ href: '/tools', label: t.nav.tools },
{ href: '/contents', label: t.nav.articles },
];
const [moreOpen, setMoreOpen] = useState(false);
const moreTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function openMore() {
if (moreTimer.current) clearTimeout(moreTimer.current);
setMoreOpen(true);
}
function closeMore() {
moreTimer.current = setTimeout(() => setMoreOpen(false), 200);
}
function isActive(href: string) {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
@@ -92,7 +109,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -105,6 +122,46 @@ export function Header() {
{item.label}
</Link>
))}
<div
className="relative"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
<button
className={`flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
moreOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{t.nav.more}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${moreOpen ? 'rotate-180' : ''}`} />
</button>
<div
className="absolute right-0 top-full z-50 pt-1"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
{moreOpen && (
<div className="w-40 bg-popover border border-border rounded-lg shadow-lg py-1 animate-fade-in">
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 text-sm transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMoreOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
</div>
</nav>
<div className="hidden md:flex items-center gap-2">
@@ -170,7 +227,22 @@ export function Header() {
{mobileOpen && (
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block py-2.5 px-2 text-sm rounded-lg transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
<div className="border-t border-border my-2 mx-2" />
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -1,7 +1,7 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
import { type ThemeProviderProps } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
+5 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useRef, useState } from 'react'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
@@ -48,7 +49,9 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
} catch (err) {
console.error('Copy failed:', err)
}
}
return (
@@ -61,7 +64,7 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
</button>
</div>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed bg-[#1e1e1e]">
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: highlighted }} />
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(highlighted) }} />
</pre>
</div>
)
+1 -1
View File
@@ -44,7 +44,7 @@ export default function PaymentModal({ open, orderNo, payResult, payChannel, onP
if (payChannel === 'alipay') {
setMessage(t.member.alipayRedirect);
if (redirectUrl && redirectUrl !== 'mock://pay') {
if (redirectUrl) {
window.open(redirectUrl, '_blank');
}
startPolling();
+13 -8
View File
@@ -2,10 +2,10 @@ import type { Translations } from './zh'
const en: Translations = {
common: { loading: 'Loading...', save: 'Save', cancel: 'Cancel', delete: 'Delete', confirm: 'Confirm', search: 'Search', back: 'Back', login: 'Login', register: 'Register', logout: 'Logout', retry: 'Retry', noData: 'No data', viewAll: 'View all' },
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles' },
home: { badge: 'Free AI Learning Community', heroHighlight: 'Empower Everyone', heroRest: 'to Master AI', desc: 'AI knowledge, prompt engineering, sandbox practice & model encyclopedia', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Guided Learning', featureGuideDesc: 'Content organized by role and scenario', featureSandbox: 'AI Sandbox', featureSandboxDesc: 'Built-in AI sandbox to learn by doing', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles', practices: 'Practice', enterprise: 'Enterprise', marketplace: 'Marketplace', more: 'More' },
home: { badge: 'AI Tool Guide & Practice', heroHighlight: 'AI Tool Guide', heroRest: 'Practice · Skill Building', desc: 'Curated AI tools with real-world practice to accelerate your AI skills', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Curated Tools', featureGuideDesc: 'Hand-picked AI tools to help you find the right solution', featureSandbox: 'Practice Sandbox', featureSandboxDesc: 'Practice tool usage skills in the sandbox', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', featuredToolsTitle: 'Featured Tools', featuredToolsDesc: 'Hand-picked AI tools for you', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
auth: { loginTitle: 'Login', registerTitle: 'Register', phone: 'Phone', password: 'Password', nickname: 'Nickname', username: 'Username', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Username / Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', usernameOptional: 'Username (optional, 2-20 chars)', passwordHint: 'Password (min 6 characters)', confirmPassword: 'Confirm password', registering: 'Registering...', agreePrefix: 'By registering, you agree to our', termsOfService: 'Terms of Service', privacyPolicy: 'Privacy Policy', aiAgreement: 'AI Service Agreement', fillAccountAndPassword: 'Please enter account and password', fillPhoneOrEmail: 'Please enter phone or email', fillPassword: 'Please enter password', passwordMinLength: 'Password must be at least 6 characters', passwordsNotMatch: 'Passwords do not match', loginFailed: 'Login failed', registerFailed: 'Registration failed', loginSuccess: 'Login successful', registerSuccess: 'Registration successful', usernameConflict: 'Username already taken', phoneConflict: 'Phone already registered', emailConflict: 'Email already registered' },
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', recentLearning: 'Recent Learning', modelLabel: 'Model: {model}', viewCount: '{n} views', likeCount: '{n} likes', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
community: { title: 'Community', desc: 'Share AI learning experiences with others', createPost: '+ New Post', latest: 'Latest', following: 'Following', newPost: 'New Post', postTitle: 'Title', contentPlaceholder: 'Share your AI learning experience...', tagsPlaceholder: 'Tags (comma-separated)', publishing: 'Publishing...', publish: 'Publish', feedEmpty: 'Follow more users to discover great content', postsEmpty: 'No posts yet — be the first!', commentPlaceholder: 'Write a comment...', sending: 'Sending...', comment: 'Comment', followed: 'Following', follow: '+ Follow' },
notifications: { title: 'Notifications', unreadCount: 'You have {n} unread notifications', noUnread: 'No unread notifications', markAllRead: 'Mark all read', markRead: 'Read', emptyTitle: 'No notifications', emptyDesc: 'Likes, comments and follows will appear here' },
my: { desc: 'Manage your profile and favorites', learningProgress: 'Learning Progress', learningProgressDesc: 'View your course progress', favorites: 'My Favorites', favoritesDesc: 'Saved prompts and courses', memberCenter: 'Membership', memberDesc: 'Manage subscription and benefits', settings: 'Settings', settingsDesc: 'Account settings and preferences', analyticsDesc: 'Knowledge mastery analysis based on conversations', pathDesc: 'Master AI skills systematically' },
@@ -15,22 +15,27 @@ const en: Translations = {
courses: { title: 'Courses', desc: 'Explore AI systematically from beginner to expert', empty: 'No courses available', free: 'Free', paid: 'Paid', moduleCount: '{n} modules' },
search: { title: 'Search Results', placeholder: 'Search courses, prompts, tools, articles...', emptyHint: 'Enter keywords to search', noResults: 'No results found for "{q}"', resultsCount: '{n} results found', groupCourse: 'Courses', groupPrompt: 'Prompts', groupTool: 'AI Tools', groupContent: 'Articles' },
tools: { title: 'AI Tools', desc: 'Curated AI tools to boost your productivity' },
discover: { desc: 'Explore trending content and curated picks', hotCourses: 'Hot Courses', hotPrompts: 'Trending Prompts', hotPosts: 'Popular Discussions', viewCount: '{n} views', likeCount: '{n} likes', postStats: '❤️ {likes} · 👁 {views}' },
discover: { desc: 'Explore trending content and curated picks', hotCourses: 'Hot Courses', hotPrompts: 'Trending Prompts', hotPosts: 'Popular Discussions', viewCount: '{n} views', likeCount: '{n} likes', postStats: '❤️ {likes} · 👁 {views}', toolsTitle: 'AI Tool Picks', toolsDesc: 'Curated popular AI tools' },
circles: { title: 'Circles', desc: 'Topic-based discussion groups', empty: 'No circles yet', back: 'Back to Discover', members: 'members', posts: 'posts' },
brand: { name: 'Yuzhiran', suffix: 'AI' },
footer: { tagline: 'Empowering Everyone to Master AI', explore: 'Explore', about: 'About', aboutUs: 'About Us', privacy: 'Privacy Policy', terms: 'Terms of Service', aiAgreement: 'AI Service Agreement', contact: 'Contact', copyright: '© {year} Yuzhiran Technology Center. All rights reserved.', models: 'Models', aiTools: 'AI Tools', articles: 'Articles', skills: 'Skills' },
models: { title: 'AI Model Encyclopedia', desc: 'Compare mainstream LLMs to find the best fit', tableName: 'Model', tableProvider: 'Provider', tableCapabilities: 'Capabilities', tableContext: 'Context', tableMaxOutput: 'Max Output', tablePricing: 'Pricing', free: 'Free', recommended: 'Recommended', pricingFree: 'Free', pricingMixed: 'Free/Paid', pricingPaid: 'Paid', contextWindow: 'Context', maxOutput: 'Max Output' },
footer: { tagline: 'Empowering Everyone to Master AI', explore: 'Explore', about: 'About', aboutUs: 'About Us', privacy: 'Privacy Policy', terms: 'Terms of Service', aiAgreement: 'AI Service Agreement', contact: 'Contact', copyright: '© {year} Yuzhiran Technology Center. All rights reserved.', models: 'Capabilities', aiTools: 'AI Tools', articles: 'Articles', skills: 'Skills' },
models: { title: 'AI Capabilities Guide', desc: 'Discover what AI can do for you — from conversations to coding, find your perfect use case', capChat: 'Chat & Writing', capChatDesc: 'Chat with AI for ideas, copywriting, translation, and more', capCode: 'Coding Assistant', capCodeDesc: 'Generate code, debug errors, explain algorithms — your coding partner', capData: 'Data Analysis', capDataDesc: 'Analyze data, generate charts, uncover trends — let data speak', capPrompt: 'Prompt Engineering', capPromptDesc: 'Learn to write effective prompts, master AI communication', capPractice: 'Skill Practice', capPracticeDesc: 'Practice AI skills in real scenarios with instant scoring feedback', capMarketplace: 'Workflow Skills', capMarketplaceDesc: 'Buy ready-to-use AI workflow skills for specific business problems', goSandbox: 'Open Sandbox', goPrompts: 'Browse Prompts', goPractice: 'Start Practice', goMarketplace: 'Go to Marketplace' },
error: { title: 'Something went wrong', desc: 'Page failed to load. Please try again.', reload: 'Reload' },
notFound: { title: '404', desc: 'Page not found', backToHome: 'Back to Home' },
share: { missingToken: 'Missing share token', invalidLink: 'Invalid share link', notAvailable: 'Shared content not available', expired: 'This share link may have expired', goToSandbox: 'Go to AI Sandbox', backToSandbox: 'AI Sandbox', modelInfo: 'Model: {model} · {date}' },
path: { back: 'Back', totalProgress: 'Total Progress', taskCount: '{completed}/{total} tasks' },
sandbox: { title: 'Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', shareSuccess: 'Shared successfully', shareFailed: 'Share failed', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago',
sandbox: { title: 'Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', sceneTutor: 'Programming Tutor', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', shareSuccess: 'Shared successfully', shareFailed: 'Share failed', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago',
freeMode: 'Free Mode', learnMode: 'Learning Mode', learnPath: 'Learning Path', learnPathDesc: 'From zero to pro in 6 steps. Click each stage to practice in free mode, check tasks when done to proceed.', stage: 'Step {n}', stageProgress: '{done}/{total} done', startPractice: 'Start Practice', stageDone: 'Completed', stageLocked: 'Locked', stageWelcome: 'First Contact', stageWelcomeDesc: 'Start your first AI conversation', stageScene: 'Scene Practice', stageSceneDesc: 'Practice in different role scenarios', stageParams: 'Parameter Tuning', stageParamsDesc: 'Adjust Temperature and see what changes', stageModels: 'Model Comparison', stageModelsDesc: 'Switch models to compare their styles', stagePrompts: 'Advanced Prompting', stagePromptsDesc: 'Learn role-setting, structured prompts', stageMaster: 'Final Challenge', stageMasterDesc: 'Apply everything in a real-world task', taskSendMsg: 'Send your first message', taskTryStarter: 'Try a starter question', taskReadReply: 'Understand AI reply characteristics', taskSwitchCoding: 'Switch to Coding scene', taskSwitchWriting: 'Switch to Writing scene', taskSwitchStudy: 'Switch to Study scene', taskHighTemp: 'Try Temperature at 0.9', taskLowTemp: 'Try Temperature at 0.1', taskCompareTemp: 'Compare the differences', taskSwitchModel: 'Switch to DeepSeek model', taskCompareModel: 'Compare model reply styles', taskRolePrompt: 'Write a prompt with role-setting', taskStructured: 'Try structured step-by-step prompts', taskCodeExec: 'Run AI-generated code in Code Sandbox', taskCommunity: 'Share a conversation to Community', quotaExhausted: 'Free quota exhausted', upgradeNow: 'Upgrade', quotaUpgradeHint: 'Upgrade for more quota and all models', quotaLoginHint: 'Login for more free daily quota' },
learning: { analytics: 'Learning Analytics', analyticsDesc: 'Analyze your learning based on AI conversations', path: 'Learning Path', pathDesc: 'Master AI skills systematically', totalSessions: 'AI Sessions', domainsCovered: 'Domains Covered', avgMastery: 'Avg Mastery', knowledgeDomains: 'Knowledge Domains', weakAreas: 'Weak Areas', weakDesc: 'Consider strengthening these areas:', recommendations: 'Recommendations', recDesc: 'Based on your weak areas', toStrengthen: 'To Strengthen', conversations: '{count} conversations', clickToGo: 'Go' },
member: { title: 'Membership', desc: 'Manage your subscription', currentPlan: 'Current Plan', freeUser: 'You are on the Free plan', monthly: 'Monthly', yearly: 'Yearly', monthlyPrice: '¥49.9/month', yearlyPrice: '¥299/year', expires: 'Expires: {date}', benefits: 'All courses + unlimited sandbox + premium prompts + ad-free', orderHistory: 'Order History', noOrders: 'No orders yet', processing: 'Processing...', planFree: 'Free', planMonthly: 'Monthly', planYearly: 'Yearly', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/mo', perYear: '/yr', popular: 'Popular', featureSandbox: 'AI Sandbox', featureSandboxFree: '10/day', featureSandboxPro: '100/day', featureSandboxUnlimited: 'Unlimited', featureModels: 'Models', featureModelsFree: '1 model', featureModelsPro: '2 models', featureModelsPremium: 'All models', featurePrompts: 'Prompts', featurePromptsFree: 'Basic', featurePromptsPro: 'All', featurePromptsPremium: 'All + Exclusive', featureCourses: 'Courses', featureCoursesFree: 'Partial', featureCoursesPro: 'All', featureCoursesPremium: 'All', featureAds: 'Ads', featureAdsFree: 'Ads', featureAdsPro: 'Ad-free', featureAdsPremium: 'Ad-free', dailyQuota: 'Daily Quota', used: '{n} used', subscribe: 'Subscribe', currentPlan_badge: 'Current', payMethod: 'Payment Method', wechatPay: 'WeChat Pay', alipay: 'Alipay', scanQrCode: 'Scan QR code with WeChat', alipayRedirect: 'Redirecting to Alipay...', openAlipay: 'Open Alipay', payFailed: 'Payment failed, please retry', orderPaid: 'Payment successful!', cancelPay: 'Cancel', waitingPay: 'Waiting for payment...' },
compare: { title: 'Compare Lab', desc: 'Compare how different models respond', placeholder: 'Enter a question or prompt to compare...', startCompare: 'Start Compare', comparing: 'Comparing...', backToSandbox: 'Back to Sandbox', noResponse: 'No response' },
codeSandbox: { title: 'Code Sandbox', run: 'Run', runShortcut: 'Run (⌘⏎)', template: 'Template...', blank: 'Blank', react: 'React (CDN)', chart: 'Chart (Chart.js)', three: '3D (Three.js)', console: 'Console' },
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career' } },
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career', marketing: 'Marketing', business: 'Business' } },
marketplace: { title: 'Skill Marketplace', desc: 'One-time purchase, lifetime access — premium AI skills for your workflow', free: 'Free', buy: 'Unlock · ¥{price}', buyNow: 'Buy Now', buying: 'Processing...', purchased: 'Owned', locked: 'Premium', loginRequired: 'Login to purchase', purchaseSuccess: 'Purchased! You can now use this skill', unlockToUse: 'Unlock to use', tryForFree: 'Try Free Skills', orUpgrade: 'Browse more premium skills', skillMarketplace: 'Skill Marketplace' },
promptWorkshop: { title: 'Prompt Workshop', desc: 'Write, test, and optimize your prompts', editor: 'Prompt Editor', test: 'Test Prompt', testing: 'Testing...', clear: 'Clear', saveToLibrary: 'Save to Library', saveSuccess: 'Saved successfully!', variables: 'Variables', role: 'Role', task: 'Task', outputFormat: 'Output Format', constraints: 'Constraints', insert: 'Insert', testResult: 'Test Result', saveDialogTitle: 'Save Prompt', saveTitle: 'Title *', saveDesc: 'Description', saveTags: 'Tags', saveTagsPlaceholder: 'e.g. programming,Python,debug', saving: 'Saving...' },
practices: { title: 'AI Practice', desc: 'Practice AI skills with real-world scenarios and get instant scoring feedback', submit: 'Submit Answer', submitting: 'Scoring...', score: 'Score', feedback: 'Feedback', criteria: 'Criteria', duration: 'Duration', seconds: 's', startPractice: 'Start Practice', yourAnswer: 'Your Answer', answerPlaceholder: 'Enter your answer here...', hint: 'Hint', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', categories: { general: 'General', coding: 'Coding', writing: 'Writing', analysis: 'Data Analysis', customer: 'Customer Service', creative: 'Creative' }, loginRequired: 'Login to submit practice', alreadySubmitted: 'View Score', viewSubmission: 'View Score', submissions: 'My Submissions', noSubmissions: 'No submissions yet', goPractice: 'Go Practice', scoreRange: '{score}/{max}', retry: 'Try Again', status: { SUBMITTED: 'Pending', SCORED: 'Scored' } },
packages: { title: 'Quota Packs', desc: 'Purchase extra AI sandbox usage', buy: 'Buy Now', popular: 'Popular', save: 'Save {amount}', quota: '{n} uses', price: '¥{price}', buying: 'Buying...', package10: '10-Use Pack', package10Desc: 'For occasional use', package50: '50-Use Standard Pack', package50Desc: '¥0.20/use, best value', package300: '300-Use Premium Pack', package300Desc: '¥0.17/use, for heavy users', currentQuota: 'Current Quota', dailyQuota: 'Used {used}/{limit} today', extraQuota: 'Extra uses remaining: {n}', quotaExhausted: 'Free quota exhausted', buyMore: 'Buy More', purchaseSuccess: 'Purchase successful! Added {n} uses', purchaseFailed: 'Purchase failed, please retry', orUpgrade: 'Or upgrade to membership for unlimited use' },
enterprise: { title: 'Enterprise', nav: 'Enterprise', desc: 'Secure, controlled AI training for your team', heroTitle: 'Let Your Team Use AI Safely', heroDesc: '75% of employees use AI tools without IT approval. Enterprise edition provides data-isolated AI sandbox environment for your team to learn, practice, and apply AI securely.', heroCta: 'Start Free Trial', heroCtaSub: 'No credit card required', problemTitle: 'Shadow AI Is Threatening Your Data Security', problemDesc: 'More employees are using public AI tools at work, putting company data at unprecedented risk.', statShadowAi: '75%', statShadowAiDesc: 'Employees use unauthorized AI tools', statDataLeak: '48%', statDataLeakDesc: 'Employees paste company data into public AI', statIpLeak: '43%', statIpLeakDesc: 'Companies experienced IP leakage', statTrainingGap: '68%', statTrainingGapDesc: 'Teachers received no AI training', solutionTitle: 'Enterprise Solutions', solutionDesc: 'Data-isolated AI training environment for teams', featureIsolation: 'Data Isolation', featureIsolationDesc: 'All conversations stored in dedicated instance, never used for training, never leaked to public', featureManage: 'Team Management', featureManageDesc: 'Invite members with one click, manage permissions, view team usage', featureAnalytics: 'Learning Analytics', featureAnalyticsDesc: 'Track team AI skill mastery, identify weak areas, improve targeted', featureSandbox: 'Private Sandbox', featureSandboxDesc: 'Practice in secure isolated AI sandbox, supports major models', featureReport: 'Usage Reports', featureReportDesc: 'Auto-generated team AI usage reports with analytics and recommendations', featureCustom: 'Custom Training', featureCustomDesc: 'Customize practice content for your business scenarios', pricingTitle: 'Flexible Pricing', pricingDesc: 'Choose the right plan for your team', pricingFree: 'Free', pricingFreePrice: '¥0', pricingFreeDesc: 'Personal AI learning experience', pricingFreeFeature1: '10 sandbox uses/day', pricingFreeFeature2: '1 model', pricingFreeFeature3: 'Basic exercises', pricingBiz: 'Enterprise', pricingBizPrice: '¥199', pricingBizPerUser: '/user/year', pricingBizDesc: 'Team AI skill development', pricingBizFeature1: 'Unlimited sandbox', pricingBizFeature2: 'All models', pricingBizFeature3: 'Data isolation', pricingBizFeature4: 'Team management', pricingBizFeature5: 'Analytics reports', pricingBizFeature6: 'Dedicated support', pricingCta: 'Contact Sales', pricingCtaFree: 'Start Free', faqTitle: 'FAQ', faq1q: 'What\'s the difference between Enterprise and Free?', faq1a: 'Enterprise provides data-isolated AI environment, team management dashboard, learning analytics reports, and priority support. All conversation data is isolated from model training.', faq2q: 'How do I invite team members?', faq2a: 'After creating your organization, invite members by email or user ID. Members get access to your enterprise sandbox and learning resources.', faq3q: 'How is data security ensured?', faq3a: 'Enterprise data is stored in isolated database instances, separate from public version. AI conversation data is never used for model training.', faq4q: 'Which AI models are supported?', faq4a: 'Enterprise supports all major AI models including GPT-4, Claude, DeepSeek, and more. Team members can freely switch between models.', faq5q: 'Can practice content be customized?', faq5a: 'Yes. Enterprise supports custom practice questions and scoring criteria based on your business scenarios.', dashboard: 'Dashboard', myTeam: 'My Team', createOrg: 'Create Team', orgName: 'Team Name', orgDesc: 'Team Description', members: 'Members', memberCount: '{n} members', inviteMember: 'Invite Member', inviteById: 'Invite by User ID', memberId: 'User ID', invite: 'Invite', removeMember: 'Remove', removeConfirm: 'Remove this member?', usageReport: 'Usage Report', totalMembers: 'Total Members', totalUsage: 'Total Usage', completionRate: 'Completion Rate', noOrg: 'No team yet', createOrgHint: 'Create a team to manage members and AI learning', noMembers: 'No members yet', inviteHint: 'Invite members to join your team', learnMore: 'Learn More', selectOrg: 'Select a team to view details' },
assistant: { title: 'AI Assistant', greeting: 'Hi! I can help you explore and use this site. Try asking:', placeholder: 'Ask me anything...', error: 'Error: {message}', loginPrompt: '📝 Log in to unlock the full AI experience.\n\nClick "Login" or "Register" in the top right corner.' },
}
+13 -8
View File
@@ -1,9 +1,9 @@
const zh = {
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章' },
home: { badge: '免费 AI 知识社区', heroHighlight: '让每个人', heroRest: '都能用好 AI', desc: '涵盖 AI 通识、提示词工程、沙盒实战、模型百科', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: '分领域指南', featureGuideDesc: '按职业和场景分类内容,学即所用', featureSandbox: 'AI 沙盒实战', featureSandboxDesc: '内置 AI 对话沙盒,边学边练', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', more: '更多' },
home: { badge: 'AI 工具指南与实战练习', heroHighlight: 'AI 工具指南', heroRest: '实战练习 · 技能提升', desc: '收录优质 AI 工具,提供真实场景练习,助你快速掌握 AI 技能', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: 'AI 工具精选', featureGuideDesc: '精心收录优质 AI 工具,帮你快速找到合适的工具', featureSandbox: 'AI 沙盒练习', featureSandboxDesc: '在沙盒中实践 AI 工具使用技巧', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', featuredToolsTitle: '精选 AI 工具', featuredToolsDesc: '精心挑选的优质 AI 工具推荐', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', username: '用户名', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '用户名 / 手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', usernameOptional: '用户名(选填,2-20位)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功', usernameConflict: '用户名已被注册', phoneConflict: '手机号已被注册', emailConflict: '邮箱已被注册' },
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', recentLearning: '最近学习', modelLabel: '模型: {model}', viewCount: '{n} 次浏览', likeCount: '{n} 个赞', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
community: { title: '社区', desc: '与 AI 学习者交流心得', createPost: '+ 发帖', latest: '最新', following: '关注', newPost: '发布新帖', postTitle: '标题', contentPlaceholder: '分享你的 AI 学习心得、实战经验...', tagsPlaceholder: '标签(逗号分隔)', publishing: '发布中...', publish: '发布', feedEmpty: '关注更多用户,发现精彩内容', postsEmpty: '还没有帖子,来发第一帖吧!', commentPlaceholder: '写下你的评论...', sending: '发送中...', comment: '评论', followed: '已关注', follow: '+ 关注' },
notifications: { title: '通知', unreadCount: '你有 {n} 条未读通知', noUnread: '暂无未读通知', markAllRead: '全部已读', markRead: '已读', emptyTitle: '暂无通知', emptyDesc: '点赞、评论或关注你的人会出现在这里' },
my: { desc: '管理你的个人信息和收藏', learningProgress: '学习进度', learningProgressDesc: '查看你的课程学习进度', favorites: '我的收藏', favoritesDesc: '提示词、课程等收藏内容', memberCenter: '会员中心', memberDesc: '管理会员订阅和权益', settings: '设置', settingsDesc: '账号设置和安全偏好', analyticsDesc: '基于对话的知识掌握度分析', pathDesc: '分阶段系统掌握 AI 技能' },
@@ -13,22 +13,27 @@ const zh = {
courses: { title: '专题', desc: '系统化探索 AI,从入门到精通', empty: '暂无专题内容', free: '免费', paid: '付费', moduleCount: '{n} 模块' },
search: { title: '搜索结果', placeholder: '搜索专题、提示词、工具、文章...', emptyHint: '输入关键词搜索', noResults: '未找到与 "{q}" 相关的结果', resultsCount: '找到 {n} 个结果', groupCourse: '专题', groupPrompt: '提示词', groupTool: 'AI 工具', groupContent: '文章' },
tools: { title: 'AI 工具库', desc: '收录优质 AI 工具,助力工作效率提升' },
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览' },
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览', toolsTitle: '工具推荐', toolsDesc: '精选热门AI工具' },
circles: { title: '圈子', desc: '按领域划分的垂直讨论区', empty: '暂无圈子', back: '返回发现', members: '人', posts: '帖' },
brand: { name: '宇之然', suffix: 'AI' },
footer: { tagline: '让每个人都能用好 AI', explore: '探索', about: '关于', aboutUs: '关于我们', privacy: '隐私政策', terms: '服务协议', aiAgreement: 'AI 服务协议', contact: '联系方式', copyright: '© {year} 北京宇之然科技中心 版权所有', models: '模型百科', aiTools: 'AI 工具', articles: '文章', skills: '技能' },
models: { title: 'AI 模型百科', desc: '收录主流大语言模型,全面对比各项参数', tableName: '模型名称', tableProvider: '提供商', tableCapabilities: '能力', tableContext: '上下文', tableMaxOutput: '最大输出', tablePricing: '价格', free: '免费', recommended: '推荐', pricingFree: '免费', pricingMixed: '免费/付费', pricingPaid: '付费', contextWindow: '上下文', maxOutput: '最大输出' },
footer: { tagline: '让每个人都能用好 AI', explore: '探索', about: '关于', aboutUs: '关于我们', privacy: '隐私政策', terms: '服务协议', aiAgreement: 'AI 服务协议', contact: '联系方式', copyright: '© {year} 北京宇之然科技中心 版权所有', models: '能力导览', aiTools: 'AI 工具', articles: '文章', skills: '技能' },
models: { title: 'AI 能力导览', desc: '探索 AI 能为你做什么,从对话写作到编程实战,找到适合你的场景', capChat: '对话写作', capChatDesc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', capCode: '编程辅助', capCodeDesc: '生成代码、调试错误、解释算法,你的随身编程搭档', capData: '数据分析', capDataDesc: '分析数据、生成图表、洞察趋势,让数据说话', capPrompt: '提示词工程', capPromptDesc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', capPractice: '技能实战', capPracticeDesc: '在真实场景中练习 AI 技能,获得即时评分反馈', capMarketplace: '工作流技能', capMarketplaceDesc: '购买即用的 AI 工作流技能,解决具体业务问题', goSandbox: '打开沙盒', goPrompts: '浏览提示词库', goPractice: '开始练习', goMarketplace: '前往技能广场' },
error: { title: '出错了', desc: '页面加载失败,请稍后重试', reload: '重新加载' },
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', shareSuccess: '分享成功', shareFailed: '分享失败', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', sceneTutor: '编程导师', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', shareSuccess: '分享成功', shareFailed: '分享失败', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
freeMode: '自由模式', learnMode: '学习模式', learnPath: '学习路径', learnPathDesc: '从零到精通,6 步掌握 AI 对话。点击每项文字进入自由模式练习,学会后打钩确认进入下一项', stage: '第 {n} 步', stageProgress: '{done}/{total} 已完成', startPractice: '开始练习', stageDone: '已完成', stageLocked: '未解锁', stageWelcome: 'AI 初体验', stageWelcomeDesc: '了解 AI 能做什么,发起第一次对话', stageScene: '场景实战', stageSceneDesc: '在不同角色场景中练习对话技巧', stageParams: '参数调优', stageParamsDesc: '调节 Temperature 等参数,观察回复变化', stageModels: '模型对比', stageModelsDesc: '切换不同模型,了解各自特点与差异', stagePrompts: '提示词进阶', stagePromptsDesc: '学习角色设定、结构化提示等高级技巧', stageMaster: '综合实战', stageMasterDesc: '综合运用所学,完成一个完整的实战任务', taskSendMsg: '发送第一条消息', taskTryStarter: '尝试一个 Starter 问题', taskReadReply: '理解 AI 回复的特点', taskSwitchCoding: '切换到编程助手场景', taskSwitchWriting: '切换到写作助手场景', taskSwitchStudy: '切换到学习辅导场景', taskHighTemp: '调高 Temperature 到 0.9 试试', taskLowTemp: '调低 Temperature 到 0.1 对比', taskCompareTemp: '对比两次回复的差异', taskSwitchModel: '切换到 DeepSeek 模型', taskCompareModel: '对比不同模型的回复风格', taskRolePrompt: '使用角色设定写一条提示词', taskStructured: '使用结构化提示(步骤化)', taskCodeExec: '在代码沙盒中运行 AI 生成的代码', taskCommunity: '将对话分享到社区', quotaExhausted: '今日免费次数已用完', upgradeNow: '升级会员', quotaUpgradeHint: '升级会员可获得更多使用次数和全部模型', quotaLoginHint: '登录后可获得更多免费使用次数' },
learning: { analytics: '学情分析', analyticsDesc: '基于 AI 沙盒对话分析你的学习情况', path: '学习路径', pathDesc: '从入门到精通,系统掌握 AI 技能', totalSessions: 'AI 对话次数', domainsCovered: '涉及知识领域', avgMastery: '平均掌握度', knowledgeDomains: '知识领域覆盖', weakAreas: '薄弱环节', weakDesc: '以下领域你较少涉及,建议加强学习:', recommendations: '推荐学习', recDesc: '根据你的薄弱环节推荐以下内容', toStrengthen: '待加强', conversations: '{count} 次对话', clickToGo: '点击前往' },
member: { title: '会员中心', desc: '管理你的会员订阅', currentPlan: '当前会员', freeUser: '你当前是免费用户', monthly: '月卡会员', yearly: '年卡会员', monthlyPrice: '开通月卡 ¥49.9/月', yearlyPrice: '开通年卡 ¥299/年', expires: '到期时间:{date}', benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告', orderHistory: '订单记录', noOrders: '暂无订单记录', processing: '处理中...', planFree: '免费', planMonthly: '月卡', planYearly: '年卡', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/月', perYear: '/年', popular: '最受欢迎', featureSandbox: 'AI 沙盒', featureSandboxFree: '10 次/日', featureSandboxPro: '100 次/日', featureSandboxUnlimited: '不限次', featureModels: '模型选择', featureModelsFree: '1 个模型', featureModelsPro: '2 个模型', featureModelsPremium: '全部模型', featurePrompts: '提示词库', featurePromptsFree: '基础', featurePromptsPro: '全部', featurePromptsPremium: '全部 + 专属', featureCourses: '课程学习', featureCoursesFree: '部分免费', featureCoursesPro: '全部', featureCoursesPremium: '全部', featureAds: '广告', featureAdsFree: '有广告', featureAdsPro: '去广告', featureAdsPremium: '去广告', dailyQuota: '日配额用量', used: '已用 {n} 次', subscribe: '开通', currentPlan_badge: '当前方案', payMethod: '支付方式', wechatPay: '微信支付', alipay: '支付宝支付', scanQrCode: '请使用微信扫描二维码', alipayRedirect: '正在跳转到支付宝...', openAlipay: '打开支付宝', payFailed: '支付失败,请重试', orderPaid: '支付成功!', cancelPay: '取消支付', waitingPay: '等待支付中...' },
compare: { title: '对比实验室', desc: '同题对比不同模型的表现', placeholder: '输入你想对比的问题或提示词...', startCompare: '开始对比', comparing: '对比中...', backToSandbox: '返回沙箱', noResponse: '无响应' },
codeSandbox: { title: '代码沙盒', run: '运行', runShortcut: '运行 (⌘⏎)', template: '模板...', blank: '空白', react: 'React (CDN)', chart: '图表 (Chart.js)', three: '3D (Three.js)', console: '控制台输出' },
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' } },
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' } },
marketplace: { title: '技能广场', desc: '一次性购买,终身使用 — 为你的工作流定制的 AI 技能', free: '免费', buy: '解锁 · ¥{price}', buyNow: '立即购买', buying: '购买中...', purchased: '已拥有', locked: '付费', loginRequired: '请登录后购买', purchaseSuccess: '购买成功!现在可以使用此技能了', unlockToUse: '解锁使用', tryForFree: '试试免费技能', orUpgrade: '浏览更多付费技能', skillMarketplace: '技能广场' },
promptWorkshop: { title: '提示词工坊', desc: '编写、测试、优化你的提示词', editor: '提示词编辑', test: '测试提示词', testing: '测试中...', clear: '清空', saveToLibrary: '保存到提示词库', saveSuccess: '保存成功!', variables: '变量设置', role: '角色', task: '任务', outputFormat: '输出格式', constraints: '约束条件', insert: '插入', testResult: '测试结果', saveDialogTitle: '保存提示词', saveTitle: '标题 *', saveDesc: '描述', saveTags: '标签', saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试', saving: '保存中...' },
practices: { title: 'AI 练习', desc: '通过实战场景练习 AI 技能,获得即时评分反馈', submit: '提交答案', submitting: '评分中...', score: '评分', feedback: '反馈', criteria: '评分项', duration: '用时', seconds: '秒', startPractice: '开始练习', yourAnswer: '你的回答', answerPlaceholder: '在此输入你的回答...', hint: '提示', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', categories: { general: '通用', coding: '编程', writing: '写作', analysis: '数据分析', customer: '客服', creative: '创意' }, loginRequired: '请登录后提交练习', alreadySubmitted: '已提交,查看评分', viewSubmission: '查看评分', submissions: '我的提交', noSubmissions: '还没有提交记录', goPractice: '去练习', scoreRange: '{score}/{max} 分', retry: '重新练习', status: { SUBMITTED: '待评分', SCORED: '已评分' } },
packages: { title: '用量包', desc: '购买额外 AI 沙盒使用次数', buy: '立即购买', popular: '最受欢迎', save: '省 {amount}', quota: '{n} 次', price: '¥{price}', buying: '购买中...', package10: '10 次体验包', package10Desc: '适合偶尔使用', package50: '50 次标准包', package50Desc: '¥0.20/次,性价比之选', package300: '300 次畅享包', package300Desc: '¥0.17/次,高频用户首选', currentQuota: '当前用量', dailyQuota: '今日已用 {used}/{limit} 次', extraQuota: '剩余额外次数:{n} 次', quotaExhausted: '免费次数已用完', buyMore: '购买额外次数', purchaseSuccess: '购买成功!已增加 {n} 次使用次数', purchaseFailed: '购买失败,请重试', orUpgrade: '或升级会员获取不限次使用' },
enterprise: { title: '企业版', nav: '企业版', desc: '为团队提供安全、可控的 AI 培训环境', heroTitle: '让团队安全地用好 AI', heroDesc: '75% 的员工在未经IT批准的情况下使用 AI 工具。企业版提供数据隔离的 AI 沙盒环境,让团队在安全可控的范围内学习、练习和应用 AI。', heroCta: '免费试用', heroCtaSub: '无需信用卡', problemTitle: 'Shadow AI 正在威胁你的数据安全', problemDesc: '越来越多的员工在工作中自行使用公共 AI 工具,企业数据面临前所未有的泄漏风险。', statShadowAi: '75%', statShadowAiDesc: '员工使用未经IT批准的AI工具', statDataLeak: '48%', statDataLeakDesc: '员工将公司数据输入公共AI', statIpLeak: '43%', statIpLeakDesc: '企业发生过IP泄漏事件', statTrainingGap: '68%', statTrainingGapDesc: '城市教师未接受AI培训', solutionTitle: '企业版解决方案', solutionDesc: '数据隔离的 AI 培训环境,让团队安全地掌握 AI 技能', featureIsolation: '数据隔离', featureIsolationDesc: '所有对话数据存储在企业专属实例中,不会用于模型训练,也不会泄漏到公共网络', featureManage: '团队管理', featureManageDesc: '一键邀请团队成员,分级管理权限,查看团队使用情况', featureAnalytics: '学习分析', featureAnalyticsDesc: '跟踪团队 AI 技能掌握度,识别薄弱环节,针对性提升', featureSandbox: '专属沙盒', featureSandboxDesc: '成员可在安全隔离的 AI 沙盒中练习,支持主流大模型', featureReport: '使用报告', featureReportDesc: '自动生成团队 AI 使用报告,包含使用量、技能分布和改进建议', featureCustom: '定制培训', featureCustomDesc: '根据业务场景定制练习内容,让 AI 培训与工作直接挂钩', pricingTitle: '灵活定价', pricingDesc: '按团队规模灵活选择', pricingFree: '免费版', pricingFreePrice: '¥0', pricingFreeDesc: '个人体验 AI 学习', pricingFreeFeature1: '10 次/日沙盒使用', pricingFreeFeature2: '1 个模型', pricingFreeFeature3: '基础练习', pricingBiz: '企业版', pricingBizPrice: '¥199', pricingBizPerUser: '/人/年', pricingBizDesc: '团队 AI 技能提升', pricingBizFeature1: '不限次沙盒使用', pricingBizFeature2: '全部模型', pricingBizFeature3: '数据隔离', pricingBizFeature4: '团队管理后台', pricingBizFeature5: '学习分析报告', pricingBizFeature6: '专属客户成功', pricingCta: '联系销售', pricingCtaFree: '免费开始', faqTitle: '常见问题', faq1q: '企业版与免费版有什么区别?', faq1a: '企业版提供数据隔离的专属 AI 环境、团队管理后台、学习分析报告和优先技术支持。所有对话数据不会用于模型训练,确保企业信息安全。', faq2q: '如何邀请团队成员?', faq2a: '创建企业组织后,可以通过邮箱或用户ID邀请成员加入。成员加入后即可使用企业专属的 AI 沙盒和学习资源。', faq3q: '数据安全如何保障?', faq3a: '企业版所有数据存储在独立的数据库实例中,不会与公共版共享。AI 对话数据不会被用于模型训练或改进,确保企业数据隐私。', faq4q: '支持哪些 AI 模型?', faq4a: '企业版支持全部主流 AI 模型,包括 GPT-4、Claude、DeepSeek 等,团队成员可以在沙盒中自由切换对比。', faq5q: '可以按需定制练习内容吗?', faq5a: '可以。企业版支持根据业务场景定制练习题目和评分标准,让 AI 培训与工作实际需求紧密结合。', dashboard: '管理后台', myTeam: '我的团队', createOrg: '创建团队', orgName: '团队名称', orgDesc: '团队描述', members: '成员管理', memberCount: '{n} 人', inviteMember: '邀请成员', inviteById: '用户ID邀请', memberId: '用户ID', invite: '邀请', removeMember: '移除', removeConfirm: '确认移除该成员?', usageReport: '使用报告', totalMembers: '总成员', totalUsage: '总使用次数', completionRate: '完成率', noOrg: '还没有创建团队', createOrgHint: '创建一个团队,开始管理成员和 AI 学习', noMembers: '暂无成员', inviteHint: '邀请成员加入你的团队', learnMore: '了解更多', selectOrg: '选择一个团队查看详情' },
assistant: { title: 'AI 助手', greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:', placeholder: '输入你的问题...', error: '出错啦:{message}', loginPrompt: '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。' },
}
+2 -2
View File
@@ -59,8 +59,8 @@ export function getAssistantContext(pathname: string): AssistantContext {
const sorted = Object.keys(PAGE_CONTEXTS).sort((a, b) => b.length - a.length)
for (const key of sorted) {
if (pathname.startsWith(key)) {
return PAGE_CONTEXTS[key]
return { ...PAGE_CONTEXTS[key], page: key }
}
}
return PAGE_CONTEXTS['/']
return { ...PAGE_CONTEXTS['/'], page: '/' }
}
+5 -10
View File
@@ -1,6 +1,7 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { setTokens, clearTokens, initAuth, isLoggedIn, apiLogout, getToken } from './auth';
interface AuthContextType {
isLoggedIn: boolean;
@@ -17,28 +18,22 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
setIsLoggedIn(!!localStorage.getItem('token'));
setInitialized(true);
initAuth().then(() => setInitialized(true));
}, []);
const login = useCallback((token: string, refreshTk?: string) => {
localStorage.setItem('token', token);
if (refreshTk) localStorage.setItem('refreshToken', refreshTk);
setIsLoggedIn(true);
setTokens(token, refreshTk);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
setIsLoggedIn(false);
apiLogout();
}, []);
return (
<AuthContext.Provider value={{ isLoggedIn, initialized, login, logout }}>
<AuthContext.Provider value={{ isLoggedIn: isLoggedIn() || !!getToken(), initialized, login, logout }}>
{children}
</AuthContext.Provider>
);
+52 -15
View File
@@ -1,40 +1,69 @@
import { API_BASE } from '@/lib/config';
let memoryToken: string | null = null;
let memoryRefreshToken: string | null = null;
let memoryAdminToken: string | null = null;
let initPromise: Promise<void> | null = null;
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
return memoryToken;
}
export function getAdminToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('adminToken');
return memoryAdminToken;
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refreshToken');
return memoryRefreshToken;
}
export function setTokens(accessToken: string, refreshToken?: string) {
localStorage.setItem('token', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
memoryToken = accessToken;
if (refreshToken) memoryRefreshToken = refreshToken;
}
export function clearTokens() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
memoryToken = null;
memoryRefreshToken = null;
}
export function setAdminToken(token: string) {
memoryAdminToken = token;
}
export function clearAdminToken() {
localStorage.removeItem('adminToken');
memoryAdminToken = null;
}
export function isLoggedIn(): boolean {
return !!getToken();
return !!memoryToken;
}
export function isAdminLoggedIn(): boolean {
return !!getAdminToken();
return !!memoryAdminToken;
}
export async function initAuth(): Promise<void> {
if (initPromise) return initPromise;
if (typeof window === 'undefined') return;
if (memoryToken) return;
initPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/auth/verify`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.accessToken) memoryToken = data.accessToken;
return;
}
} catch {
}
})();
await initPromise;
initPromise = null;
}
export async function adminApiFetch(url: string, opts: RequestInit = {}): Promise<Response> {
@@ -57,6 +86,7 @@ export async function refreshToken(): Promise<string | null> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: token }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
@@ -76,15 +106,22 @@ export async function apiFetch(url: string, opts: RequestInit = {}): Promise<Res
};
if (token) headers['Authorization'] = `Bearer ${token}`;
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
if (res.status === 401 && token) {
const newToken = await refreshToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
}
}
return res;
}
export async function apiLogout(): Promise<void> {
try {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
} catch {}
clearTokens();
}
+9 -1
View File
@@ -1,4 +1,12 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
export const API_BASE = (() => {
const configured = process.env.NEXT_PUBLIC_API_URL;
// During SSG/build in Node.js, relative paths like /api/v1 won't resolve
// Use the local backend directly (assumes backend runs on port 4000 during build)
if (typeof window === 'undefined' && configured?.startsWith('/')) {
return 'http://localhost:4000/api/v1';
}
return configured || '/api/v1';
})();
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://yuzhiran.com';