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(() => {