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:
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">← 返回列表</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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">← 返回发现</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">← {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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
← 返回发现
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">← {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">
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user