feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import HomePage from '@/app/page';
describe('HomePage', () => {
it('should render hero section', () => {
render(<HomePage />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading.textContent).toMatch(/让/);
expect(heading.textContent).toMatch(/每个人/);
expect(heading.textContent).toMatch(/都能用好 AI/);
expect(screen.getByText(/免费 AI 知识社区/)).toBeInTheDocument();
});
it('should render feature section title', () => {
render(<HomePage />);
expect(screen.getByText('为什么选择宇之然?')).toBeInTheDocument();
});
it('should render all four features', () => {
render(<HomePage />);
expect(screen.getByText('分领域指南')).toBeInTheDocument();
expect(screen.getByText('AI 沙盒实战')).toBeInTheDocument();
expect(screen.getByText('提示词库')).toBeInTheDocument();
expect(screen.getByText('持续更新')).toBeInTheDocument();
});
it('should render stats section', () => {
render(<HomePage />);
expect(screen.getByText('50+')).toBeInTheDocument();
expect(screen.getByText('200+')).toBeInTheDocument();
expect(screen.getByText('30+')).toBeInTheDocument();
expect(screen.getByText('10,000+')).toBeInTheDocument();
});
it('should render course preview section', () => {
render(<HomePage />);
expect(screen.getByText('热门专题')).toBeInTheDocument();
expect(screen.getByText('AI 通识:零基础入门')).toBeInTheDocument();
expect(screen.getByText('提示词工程从入门到精通')).toBeInTheDocument();
expect(screen.getByText('用 AI 提升 10 倍办公效率')).toBeInTheDocument();
});
it('should render sandbox preview section', () => {
render(<HomePage />);
expect(screen.getByText('AI 沙盒')).toBeInTheDocument();
expect(screen.getByText('在线体验 AI 对话,边学边练')).toBeInTheDocument();
});
it('should render CTA section', () => {
render(<HomePage />);
expect(screen.getByText('准备好开启 AI 之旅了吗?')).toBeInTheDocument();
expect(screen.getByText('立即注册,免费探索所有内容')).toBeInTheDocument();
});
it('should have CTA buttons with correct links', () => {
render(<HomePage />);
const startLearning = screen.getByText('开始探索').closest('a');
expect(startLearning).toHaveAttribute('href', '/courses');
const registerBtn = screen.getAllByText('免费注册');
expect(registerBtn.length).toBeGreaterThanOrEqual(1);
});
it('should render course descriptions', () => {
render(<HomePage />);
expect(screen.getByText(/面向零基础用户/)).toBeInTheDocument();
expect(screen.getByText(/系统学习提示词编写技巧/)).toBeInTheDocument();
expect(screen.getByText(/学习使用 AI 工具/)).toBeInTheDocument();
});
it('should render sandbox with mock chat interface', () => {
render(<HomePage />);
expect(screen.getByText(/你好!我是宇之然 AI 助手/)).toBeInTheDocument();
expect(screen.getByText(/正在输入/)).toBeInTheDocument();
});
});
+47
View File
@@ -0,0 +1,47 @@
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '关于宇之然',
description: '关于宇之然 AI 学习与实践平台',
};
export default function AboutPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8"></h1>
<section className="mb-10">
<h2 className="text-xl font-semibold text-foreground mb-3">使</h2>
<p className="text-muted-foreground leading-relaxed">
AI AI AI
</p>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI AI
</p>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<ul className="space-y-2">
<li><Link href="/privacy" className="text-brand-600 hover:text-brand-700 underline"></Link></li>
<li><Link href="/terms" className="text-brand-600 hover:text-brand-700 underline"></Link></li>
<li><Link href="/ai-agreement" className="text-brand-600 hover:text-brand-700 underline">AI </Link></li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
contact@yuzhiran.com<br />
<br />
</p>
</section>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface PendingComment {
id: number;
content: string;
status: string;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
post: { id: number; title: string };
}
export default function AdminCommentsPage() {
const [comments, setComments] = useState<PendingComment[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
function headers() {
const t = localStorage.getItem('adminToken');
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
const load = useCallback(async () => {
setLoading(true);
try {
const url = tab === 'pending'
? '/admin/comments/pending'
: tab === 'approved'
? '/admin/comments?status=PUBLISHED'
: '/admin/comments?status=REJECTED';
const res = await fetch(`${base}${url}`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setComments(data.items || []);
}
} catch (e) { console.error(e) }
setLoading(false);
}, [tab, base]);
useEffect(() => { load(); }, [load]);
async function approve(id: number) {
try {
const res = await fetch(`${base}/admin/comments/${id}/approve`, {
method: 'PUT', headers: headers(),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
}
async function reject(id: number) {
const reason = prompt('请输入拒绝原因:');
if (!reason) return;
try {
const res = await fetch(`${base}/admin/comments/${id}/reject`, {
method: 'PUT', headers: headers(), body: JSON.stringify({ reason }),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
}
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-4xl mx-auto p-6">
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
{(['pending', 'approved', 'rejected'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
tab === t ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}>
{t === 'pending' ? '待审核' : t === 'approved' ? '已通过' : '已拒绝'}
</button>
))}
</div>
{loading ? (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="bg-card rounded-xl border border-border p-4">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
))}
</div>
) : comments.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p className="text-4xl mb-4">{tab === 'pending' ? '✅' : '📝'}</p>
<p>{tab === 'pending' ? '暂无待审核评论' : tab === 'approved' ? '暂无已通过评论' : '暂无已拒绝评论'}</p>
</div>
) : (
<div className="space-y-3">
{comments.map(c => (
<div key={c.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-xs font-bold">
{c.user.nickname?.[0] || 'U'}
</div>
<span className="text-sm font-medium text-foreground">{c.user.nickname || '用户'}</span>
<span className="text-xs text-muted-foreground">· {new Date(c.createdAt).toLocaleString('zh-CN')}</span>
</div>
<p className="text-sm text-foreground mb-2">{c.content}</p>
<div className="flex items-center justify-between">
<a href={`/community/${c.post.id}`} target="_blank"
className="text-xs text-brand-600 hover:underline">
: {c.post.title}
</a>
{tab === 'pending' && (
<div className="flex gap-2">
<button onClick={() => approve(c.id)}
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700">
</button>
<button onClick={() => reject(c.id)}
className="px-3 py-1 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600">
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function EditContentPage() {
const params = useParams();
const router = useRouter();
const [form, setForm] = useState({ title: '', summary: '', content: '', cover: '', contentType: 'article' });
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadContent(); }, [params.id]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
function token() { return localStorage.getItem('adminToken'); }
function headers() {
const t = token();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
async function loadContent() {
try {
const res = await fetch(`${base}/api/v1/contents/${params.id}`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setForm({ title: data.title || '', summary: data.summary || '', content: data.content || '', cover: data.cover || '', contentType: data.contentType || 'article' });
}
} catch {}
setLoading(false);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim() || !form.content.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`${base}/api/v1/contents/${params.id}`, {
method: 'PUT', headers: headers(), body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/contents');
} catch {}
setSubmitting(false);
}
if (loading) return (
<div className="p-6">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-64 w-full max-w-2xl" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.summary} onChange={e => setForm(f => ({ ...f, summary: e.target.value }))} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
rows={8} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select value={form.contentType} onChange={e => setForm(f => ({ ...f, contentType: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="article"></option>
<option value="news"></option>
<option value="tutorial"></option>
</select>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim() || !form.content.trim()}>
{submitting ? '保存中...' : '保存'}
</Button>
<Link href="/admin/contents"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/contents`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import EditContentPage from './edit-content';
export default function Page() {
return <EditContentPage />;
}
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
export default function NewContentPage() {
const router = useRouter();
const [form, setForm] = useState({ title: '', summary: '', content: '', cover: '', contentType: 'article' });
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim() || !form.content.trim()) return;
setSubmitting(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/contents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/contents');
} catch {}
setSubmitting(false);
}
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="内容标题" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.summary} onChange={e => setForm(f => ({ ...f, summary: e.target.value }))} placeholder="简短摘要" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
rows={8} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="内容正文..." required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} placeholder="https://..." />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select value={form.contentType} onChange={e => setForm(f => ({ ...f, contentType: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="article"></option>
<option value="news"></option>
<option value="tutorial"></option>
</select>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim() || !form.content.trim()}>
{submitting ? '创建中...' : '创建内容'}
</Button>
<Link href="/admin/contents"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Content {
id: number;
title: string;
status: string;
viewCount: number;
category?: { name: string };
createdAt: string;
}
export default function AdminContents() {
const router = useRouter();
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadContents(); }, []);
async function loadContents() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/contents?pageSize=50`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setContents(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleStatus(id: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/contents/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED' }),
});
loadContents();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="bg-card border-b border-border px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground"></h1>
<Link href="/admin/contents/new" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{contents.map(content => (
<tr key={content.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground">{content.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{content.title}</div>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">{content.category?.name || '-'}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
content.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{content.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">&#128065; {content.viewCount}</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link href={`/admin/contents/${content.id}`} className="text-xs text-brand-600 hover:underline">
</Link>
<button
onClick={() => toggleStatus(content.id, content.status)}
className={`text-xs ${
content.status === 'PUBLISHED'
? 'text-red-600 hover:text-red-800'
: 'text-green-600 hover:text-green-800'
}`}
>
{content.status === 'PUBLISHED' ? '下架' : '发布'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{contents.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,95 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function EditCoursePage() {
const params = useParams();
const router = useRouter();
const [form, setForm] = useState({ title: '', description: '', cover: '', isFree: true });
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadCourse(); }, [params.id]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
function token() { return localStorage.getItem('adminToken'); }
function headers() {
const t = token();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
async function loadCourse() {
try {
const res = await fetch(`${base}/api/v1/courses/${params.id}`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setForm({ title: data.title || '', description: data.description || '', cover: data.cover || '', isFree: data.isFree ?? true });
}
} catch {}
setLoading(false);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`${base}/api/v1/courses/${params.id}`, {
method: 'PUT', headers: headers(), body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/courses');
} catch {}
setSubmitting(false);
}
if (loading) return (
<div className="p-6">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-64 w-full max-w-2xl" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={3} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="isFree" checked={form.isFree} onChange={e => setForm(f => ({ ...f, isFree: e.target.checked }))}
className="rounded border-input" />
<label htmlFor="isFree" className="text-sm text-foreground"></label>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim()}>
{submitting ? '保存中...' : '保存'}
</Button>
<Link href="/admin/courses"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/courses`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import EditCoursePage from './edit-course';
export default function Page() {
return <EditCoursePage />;
}
@@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
export default function NewCoursePage() {
const router = useRouter();
const [form, setForm] = useState({ title: '', description: '', cover: '', isFree: true });
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim()) return;
setSubmitting(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/courses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/courses');
} catch {}
setSubmitting(false);
}
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="输入课程标题" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={3} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="课程简介" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.cover} onChange={e => setForm(f => ({ ...f, cover: e.target.value }))} placeholder="https://..." />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="isFree" checked={form.isFree} onChange={e => setForm(f => ({ ...f, isFree: e.target.checked }))}
className="rounded border-input" />
<label htmlFor="isFree" className="text-sm text-foreground"></label>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim()}>
{submitting ? '创建中...' : '创建课程'}
</Button>
<Link href="/admin/courses"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
+144
View File
@@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Course {
id: number;
title: string;
isFree: boolean;
status: string;
category?: { name: string };
createdAt: string;
}
export default function AdminCourses() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadCourses(); }, []);
async function loadCourses() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/courses?pageSize=50`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setCourses(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleStatus(id: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/courses/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED' }),
});
loadCourses();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground"></h1>
<Link href="/admin/courses/new" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{courses.map(course => (
<tr key={course.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground">{course.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{course.title}</div>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">{course.category?.name || '-'}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
course.isFree ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400'
}`}>
{course.isFree ? '免费' : '付费'}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
course.status === 'PUBLISHED' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
}`}>
{course.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(course.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link href={`/admin/courses/${course.id}`} className="text-xs text-brand-600 hover:underline">
</Link>
<button
onClick={() => toggleStatus(course.id, course.status)}
className={`text-xs transition-colors ${
course.status === 'PUBLISHED'
? 'text-red-600 hover:text-red-800'
: 'text-green-600 hover:text-green-800'
}`}
>
{course.status === 'PUBLISHED' ? '下架' : '发布'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{courses.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,254 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
export default function OrgDetailPage() {
const params = useParams();
const orgId = Number(params.id);
const [org, setOrg] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showAddMember, setShowAddMember] = useState(false);
const [showAssignCourse, setShowAssignCourse] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [memberUserId, setMemberUserId] = useState('');
const [courseId, setCourseId] = useState('');
const [courseDeadline, setCourseDeadline] = useState('');
const [courses, setCourses] = useState<any[]>([]);
const [editForm, setEditForm] = useState({ name: '', description: '', contactName: '', contactPhone: '' });
function getToken() { return localStorage.getItem('adminToken'); }
function headers() {
const t = getToken();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
useEffect(() => { loadOrg(); loadCourses(); }, [orgId]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
async function loadOrg() {
try {
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, { headers: headers() });
if (res.ok) setOrg(await res.json());
} catch {}
setLoading(false);
}
async function loadCourses() {
try {
const res = await fetch(`${base}/api/v1/courses`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setCourses(data.items || []);
}
} catch {}
}
async function addMember() {
const uid = Number(memberUserId);
if (!uid) return;
try {
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members`, {
method: 'POST', headers: headers(),
body: JSON.stringify({ userId: uid }),
});
if (res.ok) {
setShowAddMember(false);
setMemberUserId('');
loadOrg();
}
} catch {}
}
async function removeMember(userId: number) {
try {
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members/${userId}`, {
method: 'DELETE', headers: headers(),
});
loadOrg();
} catch {}
}
async function assignCourse() {
const cid = Number(courseId);
if (!cid) return;
try {
const body: any = { courseId: cid };
if (courseDeadline) body.deadline = courseDeadline;
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments`, {
method: 'POST', headers: headers(),
body: JSON.stringify(body),
});
if (res.ok) {
setShowAssignCourse(false);
setCourseId('');
setCourseDeadline('');
loadOrg();
}
} catch {}
}
async function updateOrg() {
try {
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, {
method: 'PUT', headers: headers(),
body: JSON.stringify(editForm),
});
if (res.ok) {
setShowEdit(false);
loadOrg();
}
} catch {}
}
async function removeAssignment(courseId: number) {
try {
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments/${courseId}`, {
method: 'DELETE', headers: headers(),
});
loadOrg();
} catch {}
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-24 w-full rounded-xl mb-6" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Skeleton className="h-48 rounded-xl" />
<Skeleton className="h-48 rounded-xl" />
</div>
</div>
);
if (!org) return <div className="text-center py-20 text-muted-foreground"></div>;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-card rounded-xl border border-border p-6 mb-8">
<Link href="/admin/enterprise" className="text-sm text-muted-foreground hover:text-brand-600 mb-4 inline-block">&larr; </Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground mb-2">{org.name}</h1>
{org.description && <p className="text-muted-foreground mb-4">{org.description}</p>}
<div className="flex gap-6 text-sm text-muted-foreground">
<span>: {org.contactName || '-'}</span>
<span>: {org.contactPhone || '-'}</span>
<span>: {org._count?.members || 0}</span>
<span>: {org._count?.assignments || 0}</span>
</div>
</div>
<button onClick={() => {
setEditForm({ name: org.name, description: org.description || '', contactName: org.contactName || '', contactPhone: org.contactPhone || '' });
setShowEdit(!showEdit);
}} className="text-sm px-3 py-1.5 border border-border rounded-lg hover:bg-accent shrink-0 ml-4">
{showEdit ? '取消' : '编辑'}
</button>
</div>
{showEdit && (
<div className="mt-4 pt-4 border-t border-border space-y-3">
<input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
placeholder="组织名称" className="w-full px-3 py-2 border border-input rounded-lg text-sm" />
<input value={editForm.description} onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
placeholder="组织描述" className="w-full px-3 py-2 border border-input rounded-lg text-sm" />
<div className="flex gap-2">
<input value={editForm.contactName} onChange={e => setEditForm(f => ({ ...f, contactName: e.target.value }))}
placeholder="联系人" className="flex-1 px-3 py-2 border border-input rounded-lg text-sm" />
<input value={editForm.contactPhone} onChange={e => setEditForm(f => ({ ...f, contactPhone: e.target.value }))}
placeholder="联系电话" className="flex-1 px-3 py-2 border border-input rounded-lg text-sm" />
</div>
<button onClick={updateOrg} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-foreground"></h2>
<button onClick={() => setShowAddMember(!showAddMember)}
className="text-sm px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
{showAddMember ? '取消' : '+ 添加成员'}
</button>
</div>
{showAddMember && (
<div className="flex gap-2 mb-4">
<input value={memberUserId} onChange={e => setMemberUserId(e.target.value)}
placeholder="用户ID" type="number" className="flex-1 px-3 py-2 border border-input rounded-lg text-sm" />
<button onClick={addMember} className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</div>
)}
<div className="bg-card rounded-xl border border-border divide-y">
{org.members?.map((m: any) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-xs font-bold">
{m.user?.nickname?.[0] || 'U'}
</div>
<div>
<div className="text-sm font-medium">{m.user?.nickname || '用户'}</div>
<div className="text-xs text-muted-foreground">{m.user?.email || m.user?.phone || 'ID: ' + m.userId}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded ${m.role === 'ADMIN' ? 'bg-purple-50 text-purple-600' : 'bg-muted text-muted-foreground'}`}>
{m.role === 'ADMIN' ? '管理员' : '成员'}
</span>
{m.role !== 'ADMIN' && (
<button onClick={() => removeMember(m.userId)} className="text-xs text-red-500 hover:text-red-600"></button>
)}
</div>
</div>
))}
{(!org.members || org.members.length === 0) && (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-foreground"></h2>
<button onClick={() => setShowAssignCourse(!showAssignCourse)}
className="text-sm px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
{showAssignCourse ? '取消' : '+ 分配课程'}
</button>
</div>
{showAssignCourse && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<select value={courseId} onChange={e => setCourseId(e.target.value)}
className="flex-1 px-3 py-2 border border-input rounded-lg text-sm">
<option value=""></option>
{courses.map((c: any) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
<button onClick={assignCourse} className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</div>
<input type="date" value={courseDeadline} onChange={e => setCourseDeadline(e.target.value)}
className="px-3 py-2 border border-input rounded-lg text-sm" />
</div>
)}
<div className="bg-card rounded-xl border border-border divide-y">
{org.assignments?.map((a: any) => (
<div key={a.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{a.course?.title || '课程#' + a.courseId}</div>
{a.deadline && <span className="text-xs text-muted-foreground">: {new Date(a.deadline).toLocaleDateString()}</span>}
</div>
<button onClick={() => removeAssignment(a.courseId)} className="text-xs text-red-500 hover:text-red-600"></button>
</div>
))}
{(!org.assignments || org.assignments.length === 0) && (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((org: any) => ({ id: String(org.id) }));
} catch {
return [{ id: '1' }];
}
}
import OrgDetailPage from './org-detail';
export default function Page() {
return <OrgDetailPage />;
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((org: any) => ({ id: String(org.id) }));
} catch {
return [{ id: '1' }];
}
}
import ReportPage from './report-page';
export default function Page() {
return <ReportPage />;
}
@@ -0,0 +1,129 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
export default function OrgReportPage() {
const params = useParams();
const orgId = Number(params.id);
const [report, setReport] = useState<any>(null);
const [loading, setLoading] = useState(true);
function getToken() { return localStorage.getItem('adminToken'); }
function headers() {
const t = getToken();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
useEffect(() => { loadReport(); }, [orgId]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
async function loadReport() {
try {
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/report`, { headers: headers() });
if (res.ok) setReport(await res.json());
} catch {}
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Skeleton className="h-48 rounded-xl" />
<Skeleton className="h-48 rounded-xl" />
</div>
</div>
);
if (!report) return <div className="text-center py-20 text-muted-foreground"></div>;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href={`/admin/enterprise/${orgId}`} className="text-sm text-muted-foreground hover:text-brand-600 mb-4 inline-block">&larr; </Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground">{report.organization.name} - </h1>
<p className="text-muted-foreground mt-1"> {report.organization.memberCount} </p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
{[
{ label: '总成员', value: report.summary.totalMembers, color: 'text-brand-600' },
{ label: '总课程', value: report.summary.totalCourses, color: 'text-purple-600' },
{ label: '已完成课时', value: report.summary.completedLessons, color: 'text-green-600' },
{ label: '完成率', value: `${report.summary.completionRate}%`, color: 'text-brand-600' },
].map(stat => (
<div key={stat.label} className="bg-muted/50 rounded-xl p-6">
<div className={`text-3xl font-bold ${stat.color}`}>{stat.value}</div>
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-lg font-bold text-foreground mb-4"></h2>
<div className="bg-card rounded-xl border border-border divide-y">
{report.memberProgress?.map((mp: any) => (
<div key={mp.user.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-xs font-bold">
{mp.user.nickname?.[0] || 'U'}
</div>
<div className="text-sm font-medium">{mp.user.nickname || '用户'}</div>
</div>
<div className="text-sm text-muted-foreground">{mp.completedLessons} </div>
</div>
))}
{(!report.memberProgress || report.memberProgress.length === 0) && (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
</div>
<div>
<h2 className="text-lg font-bold text-foreground mb-4"></h2>
<div className="bg-card rounded-xl border border-border divide-y">
{report.assignments?.map((a: any) => {
const progress = report.summary.progressByCourse?.find((p: any) => p.courseId === a.courseId);
return (
<div key={a.id} className="px-4 py-3">
<div className="text-sm font-medium mb-2">{a.course?.title || '课程#' + a.courseId}</div>
{progress && (
<div className="flex items-center gap-3">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-brand-500 rounded-full"
style={{ width: `${Math.min(100, (progress.completedLessons / Math.max(progress.totalUniqueLessons, 1)) * 100)}%` }} />
</div>
<span className="text-xs text-muted-foreground">{progress.completedLessons}/{progress.totalUniqueLessons}</span>
</div>
)}
</div>
);
})}
{(!report.assignments || report.assignments.length === 0) && (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
</div>
</div>
<div className="mt-8 text-right">
<button onClick={() => window.print()}
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-accent">
()
</button>
</div>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Organization {
id: number; name: string; description?: string;
contactName?: string; contactPhone?: string;
status: string; memberCount: number;
_count: { members: number; assignments: number };
createdAt: string;
}
export default function EnterprisePage() {
const router = useRouter();
const [orgs, setOrgs] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', description: '', contactName: '', contactPhone: '' });
useEffect(() => { loadOrgs(); }, []);
function getToken() { return localStorage.getItem('adminToken'); }
function headers() {
const t = getToken();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
async function loadOrgs() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/enterprise/organizations`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setOrgs(data.items || []);
}
} catch {}
setLoading(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/enterprise/organizations`, {
method: 'POST', headers: headers(),
body: JSON.stringify(form),
});
if (res.ok) {
setShowCreate(false);
setForm({ name: '', description: '', contactName: '', contactPhone: '' });
loadOrgs();
}
} catch {}
}
if (loading) return (
<div className="p-6">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
</div>
);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
{showCreate ? '取消' : '+ 创建组织'}
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="bg-card rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="组织名称 *" required className="px-3 py-2 border border-border rounded-lg text-sm" />
<input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })}
placeholder="联系人" className="px-3 py-2 border border-border rounded-lg text-sm" />
<input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })}
placeholder="联系电话" className="px-3 py-2 border border-border rounded-lg text-sm" />
<input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="描述" className="px-3 py-2 border border-border rounded-lg text-sm" />
</div>
<button type="submit" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</form>
)}
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-6 py-3 text-sm font-medium text-muted-foreground"></th>
<th className="text-left px-6 py-3 text-sm font-medium text-muted-foreground"></th>
<th className="text-center px-6 py-3 text-sm font-medium text-muted-foreground"></th>
<th className="text-center px-6 py-3 text-sm font-medium text-muted-foreground"></th>
<th className="text-center px-6 py-3 text-sm font-medium text-muted-foreground"></th>
<th className="text-right px-6 py-3 text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{orgs.map(org => (
<tr key={org.id} className="border-b border-border hover:bg-accent/50">
<td className="px-6 py-4">
<div className="font-medium text-foreground">{org.name}</div>
{org.description && <div className="text-xs text-muted-foreground mt-0.5">{org.description}</div>}
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{org.contactName || '-'}<br />
{org.contactPhone && <span className="text-xs text-muted-foreground">{org.contactPhone}</span>}
</td>
<td className="px-6 py-4 text-center text-sm">{org._count?.members || 0}</td>
<td className="px-6 py-4 text-center text-sm">{org._count?.assignments || 0}</td>
<td className="px-6 py-4 text-center">
<span className={`text-xs px-2 py-0.5 rounded ${org.status === 'ACTIVE' ? 'bg-green-50 text-green-600' : 'bg-muted text-muted-foreground'}`}>
{org.status === 'ACTIVE' ? '启用' : '停用'}
</span>
</td>
<td className="px-6 py-4 text-right">
<Link href={`/admin/enterprise/${org.id}`}
className="text-sm text-brand-600 hover:text-brand-700 mr-4"></Link>
<Link href={`/admin/enterprise/${org.id}/report`}
className="text-sm text-brand-600 hover:text-brand-700"></Link>
</td>
</tr>
))}
{orgs.length === 0 && (
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground"></td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard, Users, BookOpen, MessageSquare,
FileText, Wrench, ShoppingCart, Building2, MessageCircle,
} from 'lucide-react';
const sidebarLinks = [
{ href: '/admin', label: '仪表盘', icon: LayoutDashboard },
{ href: '/admin/users', label: '用户管理', icon: Users },
{ href: '/admin/courses', label: '课程管理', icon: BookOpen },
{ href: '/admin/prompts', label: '提示词管理', icon: MessageSquare },
{ href: '/admin/contents', label: '内容管理', icon: FileText },
{ href: '/admin/tools', label: '工具管理', icon: Wrench },
{ href: '/admin/orders', label: '订单管理', icon: ShoppingCart },
{ href: '/admin/enterprise', label: '企业版管理', icon: Building2 },
{ href: '/admin/comments', label: '评论审核', icon: MessageCircle },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [checked, setChecked] = useState(false);
const isLoginPage = pathname === '/admin/login';
useEffect(() => {
const token = localStorage.getItem('adminToken');
if (!token && !isLoginPage) {
router.replace('/admin/login');
} else {
setChecked(true);
}
}, [isLoginPage, router]);
if (isLoginPage) return <>{children}</>;
if (!checked) return <div className="min-h-[calc(100vh-4rem)]" />;
function isActive(href: string) {
if (href === '/admin') return pathname === '/admin';
return pathname.startsWith(href) && href !== '/admin';
}
return (
<div className="min-h-[calc(100vh-4rem)] bg-background flex">
<aside className="w-56 border-r border-border bg-card shrink-0 hidden md:block">
<nav className="p-3 space-y-1">
{sidebarLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-colors ${
isActive(link.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Icon className="h-4 w-4" />
<span>{link.label}</span>
</Link>
);
})}
</nav>
</aside>
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
export default function AdminLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ username: '', password: '' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
if (!form.username || !form.password) { setError('请填写账号和密码'); return; }
setLoading(true);
try {
const res = await fetch(`${API_BASE}/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '管理员登录失败');
localStorage.setItem('adminToken', data.token);
toast.success('管理员登录成功');
router.push('/admin');
} catch (err: any) {
setError(err.message);
}
setLoading(false);
}
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-12">
<Card className="w-full max-w-sm">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-2xl flex items-center justify-center">
<span className="text-white font-bold text-lg">A</span>
</div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="text"
placeholder="管理员账号"
value={form.username}
onChange={(e) => setForm(f => ({ ...f, username: e.target.value }))}
autoFocus
/>
<Input
type="password"
placeholder="密码"
value={form.password}
onChange={(e) => setForm(f => ({ ...f, password: e.target.value }))}
/>
<Button type="submit" disabled={loading} className="w-full">
{loading ? '登录中...' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface Order {
id: number;
orderNo: string;
amount: number;
planType: string;
status: string;
createdAt: string;
user?: { nickname: string };
}
export default function AdminOrders() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadOrders(); }, []);
async function loadOrders() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/orders`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setOrders(data.items || []);
}
} catch {}
setLoading(false);
}
async function refund(orderNo: string) {
if (!confirm('确认要退款吗?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/payment/wxpay/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ outTradeNo: orderNo, amount: 0, reason: '管理员退款' }),
});
alert('退款成功');
loadOrders();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="bg-card border-b border-border px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{orders.map(order => (
<tr key={order.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground font-mono">{order.orderNo}</td>
<td className="px-6 py-4 text-sm text-foreground">{order.user?.nickname || '-'}</td>
<td className="px-6 py-4 text-sm text-foreground font-medium">¥{order.amount}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
order.planType === 'YEARLY' ? 'bg-purple-100 text-purple-700' :
order.planType === 'MONTHLY' ? 'bg-blue-100 text-blue-700' :
'bg-muted text-muted-foreground'
}`}>
{order.planType === 'YEARLY' ? '年卡' : order.planType === 'MONTHLY' ? '月卡' : order.planType}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
order.status === 'PAID' ? 'bg-green-100 text-green-700' :
order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' :
order.status === 'REFUNDED' ? 'bg-red-100 text-red-700' :
'bg-muted text-muted-foreground'
}`}>
{order.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
{order.status === 'PAID' && (
<button
onClick={() => refund(order.orderNo)}
className="text-xs text-red-600 hover:text-red-800"
>
退
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
+100
View File
@@ -0,0 +1,100 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Stats {
totalUsers: number;
totalCourses: number;
totalPrompts: number;
totalPosts: number;
todayOrders: number;
revenue: number;
}
const links = [
{ href: '/admin/users', title: '用户管理', desc: '管理用户、查看分析', color: 'from-blue-500 to-blue-600' },
{ href: '/admin/courses', title: '课程管理', desc: '管理课程内容', color: 'from-green-500 to-green-600' },
{ href: '/admin/prompts', title: '提示词管理', desc: '审核提示词内容', color: 'from-purple-500 to-purple-600' },
{ href: '/admin/contents', title: '内容管理', desc: '管理文章资讯', color: 'from-orange-500 to-orange-600' },
{ 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/comments', title: '评论审核', desc: '审核社区评论', color: 'from-pink-500 to-pink-600' },
];
export default function AdminDashboard() {
const router = useRouter();
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { loadStats(); }, []);
async function loadStats() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/dashboard`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setStats(data.stats || data);
}
} catch {}
setLoading(false);
}
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" />
<Skeleton className="h-10 w-3/4" />
</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">AI平台管理系统</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-3xl font-bold text-blue-600">{stats?.totalUsers || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-3xl font-bold text-green-600">{stats?.totalCourses || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-3xl font-bold text-purple-600">{stats?.totalPrompts || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-3xl font-bold text-orange-600">{stats?.todayOrders || 0}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{links.map(link => (
<Link key={link.href} href={link.href}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md hover:-translate-y-0.5 transition-all">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${link.color} flex items-center justify-center text-white text-lg font-bold mb-3`}>
{link.title[0]}
</div>
<div className="text-lg font-semibold text-foreground mb-1">{link.title}</div>
<p className="text-sm text-muted-foreground">{link.desc}</p>
</Link>
))}
</div>
</div>
</>
);
}
@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function EditPromptPage() {
const params = useParams();
const router = useRouter();
const [form, setForm] = useState({ title: '', content: '', description: '', tags: '', model: '' });
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadPrompt(); }, [params.id]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
function token() { return localStorage.getItem('adminToken'); }
function headers() {
const t = token();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
async function loadPrompt() {
try {
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, { headers: headers() });
if (res.ok) {
const data = await res.json();
setForm({ title: data.title || '', content: data.content || '', description: data.description || '', tags: data.tags || '', model: data.model || '' });
}
} catch {}
setLoading(false);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim() || !form.content.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, {
method: 'PUT', headers: headers(), body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/prompts');
} catch {}
setSubmitting(false);
}
if (loading) return (
<div className="p-6">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-64 w-full max-w-2xl" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
rows={6} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim() || !form.content.trim()}>
{submitting ? '保存中...' : '保存'}
</Button>
<Link href="/admin/prompts"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/prompts`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((p: any) => ({ id: String(p.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
@@ -0,0 +1,72 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
export default function NewPromptPage() {
const router = useRouter();
const [form, setForm] = useState({ title: '', content: '', description: '', tags: '', model: '' });
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim() || !form.content.trim()) return;
setSubmitting(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/prompts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/prompts');
} catch {}
setSubmitting(false);
}
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="提示词标题" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="简短描述" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.content} onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
rows={6} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
placeholder="输入提示词内容..." required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} placeholder="AI,提示词,编程" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} placeholder="GPT-4, Claude 等" />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.title.trim() || !form.content.trim()}>
{submitting ? '创建中...' : '创建提示词'}
</Button>
<Link href="/admin/prompts"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Prompt {
id: number;
title: string;
status: string;
viewCount: number;
likeCount: number;
category?: { name: string };
}
export default function AdminPrompts() {
const router = useRouter();
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadPrompts(); }, []);
async function loadPrompts() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/prompts?pageSize=50`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setPrompts(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleStatus(id: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/prompts/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED' }),
});
loadPrompts();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="bg-card border-b border-border px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground"></h1>
<Link href="/admin/prompts/new" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">/</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{prompts.map(prompt => (
<tr key={prompt.id} className="hover:bg-muted/50">
<td className="px-6 py-4 text-sm text-foreground">{prompt.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{prompt.title}</div>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">{prompt.category?.name || '-'}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
prompt.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{prompt.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
👁 {prompt.viewCount} / {prompt.likeCount}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link href={`/admin/prompts/${prompt.id}`} className="text-xs text-brand-600 hover:underline">
</Link>
<button
onClick={() => toggleStatus(prompt.id, prompt.status)}
className={`text-xs ${
prompt.status === 'PUBLISHED'
? 'text-red-600 hover:text-red-800'
: 'text-green-600 hover:text-green-800'
}`}
>
{prompt.status === 'PUBLISHED' ? '下架' : '发布'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{prompts.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function EditToolPage() {
const params = useParams();
const router = useRouter();
const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', tags: '' });
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadTool(); }, [params.id]);
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
function token() { return localStorage.getItem('adminToken'); }
function headers() {
const t = token();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
async function loadTool() {
try {
const res = await fetch(`${base}/api/v1/tools`, { headers: headers() });
if (res.ok) {
const data = await res.json();
const tool = (data.items || []).find((t: any) => t.id === Number(params.id));
if (tool) setForm({ name: tool.name || '', description: tool.description || '', url: tool.url || '', icon: tool.icon || '', tags: tool.tags || '' });
}
} catch {}
setLoading(false);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) return;
setSubmitting(true);
router.push('/admin/tools');
}
if (loading) return (
<div className="p-6">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-64 w-full max-w-2xl" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<p className="text-sm text-muted-foreground mb-4"></p>
<div className="space-y-3">
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.name}</p></div>
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.description || '-'}</p></div>
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.url}</p></div>
</div>
</Card>
</div>
</>
);
}
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/tools`);
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 />;
}
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
export default function NewToolPage() {
const router = useRouter();
const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', tags: '' });
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) return;
setSubmitting(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/tools`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) router.push('/admin/tools');
} catch {}
setSubmitting(false);
}
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="max-w-2xl mx-auto p-6">
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="工具名称" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={3} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="工具简介" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://..." required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.icon} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))} placeholder="https://..." />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} placeholder="AI,工具,效率" />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.name.trim() || !form.url.trim()}>
{submitting ? '创建中...' : '创建工具'}
</Button>
<Link href="/admin/tools"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card>
</div>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Tool {
id: number;
name: string;
description?: string;
category?: { name: string };
status: string;
}
export default function AdminTools() {
const router = useRouter();
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadTools(); }, []);
async function loadTools() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/tools?pageSize=50`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setTools(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleStatus(id: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/tools/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED' }),
});
loadTools();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="bg-card border-b border-border px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground"></h1>
<Link href="/admin/tools/new" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{tools.map(tool => (
<tr key={tool.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground">{tool.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{tool.name}</div>
<div className="text-xs text-muted-foreground mt-1">{tool.description?.slice(0, 50)}...</div>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">{tool.category?.name || '-'}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
tool.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{tool.status}
</span>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<Link href={`/admin/tools/${tool.id}`} className="text-xs text-brand-600 hover:underline">
</Link>
<button
onClick={() => toggleStatus(tool.id, tool.status)}
className={`text-xs ${
tool.status === 'PUBLISHED'
? 'text-red-600 hover:text-red-800'
: 'text-green-600 hover:text-green-800'
}`}
>
{tool.status === 'PUBLISHED' ? '下架' : '发布'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{tools.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
+140
View File
@@ -0,0 +1,140 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface User {
id: number;
nickname: string;
email?: string;
phone?: string;
status: string;
memberPlan: string;
createdAt: string;
}
export default function AdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsers();
}, []);
async function loadUsers() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setUsers(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleStatus(userId: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' }),
});
loadUsers();
} 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" />
<Skeleton className="h-10 w-3/4" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(user => (
<tr key={user.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground">{user.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{user.nickname || '未设置'}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-muted-foreground">{user.email || user.phone || '-'}</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
user.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{user.status}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
user.memberPlan === 'YEARLY' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' :
user.memberPlan === 'MONTHLY' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' :
'bg-muted text-muted-foreground'
}`}>
{user.memberPlan || 'NONE'}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<button
onClick={() => toggleStatus(user.id, user.status)}
className={`text-xs px-3 py-1 rounded transition-colors ${
user.status === 'ACTIVE'
? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
}`}
>
{user.status === 'ACTIVE' ? '禁用' : '启用'}
</button>
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
</div>
</div>
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 服务协议 - 宇之然',
description: '宇之然 AI 沙盒服务使用协议',
};
export default function AiAgreementPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8">AI </h1>
<p className="text-sm text-muted-foreground mb-8">2025 1 </p>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI "本服务" AI AI
AI
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3">AI </h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li>AI </li>
<li>AI </li>
<li>AI </li>
<li> AI </li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3">使</h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li>使</li>
<li> API</li>
<li></li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li>"现状"</li>
<li> AI </li>
<li>使 AI </li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI contact@yuzhiran.com
</p>
</section>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
'use client';
import { Suspense, useState, FormEvent } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useAuth } from '@/lib/auth-context';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
function AuthForm() {
const searchParams = useSearchParams();
const router = useRouter();
const { login } = useAuth();
const [tab, setTab] = useState<'login' | 'register'>(() =>
searchParams.get('tab') === 'register' ? 'register' : 'login'
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginForm, setLoginForm] = useState({ account: '', password: '' });
const [registerForm, setRegisterForm] = useState({ phone: '', email: '', password: '', confirmPassword: '', nickname: '' });
async function handleLogin(e: FormEvent) {
e.preventDefault();
setError('');
if (!loginForm.account || !loginForm.password) { setError('请填写账号和密码'); return; }
setLoading(true);
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: loginForm.account, password: loginForm.password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '登录失败');
login(data.accessToken, data.refreshToken);
toast.success('登录成功', { description: '欢迎回来!' });
router.push('/');
} catch (err: any) { setError(err.message); }
finally { setLoading(false); }
}
async function handleRegister(e: FormEvent) {
e.preventDefault();
setError('');
const { phone, email, password, confirmPassword, nickname } = registerForm;
if (!phone && !email) { setError('请填写手机号或邮箱'); return; }
if (!password) { setError('请填写密码'); return; }
if (password.length < 6) { setError('密码至少 6 位'); return; }
if (password !== confirmPassword) { setError('两次密码不一致'); return; }
setLoading(true);
try {
const body: Record<string, string> = { password };
if (phone) body.phone = phone;
if (email) body.email = email;
if (nickname) body.nickname = nickname;
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '注册失败');
login(data.accessToken, data.refreshToken);
toast.success('注册成功', { description: '欢迎加入宇之然!' });
router.push('/');
} catch (err: any) { setError(err.message); }
finally { setLoading(false); }
}
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-12">
<Card className="w-full max-w-sm">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-2xl flex items-center justify-center">
<span className="text-white font-bold text-lg">Y</span>
</div>
<CardTitle className="text-xl">{tab === 'login' ? '欢迎回来' : '加入宇之然'}</CardTitle>
<CardDescription>
{tab === 'login' ? '登录继续你的 AI 探索之旅' : '免费注册,开始探索 AI'}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={tab} onValueChange={(v) => { setTab(v as 'login' | 'register'); setError(''); }}>
<TabsList className="w-full mb-6">
<TabsTrigger value="login" className="flex-1"></TabsTrigger>
<TabsTrigger value="register" className="flex-1"></TabsTrigger>
</TabsList>
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
{error}
</div>
)}
<TabsContent value="login">
<form onSubmit={handleLogin} className="space-y-4">
<Input type="text" placeholder="手机号 / 邮箱" value={loginForm.account}
onChange={(e) => setLoginForm({ ...loginForm, account: e.target.value })} />
<Input type="password" placeholder="密码" value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })} />
<Button type="submit" disabled={loading} className="w-full">
{loading ? '登录中...' : '登录'}
</Button>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={handleRegister} className="space-y-4">
<Input type="text" placeholder="手机号(选填)" value={registerForm.phone}
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })} />
<Input type="email" placeholder="邮箱(选填,与手机号至少填一项)" value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })} />
<Input type="text" placeholder="昵称(选填)" value={registerForm.nickname}
onChange={(e) => setRegisterForm({ ...registerForm, nickname: e.target.value })} />
<Input type="password" placeholder="密码(至少 6 位)" value={registerForm.password}
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })} />
<Input type="password" placeholder="确认密码" value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })} />
<Button type="submit" disabled={loading} className="w-full">
{loading ? '注册中...' : '注册'}
</Button>
<p className="text-xs text-muted-foreground text-center leading-relaxed">
{' '}
<Link href="/terms" className="text-brand-600 hover:underline dark:text-brand-400"></Link>
{' '}{' '}
<Link href="/privacy" className="text-brand-600 hover:underline dark:text-brand-400"></Link>
{' '}{' '}
<Link href="/ai-agreement" className="text-brand-600 hover:underline dark:text-brand-400">AI </Link>
</p>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}
export default function AuthPage() {
return (
<Suspense fallback={
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="space-y-4 w-full max-w-sm px-4">
<Skeleton className="h-12 w-12 mx-auto rounded-2xl" />
<Skeleton className="h-6 w-32 mx-auto" />
<Skeleton className="h-4 w-48 mx-auto" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
}>
<AuthForm />
</Suspense>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface Post {
id: number;
title: string;
content: string;
tags?: string;
viewCount: number;
likeCount: number;
createdAt: string;
user?: { id: number; nickname: string; avatar?: string };
}
export default function CircleDetail() {
const params = useParams();
const circleId = Number(params.id);
const [circle, setCircle] = useState<any>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [isMember, setIsMember] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [formTitle, setFormTitle] = useState('');
const [formContent, setFormContent] = useState('');
useEffect(() => { loadData(); }, [circleId]);
async function loadData() {
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const [circleRes, postsRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}`, { headers }),
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/posts`, { headers }),
]);
if (circleRes.ok) setCircle(await circleRes.json());
if (postsRes.ok) {
const data = await postsRes.json();
setPosts(data.items || []);
}
if (token) {
const memRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/membership`, {
headers: { Authorization: `Bearer ${token}` },
});
if (memRes.ok) {
const memData = await memRes.json();
setIsMember(memData.isMember);
}
}
} catch (e) { console.error(e) }
setLoading(false);
}
async function toggleJoin() {
const token = localStorage.getItem('token');
if (!token) return;
const method = isMember ? 'POST' : 'POST';
const url = isMember
? `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/leave`
: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/join`;
try {
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (res.ok) {
setIsMember(!isMember);
loadData();
}
} catch (e) { console.error(e) }
}
async function handleCreatePost() {
const token = localStorage.getItem('token');
if (!token || !formTitle || !formContent) return;
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/community/posts`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: formTitle, content: formContent, circleId }),
});
if (res.ok) {
setFormTitle('');
setFormContent('');
setShowCreateForm(false);
loadData();
}
} catch (e) { console.error(e) }
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/circles" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">{circle?.name || '圈子详情'}</h1>
<p className="mt-2 text-muted-foreground">{circle?.description}</p>
<div className="flex gap-4 mt-3 text-sm text-muted-foreground">
<span>{circle?._count?.members || 0} </span>
<span>{circle?._count?.posts || 0} </span>
</div>
</div>
<button onClick={toggleJoin}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
isMember ? 'bg-muted text-muted-foreground hover:bg-accent' : 'bg-brand-600 text-white hover:bg-brand-700'
}`}>
{isMember ? '退出圈子' : '加入圈子'}
</button>
</div>
</div>
<div className="mb-6">
<button onClick={() => setShowCreateForm(!showCreateForm)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
{showCreateForm ? '取消' : '发帖'}
</button>
</div>
{showCreateForm && (
<div className="bg-card rounded-xl border border-border p-6 mb-6">
<input value={formTitle} onChange={e => setFormTitle(e.target.value)}
placeholder="标题" className="w-full px-4 py-2 border border-border rounded-lg mb-3 text-sm" />
<textarea value={formContent} onChange={e => setFormContent(e.target.value)}
placeholder="内容..." rows={4} className="w-full px-4 py-2 border border-border rounded-lg mb-3 text-sm" />
<button onClick={handleCreatePost}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</div>
)}
{posts.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p></p>
</div>
) : (
<div className="space-y-4">
{posts.map(post => (
<Link key={post.id} href={`/community/${post.id}`}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-xs font-bold">
{post.user?.nickname?.[0] || 'U'}
</div>
<div>
<div className="text-sm font-medium text-foreground">{post.user?.nickname || '匿名用户'}</div>
<div className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleDateString()}</div>
</div>
</div>
<h3 className="font-semibold text-foreground mb-2">{post.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{post.content}</p>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<span>👁 {post.viewCount}</span>
<span> {post.likeCount}</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/circles`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
interface Circle {
id: number;
name: string;
description?: string;
tags?: string;
_count: { members: number; posts: number };
creator?: { id: number; nickname: string };
}
export default function CirclesPage() {
const [circles, setCircles] = useState<Circle[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadCircles(); }, []);
async function loadCircles() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles`);
if (res.ok) {
const data = await res.json();
setCircles(data || []);
}
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-32 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1,2,3,4].map(i => <Skeleton key={i} className="h-32 rounded-xl" />)}
</div>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{circles.map(circle => (
<Link key={circle.id} href={`/circles/${circle.id}`}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<h3 className="font-semibold text-foreground mb-2">{circle.name}</h3>
<p className="text-sm text-muted-foreground mb-4">{circle.description}</p>
<div className="flex items-center justify-between">
<div className="flex gap-2">
{circle.tags?.split(',').map(tag => (
<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>
</div>
</Link>
))}
</div>
{circles.length === 0 && !loading && (
<div className="text-center py-20 text-muted-foreground"><p></p></div>
)}
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
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';
interface PostDetail {
id: number; title: string; content: string; tags?: string;
viewCount: number; likeCount: number; commentCount: number;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
liked: boolean;
comments: Comment[];
}
interface Comment {
id: number; content: string; createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
}
export default function CommunityPostDetail() {
const params = useParams();
const router = useRouter();
const [post, setPost] = useState<PostDetail | null>(null);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadPost(); }, [params.id]);
async function loadPost() {
try {
const res = await apiFetch(`/community/posts/${params.id}`);
if (res.ok) setPost(await res.json());
} catch (e) { console.error(e) }
setLoading(false);
}
async function handleComment(e: React.FormEvent) {
e.preventDefault();
if (!commentText.trim()) return;
setSubmitting(true);
try {
const res = await apiFetch(`/community/posts/${params.id}/comments`, {
method: 'POST',
body: JSON.stringify({ content: commentText }),
});
if (res.ok) { setCommentText(''); loadPost(); }
} catch (e) { console.error(e) }
setSubmitting(false);
}
async function handleLike() {
try {
await apiFetch(`/community/posts/${params.id}/like`, { method: 'POST' });
loadPost();
} catch (e) { console.error(e) }
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-32 w-full mb-4" />
</div>
);
if (!post) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/community"><Button variant="outline"></Button></Link>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground mb-6 inline-block">&larr; </Link>
<Card className="p-6 sm:p-8 mb-8">
<div className="flex items-center gap-3 mb-4">
<Link href={`/users/${post.user.id}`} className="flex items-center gap-3 group">
<Avatar className="w-9 h-9">
<AvatarFallback className="bg-brand-100 dark:bg-brand-900/40 text-brand-600 dark:text-brand-400 text-sm font-bold">{post.user.nickname?.[0] || 'U'}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-medium text-foreground group-hover:text-brand-600">{post.user.nickname}</div>
<div className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleDateString()}</div>
</div>
</Link>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">{post.title}</h1>
<div className="prose prose-sm dark:prose-invert max-w-none mb-6 whitespace-pre-wrap text-foreground leading-relaxed">{post.content}</div>
{post.tags && (
<div className="flex gap-2 mb-6">
{post.tags.split(',').map(tag => (
<span key={tag} className="text-xs px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded">{tag.trim()}</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t border-border pt-4">
<button onClick={handleLike} className="flex items-center gap-1.5 hover:text-red-500 transition-colors">
{post.liked ? '❤️' : '🤍'} {post.likeCount}
</button>
<span className="flex items-center gap-1.5">💬 {post.commentCount}</span>
<span className="flex items-center gap-1.5">👁 {post.viewCount}</span>
</div>
</Card>
<h2 className="text-lg font-semibold text-foreground mb-4"> ({post.comments?.length || 0})</h2>
<form onSubmit={handleComment} className="mb-6">
<div className="flex gap-2">
<input value={commentText} onChange={e => setCommentText(e.target.value)}
placeholder="写下你的评论..." disabled={submitting}
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<Button type="submit" disabled={submitting || !commentText.trim()}>
{submitting ? '发送中...' : '评论'}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
<Link href="/ai-agreement" className="text-brand-600 hover:underline ml-1">AI </Link>
</p>
</form>
<div className="space-y-4">
{post.comments?.map(c => (
<Card key={c.id} className="p-4">
<div className="flex gap-3">
<Avatar className="w-7 h-7">
<AvatarFallback className="text-xs">{c.user.nickname?.[0] || 'U'}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-foreground">{c.user.nickname}</span>
<span className="text-xs text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-muted-foreground">{c.content}</p>
</div>
</div>
</Card>
))}
{(!post.comments || post.comments.length === 0) && (
<p className="text-center text-muted-foreground py-8"></p>
)}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/posts`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((p: any) => ({ id: String(p.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../lib/auth";
import { useAuth } from "@/lib/auth-context";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
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 };
}
export default function CommunityPage() {
const router = useRouter();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'latest' | 'feed'>('latest');
const [followedUsers, setFollowedUsers] = useState<Set<number>>(new Set());
const { isLoggedIn } = useAuth();
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('token');
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) }
setLoading(false);
}, [activeTab]);
useEffect(() => { loadPosts(); }, [loadPosts]);
useEffect(() => { loadFollowStatus(); }, [posts]);
async function loadFollowStatus() {
const token = localStorage.getItem('token');
if (!token) return;
const followed = new Set<number>();
for (const post of posts) {
if (post.user?.id) {
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) }
}
}
setFollowedUsers(followed);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !content.trim()) return;
setSubmitting(true);
try {
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) }
setSubmitting(false);
}
async function handleLike(postId: number) {
try {
await apiFetch(`/community/posts/${postId}/like`, { method: "POST" });
loadPosts();
} catch (e) { console.error(e) }
}
async function handleFollow(userId: number) {
const token = localStorage.getItem('token');
if (!token) return;
try {
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) }
}
function PostCard({ post }: { post: Post }) {
const [expanded, setExpanded] = useState(false);
const [comment, setComment] = useState("");
const [submittingComment, setSubmittingComment] = useState(false);
async function handleComment(e: React.FormEvent) {
e.preventDefault();
if (!comment.trim()) return;
setSubmittingComment(true);
try {
await apiFetch(`/community/posts/${post.id}/comments`, {
method: "POST",
body: JSON.stringify({ content: comment }),
});
setComment("");
loadPosts();
} catch (e) { console.error(e) }
setSubmittingComment(false);
}
return (
<div className="bg-card rounded-xl border border-border p-6 mb-6">
<div className="flex items-center gap-3 mb-3">
<Link href={`/users/${post.user.id}`} className="flex items-center gap-3 group">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-brand-100 dark:bg-brand-900/40 text-brand-600 dark:text-brand-400 text-xs font-bold">{post.user.nickname?.[0] || "U"}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-medium text-foreground group-hover:text-brand-600">{post.user.nickname}</div>
<div className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleDateString()}</div>
</div>
</Link>
{isLoggedIn && (
<button onClick={() => handleFollow(post.user.id)}
className={`ml-auto text-xs px-2 py-1 rounded transition-colors ${
followedUsers.has(post.user.id)
? 'bg-muted text-muted-foreground hover:bg-accent'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}>
{followedUsers.has(post.user.id) ? '已关注' : '+ 关注'}
</button>
)}
</div>
<Link href={`/community/${post.id}`}>
<h3 className="text-lg font-semibold text-foreground mb-2 hover:text-brand-600">{post.title}</h3>
</Link>
<p className="text-muted-foreground leading-relaxed mb-4 whitespace-pre-wrap">{post.content}</p>
{post.tags && (
<div className="flex gap-2 mb-4">
{post.tags.split(",").map((tag: string) => (
<span key={tag} className="text-xs px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded">{tag.trim()}</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<button onClick={() => handleLike(post.id)} className="flex items-center gap-1 hover:text-brand-600 transition-colors">
{post.likeCount}
</button>
<button onClick={() => setExpanded(!expanded)} className="hover:text-brand-600 transition-colors">
💬 {post.commentCount}
</button>
<span>👁 {post.viewCount}</span>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t border-border">
{post.comments?.map((c: Comment) => (
<div key={c.id} className="flex gap-3 mb-3">
<Avatar className="w-6 h-6">
<AvatarFallback className="text-[10px]">{c.user.nickname?.[0] || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{c.user.nickname} · {new Date(c.createdAt).toLocaleDateString()}</div>
<p className="text-sm text-muted-foreground">{c.content}</p>
</div>
</div>
))}
<form onSubmit={handleComment} className="mt-3 flex gap-2">
<input value={comment} onChange={e => setComment(e.target.value)}
placeholder="写下你的评论..."
className="flex-1 px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="submit" disabled={submittingComment}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700 disabled:opacity-50">
{submittingComment ? "发送中..." : "评论"}
</button>
</form>
</div>
)}
</div>
);
}
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
}
return (
<div className="max-w-7xl 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"></h1>
<p className="mt-2 text-muted-foreground"> AI </p>
</div>
<button onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors">
{showForm ? "取消" : "+ 发帖"}
</button>
</div>
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
<button onClick={() => setActiveTab('latest')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'latest' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
<button onClick={() => setActiveTab('feed')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'feed' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-card rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="标题"
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<textarea value={content} onChange={e => setContent(e.target.value)}
placeholder="分享你的 AI 学习心得、实战经验..." rows={4}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
<input value={tags} onChange={e => setTags(e.target.value)}
placeholder="标签(逗号分隔,如:AI,提示词)"
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<div className="flex justify-end">
<button type="submit" disabled={submitting}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{submitting ? "发布中..." : "发布"}
</button>
</div>
</form>
)}
{posts.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p>{activeTab === 'feed' ? '关注更多用户,发现精彩内容' : '还没有帖子,来发第一帖吧!'}</p>
</div>
) : (
<div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>
)}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Content {
id: number; title: string; summary?: string; content?: string; cover?: string;
contentType: string; tags?: string; authorName?: string; viewCount: number;
isAiGenerated: boolean; publishedAt: string; createdAt: string;
category?: { name: string };
}
export default function ContentDetailClient() {
const params = useParams();
const [content, setContent] = useState<Content | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!params.id) return;
setLoading(true);
fetch(`${API_BASE}/contents/${params.id}`)
.then(r => r.json())
.then(data => {
if (!data || !data.id) throw new Error('内容不存在');
setContent(data);
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
if (error || !content) return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-xl font-bold text-foreground mb-2">{error || '内容不存在'}</h1>
<Link href="/" className="text-brand-600 hover:underline text-sm"></Link>
</div>
);
return (
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3">
{content.category && (
<span className="bg-brand-50 text-brand-600 px-2 py-0.5 rounded">{content.category.name}</span>
)}
<span>{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
<span>{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString() : new Date(content.createdAt).toLocaleDateString()}</span>
<span>{content.viewCount} </span>
{content.isAiGenerated && (
<span className="text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded">AI </span>
)}
</div>
<h1 className="text-3xl font-bold text-foreground leading-tight">{content.title}</h1>
{content.authorName && (
<p className="mt-2 text-sm text-muted-foreground">{content.authorName}</p>
)}
{content.summary && (
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">{content.summary}</p>
)}
{content.tags && (
<div className="flex gap-2 mt-4 flex-wrap">
{content.tags.split(',').map(tag => (
<span key={tag} className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{tag.trim()}</span>
))}
</div>
)}
</div>
{content.cover && (
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
{content.cover.startsWith('http') ? (
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
)}
<div className="prose prose-gray max-w-none">
{content.content ? (
<div className="text-foreground leading-relaxed whitespace-pre-wrap text-base">
{content.content}
</div>
) : (
<p className="text-muted-foreground italic py-8 text-center"></p>
)}
</div>
<div className="mt-12 pt-8 border-t border-border">
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
&larr;
</Link>
</div>
</article>
);
}
+18
View File
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/contents`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import ContentDetailClient from './client';
export default function ContentDetailPage() {
return <ContentDetailClient />;
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { FileText, Eye } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Content {
id: number; title: string; summary: string | null; cover: string | null;
contentType: string; tags: string | null; authorName: string | null;
viewCount: number; publishedAt: string; category?: { name: string };
}
function ContentSkeleton() {
return (
<Card className="overflow-hidden">
<Skeleton className="h-44 w-full" />
<div className="p-5 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32" />
</div>
</Card>
);
}
export default function ContentsPage() {
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/contents`)
.then(r => r.json()).then(data => setContents(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
const typeLabels: Record<string, string> = { article: '文章', tutorial: '教程', news: '资讯' };
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground">AI </p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1,2,3].map(i => <ContentSkeleton key={i} />)}
</div>
) : contents.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{contents.map((item) => (
<Link key={item.id} href={`/contents/${item.id}`} className="block group">
<Card className="overflow-hidden hover:shadow-lg transition-all hover:-translate-y-0.5">
<div className="h-44 bg-gradient-to-br from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20 flex items-center justify-center">
<FileText className="w-12 h-12 text-brand-300 dark:text-brand-600" />
</div>
<div className="p-5">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
{item.category && <Badge variant="secondary">{item.category.name}</Badge>}
<span>{item.publishedAt ? new Date(item.publishedAt).toLocaleDateString() : ''}</span>
</div>
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{item.title}</h3>
{item.summary && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.summary}</p>}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Eye className="w-3.5 h-3.5" />{item.viewCount} </span>
{item.authorName && <span>{item.authorName}</span>}
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Lesson {
id: number; title: string; content?: string; sortOrder: number; status: string;
}
interface Chapter {
id: number; title: string; sortOrder: number; lessons: Lesson[];
}
interface Course {
id: number; title: string; description: string; cover?: string;
isFree: boolean; price: number; status: string;
category?: { name: string };
chapters: Chapter[];
createdAt: string;
}
export default function CourseDetailClient() {
const params = useParams();
const [course, setCourse] = useState<Course | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [paying, setPaying] = useState(false);
const [orderNo, setOrderNo] = useState('');
const [qrCodeUrl, setQrCodeUrl] = useState('');
const [payLoading, setPayLoading] = useState(false);
useEffect(() => {
if (!params.id) return;
setLoading(true);
fetch(`${API_BASE}/courses/${params.id}`)
.then(r => r.json())
.then(data => {
if (!data || !data.id) throw new Error('课程不存在');
setCourse(data);
const firstLesson = data.chapters?.[0]?.lessons?.[0];
if (firstLesson) setActiveLesson(firstLesson);
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
if (error || !course) return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-xl font-bold text-foreground mb-2">{error || '课程不存在'}</h1>
<Link href="/courses" className="text-brand-600 hover:underline text-sm"></Link>
</div>
);
const totalLessons = course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
function PayModal() {
const [qrUrl, setQrUrl] = useState(qrCodeUrl);
useEffect(() => { setQrUrl(qrCodeUrl); }, [qrCodeUrl]);
if (!paying) return null;
return (
<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' ? (
<>
<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="支付二维码" />
</div>
<p className="text-sm text-muted-foreground mb-4">使</p>
</>
) : (
<p className="text-muted-foreground mb-4"></p>
)}
<div className="flex gap-3 justify-center">
<button
onClick={() => { setPaying(false); setQrCodeUrl(''); }}
className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-muted/50"
>
</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>
);
}
return (
<div className="flex h-[calc(100vh-4rem)]">
<aside className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} fixed md:relative md:translate-x-0 z-30 w-72 bg-card border-r border-border overflow-y-auto flex-shrink-0 transition-transform`}>
<div className="p-4 border-b border-border">
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 block">
&larr;
</Link>
<h2 className="font-semibold text-foreground text-sm line-clamp-2">{course.title}</h2>
<p className="text-xs text-muted-foreground mt-1">{course.chapters.length} · {totalLessons} </p>
</div>
<nav className="p-2">
{course.chapters.map((chapter, ci) => (
<div key={chapter.id} className="mb-3">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{ci + 1}. {chapter.title}
</div>
{chapter.lessons.map((lesson, li) => (
<button
key={lesson.id}
onClick={() => setActiveLesson(lesson)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeLesson?.id === lesson.id
? 'bg-brand-50 text-brand-700 font-medium'
: 'text-muted-foreground hover:bg-muted/50'
}`}
>
<span className="text-xs text-muted-foreground mr-2">{ci + 1}.{li + 1}</span>
{lesson.title}
</button>
))}
</div>
))}
{course.chapters.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"></p>
)}
</nav>
</aside>
{sidebarOpen && (
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
)}
<div className="flex-1 flex flex-col min-w-0">
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
>
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-medium text-foreground truncate">
{activeLesson?.title || course.title}
</h1>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{course.isFree ? (
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded"></span>
) : (
<>
<span className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded">¥{course.price}</span>
<button
onClick={async () => {
setPayLoading(true);
try {
const res = await fetch(`${API_BASE}/payment/wxpay/unified-order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: course.title,
outTradeNo: `COURSE_${course.id}_${Date.now()}`,
amount: course.price,
tradeType: 'NATIVE',
}),
});
const data = await res.json();
if (data.codeUrl) {
setQrCodeUrl(data.codeUrl);
setOrderNo(data.outTradeNo || '');
setPaying(true);
}
} catch {}
setPayLoading(false);
}}
disabled={payLoading}
className="px-3 py-1 bg-brand-600 text-white text-xs rounded hover:bg-brand-700 disabled:opacity-50"
>
{payLoading ? '处理中...' : '立即购买'}
</button>
</>
)}
{course.category && (
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{activeLesson ? (
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
<div className="prose prose-gray max-w-none">
{activeLesson.content ? (
<div className="text-foreground leading-relaxed whitespace-pre-wrap">
{activeLesson.content}
</div>
) : (
<p className="text-muted-foreground italic"></p>
)}
</div>
</article>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<p className="text-muted-foreground"></p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/courses`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import CourseDetailClient from './client';
export default function CourseDetailPage() {
return <CourseDetailClient />;
}
@@ -0,0 +1,18 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import CoursesPage from '../app/courses/page';
describe('Courses Page', () => {
it('should render courses page', () => {
render(<CoursesPage />);
const heading = screen.getByText(/学堂|课程/i);
expect(heading).toBeInTheDocument();
});
it('should render loading state initially', () => {
render(<CoursesPage />);
// 应该有加载指示器或内容
const loading = screen.queryByRole('status');
expect(loading || screen.getByText(/学堂|课程/i)).toBeTruthy();
});
});
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { BookOpen, Users } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Course {
id: number; title: string; description: string; cover: string | null;
isFree: boolean; chapters?: { lessons: any[] }[];
}
function CourseSkeleton() {
return (
<Card className="p-6">
<Skeleton className="h-40 w-full rounded-lg mb-4" />
<Skeleton className="h-4 w-16 mb-3" />
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</Card>
);
}
export default function CoursesPage() {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/courses`)
.then(r => r.json()).then(data => setCourses(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1,2,3,4,5,6].map(i => <CourseSkeleton key={i} />)}
</div>
) : courses.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<BookOpen className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<Link key={course.id} href={`/courses/${course.id}`} className="block group">
<Card className="overflow-hidden hover:shadow-lg transition-all hover:-translate-y-0.5">
{course.cover ? (
<img src={course.cover} alt={course.title} className="w-full h-40 object-cover" />
) : (
<div className="h-40 bg-gradient-to-br from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20 flex items-center justify-center">
<BookOpen className="w-10 h-10 text-brand-300 dark:text-brand-600" />
</div>
)}
<div className="p-5">
<Badge variant={course.isFree ? 'success' : 'destructive'} className="mb-3">
{course.isFree ? '免费' : '付费'}
</Badge>
<h3 className="font-semibold group-hover:text-brand-600 transition-colors mb-2">{course.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{course.description}</p>
{course.chapters && (
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
<Users className="w-3.5 h-3.5" />
<span>{course.chapters.reduce((s, ch) => s + (ch.lessons?.length || 0), 0)} </span>
</div>
)}
</div>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import Link from 'next/link';
import { apiFetch, isLoggedIn, clearTokens } from '../../lib/auth';
import { useRouter } from 'next/navigation';
interface Stats {
inProgressCourses: number;
completedLessons: number;
favoritePrompts: number;
studyDays: number;
todayLearned: number;
}
interface UserInfo {
nickname: string;
avatar: string | null;
memberPlan: string;
memberExpire: string | null;
sandboxDaily: number;
joinedAt: string;
}
interface CourseProgress {
course: { id: number; title: string; cover: string | null };
progress: number;
completedCount: number;
totalCount: number;
recentLessons: { id: number; title: string; completed: boolean; progress: number; updatedAt: string }[];
}
interface RecentRecord {
lessonId: number;
lessonTitle: string;
courseId: number;
courseTitle: string;
completed: boolean;
progress: number;
updatedAt: string;
}
interface PromptFavorite {
id: number;
promptId: number;
title: string;
description: string | null;
model: string | null;
viewCount: number;
likeCount: number;
favoritedAt: string;
}
type Tab = 'progress' | 'favorites' | 'profile';
export default function DashboardPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('progress');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stats, setStats] = useState<Stats | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [courses, setCourses] = useState<CourseProgress[]>([]);
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
const [favorites, setFavorites] = useState<PromptFavorite[]>([]);
const [nickname, setNickname] = useState('');
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState('');
useEffect(() => {
if (!isLoggedIn()) {
router.push('/auth');
return;
}
loadAll();
}, []);
async function loadAll() {
setLoading(true);
setError('');
try {
const [statsRes, progressRes, favRes, profileRes] = await Promise.all([
apiFetch('/dashboard/stats'),
apiFetch('/dashboard/progress'),
apiFetch('/dashboard/favorites'),
apiFetch('/dashboard/profile'),
]);
if (!statsRes.ok || !progressRes.ok || !favRes.ok || !profileRes.ok) {
throw new Error('加载数据失败');
}
const statsData = await statsRes.json();
const progressData = await progressRes.json();
const favData = await favRes.json();
const profileData = await profileRes.json();
setStats(statsData.stats);
setUserInfo(statsData.user);
setCourses(progressData.courses || []);
setRecentRecords(progressData.recentRecords || []);
setFavorites(favData || []);
setNickname(profileData.nickname || '');
} catch (e: any) {
if (e.message?.includes('401') || e.message?.includes('Unauthorized')) {
clearTokens();
router.push('/auth');
}
setError(e.message || '加载失败');
} finally {
setLoading(false);
}
}
async function handleSaveProfile() {
setSaving(true);
setSaveMsg('');
try {
const res = await apiFetch('/dashboard/profile', {
method: 'PUT',
body: JSON.stringify({ nickname }),
});
if (!res.ok) throw new Error('保存失败');
setSaveMsg('保存成功');
setUserInfo(prev => prev ? { ...prev, nickname } : prev);
} catch {
setSaveMsg('保存失败');
} finally {
setSaving(false);
setTimeout(() => setSaveMsg(''), 2000);
}
}
function handleLogout() {
clearTokens();
router.push('/');
}
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-5 w-72 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[1,2,3,4].map(i => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 py-20 text-center">
<p className="text-red-500 mb-4">{error}</p>
<button onClick={loadAll} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
</button>
</div>
);
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'progress', label: '学习进度' },
{ key: 'favorites', label: '收藏夹' },
{ key: 'profile', label: '个人设置' },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<button
onClick={handleLogout}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors mt-1"
>
退
</button>
</div>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
{[
{ label: '学习中课程', value: stats.inProgressCourses },
{ label: '已完成课时', value: stats.completedLessons },
{ label: '收藏提示词', value: stats.favoritePrompts },
{ label: '学习天数', value: stats.studyDays },
{ label: '今日学习', value: stats.todayLearned },
].map((item) => (
<div key={item.label} className="bg-card rounded-xl border border-border p-4 text-center">
<div className="text-2xl font-bold text-brand-600">{item.value}</div>
<div className="text-xs text-muted-foreground mt-1">{item.label}</div>
</div>
))}
</div>
)}
<div className="flex gap-6 flex-col lg:flex-row">
<div className="lg:w-48 flex-shrink-0">
<nav className="flex lg:flex-col gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-sm font-medium rounded-lg text-left transition-colors ${
activeTab === tab.key
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="flex-1 min-w-0">
{activeTab === 'progress' && (
<div>
{courses.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/courses" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
) : (
<div className="space-y-6">
{courses.map((entry) => (
<div key={entry.course.id} className="bg-card rounded-xl border border-border p-6">
<Link href={`/courses/${entry.course.id}`} className="text-lg font-semibold text-foreground hover:text-brand-600">
{entry.course.title}
</Link>
<div className="mt-3">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-1.5">
<span></span>
<span>{entry.completedCount}/{entry.totalCount} ({entry.progress}%)</span>
</div>
<Progress value={entry.progress} className="h-2" />
</div>
{entry.recentLessons.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="space-y-1.5">
{entry.recentLessons.map((lesson) => (
<div key={lesson.id} className="flex items-center gap-2 text-sm">
<span className={`w-1.5 h-1.5 rounded-full ${lesson.completed ? 'bg-green-500' : 'bg-brand-300'}`} />
<span className="text-muted-foreground">{lesson.title}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
{recentRecords.length > 0 && (
<div className="bg-card rounded-xl border border-border p-6">
<h3 className="text-base font-semibold text-foreground mb-4"></h3>
<div className="space-y-3">
{recentRecords.map((r, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${r.completed ? 'bg-green-500' : 'bg-brand-300'}`} />
<span className="text-muted-foreground">{r.lessonTitle}</span>
<span className="text-muted-foreground">- {r.courseTitle}</span>
</div>
<span className="text-xs text-muted-foreground">{new Date(r.updatedAt).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
{activeTab === 'favorites' && (
<div>
{favorites.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/prompts" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
) : (
<div className="grid gap-4">
{favorites.map((fav) => (
<Link
key={fav.id}
href={`/prompts`}
className="block bg-card rounded-xl border border-border p-5 hover:border-brand-200 transition-colors"
>
<h3 className="font-semibold text-foreground">{fav.title}</h3>
{fav.description && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{fav.description}</p>}
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
{fav.model && <span>: {fav.model}</span>}
<span>{fav.viewCount} </span>
<span>{fav.likeCount} </span>
<span className="ml-auto">{new Date(fav.favoritedAt).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
</div>
)}
{activeTab === 'profile' && (
<div className="bg-card rounded-xl border border-border p-6">
<h3 className="text-base font-semibold text-foreground mb-6"></h3>
<div className="space-y-5 max-w-md">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input
type="text"
value={nickname}
onChange={e => setNickname(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="输入昵称"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<p className="text-sm text-muted-foreground">{userInfo?.memberPlan === 'FREE' ? '免费用户' : userInfo?.memberPlan}</p>
</div>
{userInfo?.memberExpire && (
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<p className="text-sm text-muted-foreground">{new Date(userInfo.memberExpire).toLocaleDateString()}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<p className="text-sm text-muted-foreground">{userInfo?.joinedAt ? new Date(userInfo.joinedAt).toLocaleDateString() : '-'}</p>
</div>
<div>
<button
onClick={handleSaveProfile}
disabled={saving}
className="px-6 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
</button>
{saveMsg && (
<span className={`ml-3 text-sm ${saveMsg === '保存成功' ? 'text-green-600' : 'text-red-500'}`}>
{saveMsg}
</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
interface DailyItem {
id: number;
title: string;
summary?: string;
cover?: string;
_type: 'course' | 'prompt' | 'content';
}
export default function DailyPage() {
const [items, setItems] = useState<DailyItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadDaily();
}, []);
async function loadDaily() {
try {
const [coursesRes, promptsRes, contentsRes] = await Promise.all([
apiFetch('/courses?pageSize=3'),
apiFetch('/prompts?pageSize=3'),
apiFetch('/contents?pageSize=3'),
]);
const coursesData = await coursesRes.json();
const promptsData = await promptsRes.json();
const contentsData = await contentsRes.json();
const items: DailyItem[] = [
...(coursesData.items || []).map((c: any) => ({ ...c, _type: 'course' as const })),
...(promptsData.items || []).map((p: any) => ({ ...p, _type: 'prompt' as const })),
...(contentsData.items || []).map((c: any) => ({ ...c, _type: 'content' as const })),
];
setItems(items);
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="space-y-4">
{items.map((item, index) => (
<Link
key={`${item._type}-${item.id}`}
href={
item._type === 'course' ? `/courses/${item.id}` :
item._type === 'prompt' ? `/prompts/${item.id}` :
`/contents/${item.id}`
}
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex gap-4"
>
{item.cover && (
<img src={item.cover} alt={item.title} className="w-20 h-20 object-cover rounded-lg shrink-0" />
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
item._type === 'course' ? 'bg-blue-100 text-blue-700' :
item._type === 'prompt' ? 'bg-purple-100 text-purple-700' :
'bg-green-100 text-green-700'
}`}>
{item._type === 'course' ? '课程' : item._type === 'prompt' ? '提示词' : '文章'}
</span>
<span className="text-xs text-muted-foreground">#{index + 1}</span>
</div>
<h3 className="font-semibold text-foreground mb-1">{item.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{item.summary || '暂无描述'}</p>
</div>
</Link>
))}
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
interface HotItem {
id: number;
title: string;
viewCount?: number;
likeCount?: number;
_type: 'course' | 'prompt' | 'post';
}
export default function HotPage() {
const [items, setItems] = useState<HotItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadHot();
}, []);
async function loadHot() {
try {
const [coursesRes, promptsRes, postsRes] = await Promise.all([
apiFetch('/courses?pageSize=10'),
apiFetch('/prompts?pageSize=10'),
apiFetch('/community/posts?pageSize=10'),
]);
const coursesData = await coursesRes.json();
const promptsData = await promptsRes.json();
const postsData = await postsRes.json();
const items: HotItem[] = [
...(coursesData.items || []).map((c: any) => ({ ...c, _type: 'course' as const })),
...(promptsData.items || []).map((p: any) => ({ ...p, _type: 'prompt' as const })),
...(postsData.items || []).map((p: any) => ({ ...p, _type: 'post' as const })),
].sort((a, b) => {
const aScore = (a.viewCount || 0) + (a.likeCount || 0) * 2;
const bScore = (b.viewCount || 0) + (b.likeCount || 0) * 2;
return bScore - aScore;
}).slice(0, 20);
setItems(items);
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="bg-card rounded-2xl border border-border overflow-hidden">
{items.map((item, index) => (
<Link
key={`${item._type}-${item.id}`}
href={
item._type === 'course' ? `/courses/${item.id}` :
item._type === 'prompt' ? `/prompts/${item.id}` :
`/community/${item.id}`
}
className={`flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors ${
index !== items.length - 1 ? 'border-b border-border' : ''
}`}
>
<span className={`text-2xl font-bold w-10 text-center ${
index === 0 ? 'text-yellow-500' :
index === 1 ? 'text-muted-foreground' :
index === 2 ? 'text-amber-600' :
'text-muted-foreground/40'
}`}>
{index + 1}
</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
item._type === 'course' ? 'bg-blue-100 text-blue-700' :
item._type === 'prompt' ? 'bg-purple-100 text-purple-700' :
'bg-green-100 text-green-700'
}`}>
{item._type === 'course' ? '课程' : item._type === 'prompt' ? '提示词' : '讨论'}
</span>
</div>
<h3 className="font-medium text-foreground">{item.title}</h3>
</div>
<div className="text-xs text-muted-foreground text-right">
<div>👁 {item.viewCount || 0}</div>
<div> {item.likeCount || 0}</div>
</div>
</Link>
))}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface HotItem {
id: number;
title: string;
viewCount: number;
likeCount: number;
_type: 'course' | 'prompt' | 'post';
}
export default function DiscoverPage() {
const [hotCourses, setHotCourses] = useState<HotItem[]>([]);
const [hotPrompts, setHotPrompts] = useState<HotItem[]>([]);
const [hotPosts, setHotPosts] = useState<HotItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const [coursesRes, promptsRes, postsRes] = await Promise.all([
apiFetch('/courses?pageSize=5'),
apiFetch('/prompts?pageSize=5'),
apiFetch('/community/posts?pageSize=5'),
]);
const coursesData = await coursesRes.json();
const promptsData = await promptsRes.json();
const postsData = await postsRes.json();
setHotCourses((coursesData.items || []).map((c: any) => ({ ...c, _type: 'course' as const })));
setHotPrompts((promptsData.items || []).map((p: any) => ({ ...p, _type: 'prompt' as const })));
setHotPosts((postsData.items || []).map((p: any) => ({ ...p, _type: 'post' as const })));
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-24 mb-2" />
<Skeleton className="h-5 w-64 mb-8" />
{[1,2,3].map(i => <Skeleton key={i} className="h-32 rounded-xl mb-4" />)}
</div>
);
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"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="space-y-8">
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">🔥 </h2>
<Link href="/courses" className="text-sm text-brand-600 hover:underline"></Link>
</div>
<div className="grid gap-3">
{hotCourses.map((item, index) => (
<Link key={item.id} href={`/courses/${item.id}`}
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex items-center gap-4">
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1">👁 {item.viewCount} </p>
</div>
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground"> </h2>
<Link href="/prompts" className="text-sm text-brand-600 hover:underline"></Link>
</div>
<div className="grid gap-3">
{hotPrompts.map((item, index) => (
<Link key={item.id} href={`/prompts/${item.id}`}
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex items-center gap-4">
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1"> {item.likeCount} </p>
</div>
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">💬 </h2>
<Link href="/community" className="text-sm text-brand-600 hover:underline"></Link>
</div>
<div className="grid gap-3">
{hotPosts.map((item, index) => (
<Link key={item.id} href={`/community/${item.id}`}
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex items-center gap-4">
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1"> {item.likeCount} · 👁 {item.viewCount} </p>
</div>
</Link>
))}
</div>
</section>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
interface Tool {
id: number;
name: string;
description?: string;
url?: string;
icon?: string;
category?: { name: string };
}
export default function ToolsRecommendPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadTools();
}, []);
async function loadTools() {
try {
const res = await apiFetch('/tools?pageSize=10');
const data = await res.json();
setTools(data.items || []);
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground">AI工具</p>
</div>
<div className="grid gap-4">
{tools.map((tool) => (
<Link
key={tool.id}
href={tool.url || '#'}
target="_blank"
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex items-center gap-4"
>
{tool.icon ? (
<img src={tool.icon} alt={tool.name} className="w-12 h-12 rounded-lg object-cover" />
) : (
<div className="w-12 h-12 bg-brand-100 rounded-lg flex items-center justify-center text-brand-600 font-bold">
{tool.name?.[0] || 'T'}
</div>
)}
<div className="flex-1">
<h3 className="font-semibold text-foreground">{tool.name}</h3>
<p className="text-sm text-muted-foreground line-clamp-1">{tool.description}</p>
{tool.category && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 bg-muted rounded">
{tool.category.name}
</span>
)}
</div>
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
))}
</div>
{tools.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<p></p>
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
'use client';
export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<h1 className="text-4xl font-bold text-foreground"></h1>
<p className="text-muted-foreground"></p>
<button
onClick={() => reset()}
className="text-brand-600 hover:text-brand-700 font-medium"
>
</button>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

+19
View File
@@ -0,0 +1,19 @@
'use client';
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
return (
<html lang="zh-CN">
<body className="min-h-screen flex flex-col items-center justify-center gap-4 bg-background">
<h1 className="text-4xl font-bold text-foreground"></h1>
<p className="text-muted-foreground"></p>
<button
onClick={() => reset()}
className="text-brand-600 hover:text-brand-700 font-medium"
>
</button>
</body>
</html>
);
}
+62
View File
@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 207 100% 38%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 207 100% 38%;
--radius: 0.75rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 207 58% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 207 58% 52%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function HomePageClient({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from 'next';
import './globals.css';
import { Header } from '@/components/layout/header';
import { Footer } from '@/components/layout/footer';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { AuthProvider } from '@/lib/auth-context';
import { Toaster } from '@/components/ui/sonner';
export const metadata: Metadata = {
title: {
default: '宇之然 AI - AI 工具与知识社区',
template: '%s | 宇之然 AI',
},
description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科等,让每个人都能用好 AI。',
keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然'],
icons: {
icon: '/favicon.png',
shortcut: '/favicon.png',
apple: '/icon.svg',
},
openGraph: {
type: 'website',
locale: 'zh_CN',
siteName: '宇之然 AI',
title: '宇之然 AI - AI 工具与知识社区',
description: '让每个人都能用好 AI',
url: 'https://yuzhiran.com',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="min-h-screen flex flex-col">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
<Toaster richColors closeButton />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}
@@ -0,0 +1,170 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface Domain {
id: string;
name: string;
sessionCount: number;
mastery: number;
lastActive: string | null;
weak: boolean;
}
interface Recommendation {
title: string;
url: string;
}
interface Analytics {
domains: Domain[];
totalSessions: number;
weakDomains: string[];
recommendations: Recommendation[];
}
export default function LearningAnalyticsPage() {
const [data, setData] = useState<Analytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
loadAnalytics();
}, []);
async function loadAnalytics() {
try {
const res = await apiFetch('/learning/analytics');
if (!res.ok) throw new Error('加载失败');
const json = await res.json();
setData(json);
} catch (e: any) {
setError(e.message);
}
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-5 w-72 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
if (error) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<p className="text-red-500 mb-4">{error}</p>
<button onClick={loadAnalytics} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"></button>
</div>
);
const coveredDomains = data?.domains.filter(d => d.sessionCount > 0) || [];
const weakDomains = data?.domains.filter(d => d.weak) || [];
const avgMastery = data?.domains.length
? Math.round(data.domains.reduce((sum, d) => sum + d.mastery, 0) / data.domains.length)
: 0;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI </p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1">AI </div>
<div className="text-2xl font-bold text-foreground">{data?.totalSessions || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold text-foreground">{coveredDomains.length}/{data?.domains.length || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold text-foreground">{avgMastery}%</div>
</div>
</div>
{data && data.domains.length > 0 && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4"></h2>
<div className="space-y-4">
{data.domains.map(domain => (
<div key={domain.id}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{domain.name}</span>
{domain.weak && (
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded"></span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{domain.sessionCount} </span>
<span className={`text-xs font-medium tabular-nums ${
domain.mastery >= 60 ? 'text-green-600' : domain.mastery >= 30 ? 'text-amber-600' : 'text-red-500'
}`}>{domain.mastery}%</span>
</div>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div className={`h-2 rounded-full transition-all ${
domain.mastery >= 60 ? 'bg-green-500' : domain.mastery >= 30 ? 'bg-amber-500' : 'bg-red-500'
}`} style={{ width: `${domain.mastery}%` }} />
</div>
</div>
))}
</div>
</div>
)}
{weakDomains.length > 0 && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="text-lg font-semibold text-foreground mb-2"></h2>
<p className="text-sm text-muted-foreground mb-4">
</p>
<div className="flex flex-wrap gap-2">
{weakDomains.map(d => (
<span key={d.id} className="px-3 py-1.5 text-sm bg-amber-50 text-amber-700 rounded-lg border border-amber-200">
{d.name}
</span>
))}
</div>
</div>
)}
{data && data.recommendations.length > 0 && (
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2"></h2>
<p className="text-sm text-muted-foreground mb-4"></p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{data.recommendations.map((rec, i) => (
<Link key={i} href={rec.url}
className="flex items-center gap-3 p-4 rounded-xl border border-border hover:bg-accent transition-colors group">
<div className="w-10 h-10 bg-brand-100 rounded-lg flex items-center justify-center text-brand-600 font-bold shrink-0">
{rec.title[0]}
</div>
<div>
<div className="text-sm font-medium text-foreground group-hover:text-brand-600 transition-colors">{rec.title}</div>
<div className="text-xs text-muted-foreground mt-0.5"></div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface Task {
label: string;
action: string;
keyword: string;
}
interface StageLink {
title: string;
url: string;
}
interface Stage {
id: string;
title: string;
icon: string;
description: string;
tasks: Task[];
links: StageLink[];
completedCount: number;
totalTasks: number;
progress: number;
unlocked: boolean;
}
export default function LearningPathPage() {
const [stages, setStages] = useState<Stage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPath();
}, []);
async function loadPath() {
try {
const res = await apiFetch('/learning/path');
if (res.ok) setStages(await res.json());
} catch {}
setLoading(false);
}
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-2" />
<Skeleton className="h-5 w-64 mb-8" />
{[1,2,3,4].map(i => <Skeleton key={i} className="h-40 w-full rounded-xl mb-4" />)}
</div>
);
const totalProgress = stages.length
? Math.round(stages.reduce((s, st) => s + st.progress, 0) / stages.length)
: 0;
const totalCompleted = stages.reduce((s, st) => s + st.completedCount, 0);
const totalTasks = stages.reduce((s, st) => s + st.totalTasks, 0);
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI </p>
</div>
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground"></span>
<span className="text-sm text-muted-foreground">{totalCompleted}/{totalTasks} </span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${totalProgress}%` }} />
</div>
</div>
<div className="relative">
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-muted hidden md:block" />
<div className="space-y-8">
{stages.map((stage, index) => (
<div key={stage.id} className="relative md:pl-20">
<div className="hidden md:flex absolute left-0 top-0 w-16 items-center justify-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-xl border-2 z-10 bg-card ${
stage.progress === 100 ? 'border-green-500' : 'border-brand-600'
}`}>
{stage.progress === 100 ? '✅' : stage.icon}
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="md:hidden text-xl">{stage.progress === 100 ? '✅' : stage.icon}</span>
<h2 className="text-lg font-semibold text-foreground">{stage.title}</h2>
</div>
<p className="text-sm text-muted-foreground">{stage.description}</p>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{stage.completedCount}/{stage.totalTasks}
</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5 mb-4">
<div className={`h-1.5 rounded-full transition-all ${
stage.progress === 100 ? 'bg-green-500' : 'bg-brand-600'
}`} style={{ width: `${stage.progress}%` }} />
</div>
<div className="space-y-1.5 mb-4">
{stage.tasks.map((task, ti) => {
const done = ti < stage.completedCount;
return (
<div key={ti} className="flex items-center gap-2 text-sm">
<span className={`w-4 h-4 rounded-full border flex items-center justify-center shrink-0 ${
done ? 'bg-green-500 border-green-500 text-white' : 'border-muted-foreground'
}`}>
{done && <span className="text-[10px]"></span>}
</span>
<span className={done ? 'text-muted-foreground line-through' : 'text-foreground'}>{task.label}</span>
<span className="text-xs text-muted-foreground/60"> {task.action}</span>
</div>
);
})}
</div>
<div className="flex flex-wrap gap-2">
{stage.links.map((link, li) => (
<Link key={li} href={link.url}
className="text-xs px-3 py-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors">
{link.title}
</Link>
))}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
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;
}
export default function ModelsPage() {
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>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-foreground">AI </h1>
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">
</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"></th>
<th className="text-left py-4 px-4 font-semibold text-foreground"></th>
<th className="text-left py-4 px-4 font-semibold text-foreground hidden md:table-cell"></th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell"></th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell"></th>
<th className="text-center py-4 px-4 font-semibold text-foreground hidden sm:table-cell"></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"></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"></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 ? '免费' : model.pricing?.includes('免费') ? '免费/付费' : '付费'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</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"></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"></span>
{model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K tokens` : '-'}
</div>
<div>
<span className="block text-muted-foreground"></span>
{model.maxTokens ? `${(model.maxTokens / 1024).toFixed(0)}K tokens` : '-'}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface Stats {
totalCourses: number;
completedCourses: number;
totalSandboxSessions: number;
totalPrompts: number;
totalLikes: number;
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStats();
}, []);
async function loadStats() {
try {
const res = await apiFetch('/dashboard/stats');
const data = await res.json();
setStats(data);
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<button onClick={() => router.back()} className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</button>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold text-brand-600">{stats.completedCourses}/{stats.totalCourses}</div>
<div className="text-xs text-muted-foreground mt-1">/</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1">使</div>
<div className="text-2xl font-bold text-purple-600">{stats.totalSandboxSessions}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold text-green-600">{stats.totalPrompts}</div>
<div className="text-xs text-muted-foreground mt-1">/</div>
</div>
<div className="bg-card rounded-xl border border-border p-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold text-red-600">{stats.totalLikes}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
</div>
)}
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-muted/50 rounded-xl">
<div className="text-3xl font-bold text-foreground">{stats?.totalCourses || 0}</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-xl">
<div className="text-3xl font-bold text-foreground">{stats?.completedCourses || 0}</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-xl">
<div className="text-3xl font-bold text-foreground">
{stats?.totalCourses ? Math.round((stats.completedCourses / stats.totalCourses) * 100) : 0}%
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface FavoritePrompt {
id: number;
promptId: number;
prompt: {
id: number;
title: string;
description: string;
likeCount: number;
};
}
export default function FavoritesPage() {
const [items, setItems] = useState<FavoritePrompt[]>([]);
const [loading, setLoading] = useState(true);
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) }
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) }
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
{items.length === 0 ? (
<div className="text-center py-20">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
</div>
<p className="text-muted-foreground mb-4"></p>
<Link href="/prompts" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map(item => (
<div key={item.id} className="bg-card rounded-xl border border-border p-5 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-2">
<Link href={`/prompts/${item.prompt.id}`} className="font-semibold text-foreground hover:text-brand-600">
{item.prompt.title}
</Link>
<button onClick={() => removeFavorite(item.promptId)} className="text-muted-foreground hover:text-red-500 text-sm">
</button>
</div>
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{item.prompt.description}</p>
<div className="text-xs text-muted-foreground"> {item.prompt.likeCount}</div>
</div>
))}
</div>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface LearningItem {
courseId: number;
courseTitle: string;
progress: number;
completedLessons: number;
totalLessons: number;
}
export default function LearningPage() {
const [items, setItems] = useState<LearningItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const res = await apiFetch('/courses/my-learning');
const data = await res.json();
setItems(data.items || []);
} catch (e) { console.error(e) }
setLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
{items.length === 0 ? (
<div className="text-center py-20">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
📚
</div>
<p className="text-muted-foreground mb-4"></p>
<Link href="/courses" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
</div>
) : (
<div className="space-y-4">
{items.map(item => (
<Link key={item.courseId} href={`/courses/${item.courseId}`}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-foreground">{item.courseTitle}</h3>
<span className="text-sm text-muted-foreground">{item.progress}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mb-2">
<div className="bg-brand-600 h-2 rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
<p className="text-xs text-muted-foreground">
{item.completedLessons}/{item.totalLessons}
</p>
</Link>
))}
</div>
)}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface Subscription {
id: number;
plan: string;
startDate: string;
endDate: string;
status: string;
}
interface Order {
id: number;
orderNo: string;
amount: number;
planType: string;
status: string;
createdAt: string;
}
export default function MemberPage() {
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState(false);
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const [subRes, ordersRes] = await Promise.all([
apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
apiFetch('/orders'),
]);
if (subRes.ok) {
const subData = await subRes.json();
setSubscription(subData);
}
const ordersData = await ordersRes.json();
setOrders(ordersData.items || []);
} catch (e) { console.error(e) }
setLoading(false);
}
async function handleSubscribe(planType: string) {
setPayLoading(true);
try {
const res = await apiFetch('/orders/create', {
method: 'POST',
body: JSON.stringify({
amount: planType === 'MONTHLY' ? 29.9 : 199,
planType,
payChannel: 'wxpay',
}),
});
const data = await res.json();
if (data.order && data.payResult) {
alert('订单创建成功,请扫码支付(模拟模式)');
loadData();
}
} catch (e) { console.error(e) }
setPayLoading(false);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4"></h2>
{subscription ? (
<div>
<div className="flex items-center gap-3 mb-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
subscription.plan === 'YEARLY' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>
{subscription.plan === 'YEARLY' ? '年卡会员' : '月卡会员'}
</span>
<span className="text-sm text-muted-foreground">
{new Date(subscription.endDate).toLocaleDateString()}
</span>
</div>
<div className="text-sm text-muted-foreground">
+ + + 广
</div>
</div>
) : (
<div>
<p className="text-muted-foreground mb-4"></p>
<div className="flex gap-4">
<button
onClick={() => handleSubscribe('MONTHLY')}
disabled={payLoading}
className="px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50"
>
{payLoading ? '处理中...' : '开通月卡 ¥29.9/月'}
</button>
<button
onClick={() => handleSubscribe('YEARLY')}
disabled={payLoading}
className="px-6 py-3 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 disabled:opacity-50"
>
{payLoading ? '处理中...' : '开通年卡 ¥199/年'}
</button>
</div>
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold text-foreground mb-4"></h2>
{orders.length === 0 ? (
<p className="text-muted-foreground text-center py-8"></p>
) : (
<div className="space-y-3">
{orders.map(order => (
<div key={order.id} className="bg-card rounded-xl border border-border p-4 flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{order.planType === 'MONTHLY' ? '月卡会员' : '年卡会员'}</div>
<div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div>
</div>
<div className="text-right">
<div className="font-semibold text-foreground">¥{order.amount}</div>
<span className={`text-xs px-2 py-0.5 rounded ${
order.status === 'PAID' ? 'bg-green-100 text-green-700' :
order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' :
'bg-muted text-muted-foreground'
}`}>
{order.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../lib/auth';
interface LearningItem {
courseId: number;
courseTitle: string;
progress: number;
completedLessons: number;
totalLessons: number;
}
export default function MyPage() {
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"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Link href="/my/learning" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-brand-100 rounded-xl flex items-center justify-center text-brand-600 text-xl mb-4">📚</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</Link>
<Link href="/my/favorites" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center text-red-600 text-xl mb-4"></div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</Link>
<Link href="/my/member" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-amber-600 text-xl mb-4">👑</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</Link>
<Link href="/my/settings" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-muted rounded-xl flex items-center justify-center text-muted-foreground text-xl mb-4"></div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</Link>
<Link href="/learning/analytics" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-brand-100 rounded-xl flex items-center justify-center text-brand-600 text-xl mb-4">📊</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</Link>
<Link href="/learning/path" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-green-600 text-xl mb-4">🗺</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"> AI </p>
</Link>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
interface Profile {
nickname: string;
email: string;
phone: string;
avatar: string;
}
export default function SettingsPage() {
const router = useRouter();
const [profile, setProfile] = useState<Profile>({ nickname: '', email: '', phone: '', avatar: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [nickname, setNickname] = useState('');
const [email, setEmail] = useState('');
useEffect(() => {
loadProfile();
}, []);
async function loadProfile() {
try {
const res = await apiFetch('/dashboard/profile');
const data = await res.json();
setProfile(data);
setNickname(data.nickname || '');
setEmail(data.email || '');
} catch (e) { console.error(e) }
setLoading(false);
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await apiFetch('/dashboard/profile', {
method: 'PUT',
body: JSON.stringify({ nickname, email }),
});
alert('保存成功');
} catch (e) { console.error(e) }
setSaving(false);
}
async function handleLogout() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
router.push('/auth');
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<button onClick={() => router.back()} className="text-sm text-muted-foreground hover:text-foreground mb-4 block">&larr; </button>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<form onSubmit={handleSave} className="bg-card rounded-2xl border border-border p-6 space-y-6">
<h2 className="text-lg font-semibold text-foreground"></h2>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5"></label>
<input
type="text"
value={nickname}
onChange={e => setNickname(e.target.value)}
className="w-full px-4 py-2.5 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5"></label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-4 py-2.5 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{saving ? '保存中...' : '保存修改'}
</button>
</div>
</form>
<div className="bg-card rounded-2xl border border-border p-6 mt-6">
<h2 className="text-lg font-semibold text-foreground mb-4"></h2>
<button
onClick={handleLogout}
className="px-6 py-2.5 border border-red-200 text-red-600 rounded-xl text-sm font-medium hover:bg-red-50"
>
退
</button>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<h1 className="text-4xl font-bold text-foreground">404</h1>
<p className="text-muted-foreground"></p>
<Link href="/" className="text-brand-600 hover:text-brand-700 font-medium">
</Link>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
interface Notification {
id: number;
type: 'like' | 'comment' | 'follow' | 'system';
title: string;
content?: string;
link?: string;
relatedId?: number;
isRead: boolean;
createdAt: string;
}
function NotificationIcon({ type }: { type: string }) {
const icons: Record<string, string> = {
like: '❤️',
comment: '💬',
follow: '👤',
system: '🔔',
};
return <span className="text-lg">{icons[type] || '🔔'}</span>;
}
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
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) }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
async function markRead(id: number) {
try {
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) }
}
async function markAllRead() {
try {
await apiFetch('/notifications/read-all', { method: 'PATCH' });
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setUnreadCount(0);
} catch (e) { console.error(e) }
}
if (loading) return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
return (
<div className="max-w-3xl 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"></h1>
<p className="mt-2 text-muted-foreground">
{unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知'}
</p>
</div>
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={markAllRead}>
</Button>
)}
</div>
<div className="space-y-2">
{notifications.map(n => (
<div key={n.id}
className={`flex items-start gap-4 p-4 rounded-xl border transition-colors ${
n.isRead
? 'bg-card border-border'
: 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
}`}>
<div className="mt-1"><NotificationIcon type={n.type} /></div>
<div className="flex-1 min-w-0">
{n.link ? (
<Link href={n.link} onClick={() => { if (!n.isRead) markRead(n.id); }}
className="text-sm font-medium text-foreground hover:text-brand-600">
{n.title}
</Link>
) : (
<p className="text-sm font-medium text-foreground">{n.title}</p>
)}
{n.content && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{n.content}</p>}
<p className="text-xs text-muted-foreground mt-1">
{new Date(n.createdAt).toLocaleString('zh-CN')}
</p>
</div>
{!n.isRead && (
<button onClick={() => markRead(n.id)}
className="text-xs text-muted-foreground hover:text-foreground shrink-0 px-2 py-1 rounded hover:bg-accent">
</button>
)}
</div>
))}
{notifications.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<p className="text-4xl mb-4">🔔</p>
<p></p>
<p className="text-sm mt-1"></p>
</div>
)}
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import Link from 'next/link';
import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap } from 'lucide-react';
const stats = [
{ value: '50+', label: 'AI 专题' },
{ value: '200+', label: '精选提示词' },
{ value: '30+', label: 'AI 工具评测' },
{ value: '10,000+', label: '探索者' },
];
const features = [
{ icon: Compass, title: '分领域指南', desc: '按职业和场景分类内容,学即所用,精准提升 AI 应用能力' },
{ icon: Bot, title: 'AI 沙盒实战', desc: '内置 AI 对话沙盒,边学边练,在实践中掌握提示词技巧' },
{ icon: BookOpen, title: '提示词库', desc: '精选 200+ 提示词模板,覆盖办公、编程、创作等场景' },
{ icon: Zap, title: '持续更新', desc: '紧跟大模型迭代,内容实时更新,始终走在 AI 前沿' },
];
export default function HomePage() {
return (
<HomePageClient>
{/* Hero */}
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-blue-50 dark:from-brand-950/30 dark:via-background dark:to-blue-950/20" />
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiM2NjdlZWEiIGZpbGwtb3BhY2l0eT0iMC4wNCI+PHBhdGggZD0iTTM2IDM0djItSDI0di0yaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-50 dark:opacity-20" />
<div className="absolute top-20 right-0 w-96 h-96 bg-brand-400/10 dark:bg-brand-400/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-20 w-72 h-72 bg-blue-400/10 dark:bg-blue-400/5 rounded-full blur-3xl" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-28 relative">
<div className="text-center max-w-3xl mx-auto animate-fade-in-up">
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 rounded-full mb-8 border border-brand-200 dark:border-brand-800">
<Sparkles className="w-3.5 h-3.5" />
AI
</span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight">
<span className="bg-gradient-to-r from-brand-600 via-brand-500 to-blue-500 bg-clip-text text-transparent">
</span>
<br /> AI
</h1>
<p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
AI AI
<br className="hidden sm:block" />
AI
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/courses"
className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]"
>
<BookOpen className="w-5 h-5" />
<ArrowRight className="w-4 h-4" />
</Link>
<Link
href="/auth?tab=register"
className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm"
>
</Link>
</div>
</div>
</div>
</section>
{/* Stats */}
<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) => (
<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}
</div>
<div className="mt-1.5 text-sm text-muted-foreground">{s.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold"></h2>
<p className="mt-4 text-lg text-muted-foreground"> AI</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{features.map((feat) => (
<div key={feat.title} className="group text-center p-8 rounded-2xl bg-card border border-border hover:border-brand-200 dark:hover:border-brand-800 transition-all hover:-translate-y-1 hover:shadow-lg">
<div className="w-14 h-14 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center mx-auto mb-5 group-hover:scale-110 transition-transform">
<feat.icon className="w-7 h-7 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{feat.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{feat.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Popular Courses */}
<section className="py-20 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-10">
<div>
<h2 className="text-3xl font-bold"></h2>
<p className="mt-2 text-muted-foreground"> AI</p>
</div>
<Link href="/courses" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ title: 'AI 通识:零基础入门', lessons: '12 模块', students: '1,280', tag: '免费', gradient: 'from-brand-500 to-blue-500', desc: '面向零基础用户,带你了解 AI 的基本概念、发展历程和实际应用。' },
{ title: '提示词工程从入门到精通', lessons: '20 模块', students: '860', tag: '热门', gradient: 'from-violet-500 to-purple-500', desc: '系统学习提示词编写技巧,掌握与 AI 高效沟通的方法。' },
{ title: '用 AI 提升 10 倍办公效率', lessons: '15 模块', students: '2,150', tag: '推荐', gradient: 'from-amber-500 to-orange-500', desc: '学习使用 AI 工具处理文档、数据分析、演示制作等日常工作。' },
].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 ${course.gradient}`} />
<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">{course.desc}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="w-4 h-4" />
{course.lessons}
</span>
<span className="flex items-center gap-1">
<span className="text-lg leading-none">·</span>
{course.students}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* AI Sandbox Preview */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-10">
<div>
<h2 className="text-3xl font-bold">AI </h2>
<p className="mt-2 text-muted-foreground">线 AI </p>
</div>
<Link href="/sandbox" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-xl">
<div className="flex items-center gap-1.5 px-4 pt-3 pb-2 border-b border-border">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-400" />
<span className="w-3 h-3 rounded-full bg-yellow-400" />
<span className="w-3 h-3 rounded-full bg-green-400" />
</div>
<span className="ml-2 text-xs text-muted-foreground">AI - 线</span>
</div>
<div className="p-4 space-y-4 bg-muted/30 dark:bg-muted/10">
<div className="flex items-start gap-3">
<span className="w-7 h-7 bg-brand-600 rounded-lg flex items-center justify-center text-white text-xs font-bold shrink-0">Y</span>
<div className="bg-card dark:bg-card rounded-xl rounded-tl-none px-3 py-2.5 text-sm shadow-sm max-w-[80%]">
AI AI
</div>
</div>
<div className="flex items-start gap-3 justify-end">
<div className="bg-brand-50 dark:bg-brand-900/30 rounded-xl rounded-tr-none px-3 py-2.5 text-sm max-w-[80%]">
Python Fibonacci
</div>
<span className="w-7 h-7 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-xs font-bold shrink-0"></span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground pl-9">
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
...
</div>
</div>
<div className="border-t border-border p-3 bg-card">
<div className="flex gap-2">
<input
type="text"
placeholder="输入你的问题..."
readOnly
className="flex-1 bg-muted border-0 rounded-lg px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<button className="px-4 py-2 bg-brand-600 text-white text-sm font-medium rounded-lg hover:bg-brand-700 transition-colors cursor-default">
</button>
</div>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 bg-gradient-to-r from-brand-600 to-brand-800 dark:from-brand-900 dark:to-brand-950 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjAgMjB2MTBoLTEwVjIwaDEwek0yMCAwaDEwdjEwSDIwVjB6IiBmaWxsPSIjZmZmIiBmaWxsLW9wYWNpdHk9IjAuMDMiLz48L3N2Zz4=')] opacity-50" />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative">
<h2 className="text-3xl font-bold text-white mb-4"> AI </h2>
<p className="text-brand-100/80 dark:text-brand-200/80 mb-8 text-lg"></p>
<Link
href="/auth?tab=register"
className="inline-flex items-center gap-2 px-8 py-3 text-base font-medium text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-xl"
>
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</section>
</HomePageClient>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '隐私政策 - 宇之然',
description: '宇之然 AI 学习与实践平台隐私政策',
};
export default function PrivacyPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8"></h1>
<p className="text-sm text-muted-foreground mb-8">2025 1 </p>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed mb-3">
使
</p>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li>使 AI </li>
<li></li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3">使</h2>
<p className="text-muted-foreground leading-relaxed mb-3"></p>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li></li>
<li></li>
<li> AI </li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
SSL/TLS 访 100%
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3">Cookie 使</h2>
<p className="text-muted-foreground leading-relaxed">
使 Cookie Cookie
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed mb-3"></p>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li>访</li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
contact@yuzhiran.com
</p>
</section>
</div>
);
}
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import PromptsPage from '../app/prompts/page';
describe('Prompts Page', () => {
it('should render prompts page', () => {
render(<PromptsPage />);
const heading = screen.getByText(/提示词库/i);
expect(heading).toBeInTheDocument();
});
it('should have correct title', () => {
render(<PromptsPage />);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toBeInTheDocument();
});
});
+75
View File
@@ -0,0 +1,75 @@
'use client';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { MessageSquare, Heart } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Prompt {
id: number; title: string; description: string; content: string;
model: string | null; likeCount: number; tags: string | null;
}
function PromptSkeleton() {
return (
<Card className="p-5">
<div className="flex gap-2 mb-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-5 w-3/4 mb-1" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-16" />
</Card>
);
}
export default function PromptsPage() {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/prompts`)
.then(r => r.json()).then(data => setPrompts(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1,2,3,4].map(i => <PromptSkeleton key={i} />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{prompts.map((prompt) => (
<Card key={prompt.id} className="p-5 hover:shadow-md transition-shadow group">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{prompt.tags?.split(',').slice(0, 2).map(tag => (
<Badge key={tag} variant="default">{tag.trim()}</Badge>
))}
{prompt.model && (
<span className="text-xs text-muted-foreground ml-auto">{prompt.model}</span>
)}
</div>
<h3 className="font-semibold group-hover:text-brand-600 transition-colors mb-1">{prompt.title}</h3>
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">{prompt.description || prompt.content}</p>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Heart className="w-3.5 h-3.5" />
<span>{prompt.likeCount}</span>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth';
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export default function PromptWorkshopPage() {
const router = useRouter();
const { isLoggedIn } = useAuth();
const [prompt, setPrompt] = useState('');
const [variables, setVariables] = useState<Record<string, string>>({});
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '欢迎使用提示词工坊!输入你的提示词,测试不同模型的效果。' },
]);
const [sending, setSending] = useState(false);
const [model, setModel] = useState(DEFAULT_MODEL);
const [showSave, setShowSave] = useState(false);
const [saveTitle, setSaveTitle] = useState('');
const [saveDesc, setSaveDesc] = useState('');
const [saveTags, setSaveTags] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
async function handleTest() {
if (!prompt.trim() || sending) return;
const userMsg: Message = { role: 'user', content: prompt };
setMessages(prev => [...prev, userMsg]);
setSending(true);
try {
const tk = getToken();
let reply = '';
if (tk) {
const res = await fetch(`${API_BASE}/sandbox/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({
model,
messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content })),
}),
});
const data = await res.json();
reply = data.reply || '无响应';
} else {
await new Promise(r => setTimeout(r, 600));
reply = `你输入的提示词是:\n\n${prompt}\n\n---\n登录后可测试真实模型回复。`;
}
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (e: any) {
setMessages(prev => [...prev, { role: 'assistant', content: `错误: ${e.message}` }]);
} finally {
setSending(false);
}
}
function insertVariable(name: string) {
setPrompt(prev => prev + `{{${name}}}`);
}
async function handleSave() {
if (!saveTitle.trim() || saving) return;
setSaving(true);
try {
await apiFetch('/prompts', {
method: 'POST',
body: JSON.stringify({
title: saveTitle,
content: prompt,
description: saveDesc || undefined,
tags: saveTags || undefined,
model: model === 'general' ? undefined : model,
}),
});
setSaved(true);
setShowSave(false);
setSaveTitle('');
setSaveDesc('');
setSaveTags('');
setTimeout(() => setSaved(false), 3000);
} catch (e: any) {
alert('保存失败: ' + e.message);
} finally {
setSaving(false);
}
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<button onClick={() => router.back()} className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
{saved && (
<span className="text-sm text-green-600 font-medium"></span>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<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"></h3>
<select
value={model}
onChange={e => setModel(e.target.value)}
className="px-3 py-1.5 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
{AVAILABLE_MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder={'输入你的提示词...\n使用 {{变量名}} 定义变量\n\n例如:你是一名{{角色}},请帮我{{任务}}'}
rows={12}
className="w-full px-4 py-3 border border-border rounded-xl text-sm font-mono bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
<div className="mt-4 flex gap-2">
<button
onClick={handleTest}
disabled={sending || !prompt.trim()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700 disabled:opacity-50"
>
{sending ? '测试中...' : '测试提示词'}
</button>
<button
onClick={() => {
setPrompt('');
setMessages([{ role: 'assistant', content: '欢迎使用提示词工坊!' }]);
}}
className="px-4 py-2 border border-border rounded-lg text-sm text-foreground hover:bg-accent"
>
</button>
{isLoggedIn && prompt.trim() && (
<button
onClick={() => setShowSave(true)}
className="px-4 py-2 border border-border rounded-lg text-sm text-brand-600 hover:bg-accent ml-auto"
>
</button>
)}
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h3 className="font-semibold text-foreground mb-4"></h3>
<div className="space-y-3">
{['角色', '任务', '输出格式', '约束条件'].map(varName => (
<div key={varName} className="flex items-center gap-3">
<span className="text-sm text-muted-foreground w-20">{varName}:</span>
<input
type="text"
value={variables[varName] || ''}
onChange={e => setVariables(prev => ({ ...prev, [varName]: e.target.value }))}
placeholder={`输入${varName}...`}
className="flex-1 px-3 py-1.5 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={() => insertVariable(varName)}
className="text-xs text-brand-600 hover:underline"
>
</button>
</div>
))}
</div>
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h3 className="font-semibold text-foreground mb-4"></h3>
<div className="h-[60vh] overflow-y-auto space-y-4">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-xs font-bold shrink-0">
Y
</div>
)}
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm ${
msg.role === 'user'
? 'bg-brand-600 text-white'
: 'bg-muted text-foreground'
}`}>
{msg.content}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 bg-muted-foreground/20 rounded-xl flex items-center justify-center text-xs font-bold shrink-0">
</div>
)}
</div>
))}
{sending && (
<div className="flex gap-3">
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-xs font-bold">
Y
</div>
<div className="bg-muted rounded-2xl rounded-tl-none px-4 py-2.5">
<span className="inline-flex gap-1">
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
</div>
</div>
</div>
{showSave && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowSave(false)}>
<div className="bg-card rounded-2xl border border-border p-6 w-full max-w-lg mx-4 shadow-xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground block mb-1"> *</label>
<input type="text" value={saveTitle} onChange={e => setSaveTitle(e.target.value)}
placeholder="提示词标题"
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div>
<label className="text-sm font-medium text-foreground block mb-1"></label>
<input type="text" value={saveDesc} onChange={e => setSaveDesc(e.target.value)}
placeholder="简短描述这个提示词的用途"
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div>
<label className="text-sm font-medium text-foreground block mb-1"></label>
<input type="text" value={saveTags} onChange={e => setSaveTags(e.target.value)}
placeholder="用逗号分隔,如:编程,Python,调试"
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button onClick={() => setShowSave(false)}
className="px-4 py-2 border border-border rounded-lg text-sm text-foreground hover:bg-accent">
</button>
<button onClick={handleSave} disabled={saving || !saveTitle.trim()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700 disabled:opacity-50">
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
const TEMPLATES = [
{
id: 'blank',
name: '空白',
html: '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>页面</title>\n <style>\n body {\n font-family: system-ui, sans-serif;\n max-width: 720px;\n margin: 0 auto;\n padding: 2rem;\n }\n </style>\n</head>\n<body>\n <h1>Hello, World!</h1>\n <script>\n console.log("Hello from Code Sandbox!");\n </script>\n</body>\n</html>',
},
{
id: 'react',
name: 'React (CDN)',
html: '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>React Demo</title>\n <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>\n <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>\n <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>\n</head>\n<body>\n <div id="root"></div>\n <script type="text/babel">\n function App() {\n const [count, setCount] = React.useState(0);\n return (\n <div style={{ textAlign: "center", padding: "2rem" }}>\n <h1>React 计数器</h1>\n <p>计数: {count}</p>\n <button onClick={() => setCount(c => c + 1)}>+1</button>\n <button onClick={() => setCount(c => c - 1)}>-1</button>\n </div>\n );\n }\n ReactDOM.createRoot(document.getElementById("root")).render(<App />);\n </script>\n</body>\n</html>',
},
{
id: 'chart',
name: '图表 (Chart.js)',
html: '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>图表</title>\n <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>\n <style>\n body { font-family: system-ui, sans-serif; display: flex; justify-content: center; padding: 2rem; }\n canvas { max-width: 600px; max-height: 400px; }\n </style>\n</head>\n<body>\n <div style="width: 600px;">\n <h2 style="text-align: center;">示例图表</h2>\n <canvas id="myChart"></canvas>\n </div>\n <script>\n new Chart(document.getElementById("myChart"), {\n type: "bar",\n data: {\n labels: ["一月", "二月", "三月", "四月", "五月", "六月"],\n datasets: [{\n label: "销量",\n data: [12, 19, 3, 5, 2, 3],\n backgroundColor: "rgba(99, 102, 241, 0.5)",\n borderColor: "rgb(99, 102, 241)",\n borderWidth: 1,\n }],\n },\n });\n </script>\n</body>\n</html>',
},
{
id: 'three',
name: '3D (Three.js)',
html: '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>Three.js</title>\n <script type="importmap">\n { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } }\n </script>\n</head>\n<body>\n <script type="module">\n import * as THREE from "three";\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);\n const renderer = new THREE.WebGLRenderer();\n renderer.setSize(window.innerWidth, window.innerHeight);\n document.body.appendChild(renderer.domElement);\n const geo = new THREE.BoxGeometry(1, 1, 1);\n const mat = new THREE.MeshPhongMaterial({ color: 0x6366f1 });\n const cube = new THREE.Mesh(geo, mat);\n scene.add(cube);\n const light = new THREE.DirectionalLight(0xffffff, 1);\n light.position.set(5, 5, 5);\n scene.add(light);\n scene.add(new THREE.AmbientLight(0x404060));\n camera.position.z = 3;\n function animate() {\n requestAnimationFrame(animate);\n cube.rotation.x += 0.01;\n cube.rotation.y += 0.01;\n renderer.render(scene, camera);\n }\n animate();\n </script>\n</body>\n</html>',
},
];
export default function CodeSandboxPage() {
const router = useRouter();
const [code, setCode] = useState(TEMPLATES[0].html);
const [template, setTemplate] = useState('blank');
const [previewKey, setPreviewKey] = useState(0);
const [logs, setLogs] = useState<string[]>([]);
const [error, setError] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const initialCode = params.get('code');
if (initialCode) {
try {
setCode(atob(initialCode));
setTemplate('');
} catch {}
}
}, []);
function handleTemplateSelect(tplId: string) {
const tpl = TEMPLATES.find(t => t.id === tplId);
if (tpl) {
setCode(tpl.html);
setTemplate(tplId);
setLogs([]);
setError('');
}
}
function handleRun() {
setPreviewKey(k => k + 1);
setLogs([]);
setError('');
}
function handleIframeLoad() {
try {
const iframe = iframeRef.current;
if (!iframe || !iframe.contentWindow) return;
const win = iframe.contentWindow;
const origLog = win.console.log;
const origError = win.console.error;
const newLogs: string[] = [];
win.console.log = (...args: any[]) => {
newLogs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' '));
setLogs(prev => [...prev, ...newLogs.slice(prev.length)]);
};
win.console.error = (...args: any[]) => {
const msg = '❌ ' + args.map(a => String(a)).join(' ');
newLogs.push(msg);
setLogs(prev => [...prev, msg]);
};
setTimeout(() => {
setLogs(newLogs);
}, 500);
} catch {}
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleRun();
}
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
<div className="flex items-center justify-between px-4 sm:px-6 py-2 border-b border-border bg-card shrink-0">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/sandbox')} className="text-sm text-muted-foreground hover:text-foreground">
&larr;
</button>
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="flex items-center gap-2">
<select value={template} onChange={e => handleTemplateSelect(e.target.value)}
className="px-2 py-1 text-xs border border-border rounded-lg bg-background text-foreground focus:outline-none">
<option value="">...</option>
{TEMPLATES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<button onClick={handleRun}
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700">
()
</button>
</div>
</div>
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
<div className="flex-1 flex flex-col min-h-0 lg:w-1/2">
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/30 shrink-0">
HTML / CSS / JavaScript
</div>
<textarea
value={code}
onChange={e => { setCode(e.target.value); setTemplate(''); }}
onKeyDown={handleKeyDown}
className="flex-1 w-full p-4 text-sm font-mono leading-relaxed bg-background text-foreground resize-none focus:outline-none"
spellCheck={false}
/>
</div>
<div className="flex-1 flex flex-col min-h-0 lg:w-1/2 border-t lg:border-t-0 lg:border-l border-border">
<div className="flex-1 bg-background relative">
<iframe
key={previewKey}
ref={iframeRef}
srcDoc={code}
onLoad={handleIframeLoad}
className="w-full h-full border-0"
title="预览"
sandbox="allow-scripts allow-modals allow-same-origin"
/>
</div>
{logs.length > 0 && (
<div className="h-32 border-t border-border bg-card overflow-y-auto p-3 shrink-0">
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
{logs.map((log, i) => (
<div key={i} className="text-xs font-mono text-foreground py-0.5">{log}</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { AVAILABLE_MODELS } from '@/lib/models';
interface ModelResult {
model: string;
label: string;
reply: string;
loading: boolean;
error?: string;
}
const MODELS = AVAILABLE_MODELS;
export default function ComparePage() {
const router = useRouter();
const [prompt, setPrompt] = useState('');
const [results, setResults] = useState<ModelResult[]>(
MODELS.map(m => ({ ...m, reply: '', loading: false }))
);
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(1);
const [maxTokens, setMaxTokens] = useState(2000);
async function handleCompare() {
if (!prompt.trim() || sending) return;
setSending(true);
const newResults = results.map(r => ({ ...r, reply: '', loading: true, error: undefined }));
setResults(newResults);
await Promise.all(
MODELS.map(async (model, index) => {
try {
const res = await apiFetch('/sandbox/chat', {
method: 'POST',
body: JSON.stringify({
model: model.id,
messages: [{ role: 'user', content: prompt }],
temperature,
top_p: topP,
max_tokens: maxTokens,
}),
});
const data = await res.json();
setResults(prev => prev.map((r, i) =>
i === index ? { ...r, reply: data.reply || '无响应', loading: false } : r
));
} catch (e: any) {
setResults(prev => prev.map((r, i) =>
i === index ? { ...r, error: e.message, loading: false } : r
));
}
})
);
setSending(false);
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<button onClick={() => router.back()} className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</button>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="输入你想对比的问题或提示词..."
rows={4}
className="w-full px-4 py-3 border border-border rounded-xl text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-none mb-4"
/>
<div className="flex items-center justify-between">
<button
onClick={handleCompare}
disabled={sending || !prompt.trim()}
className="px-6 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{sending ? '对比中...' : '开始对比'}
</button>
<button onClick={() => setShowParams(!showParams)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
showParams
? 'bg-accent text-foreground border-border'
: 'text-muted-foreground border-border hover:text-foreground hover:bg-accent'
}`}>
<svg className={`w-3.5 h-3.5 transition-transform ${showParams ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
</div>
{showParams && (
<div className="mt-4 grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-xl border border-border">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Temperature</label>
<span className="text-xs text-muted-foreground tabular-nums">{temperature.toFixed(1)}</span>
</div>
<input type="range" min="0" max="2" step="0.1" value={temperature}
onChange={e => setTemperature(parseFloat(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Top P</label>
<span className="text-xs text-muted-foreground tabular-nums">{topP.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" value={topP}
onChange={e => setTopP(parseFloat(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Max Tokens</label>
<span className="text-xs text-muted-foreground tabular-nums">{maxTokens}</span>
</div>
<input type="range" min="100" max="8192" step="100" value={maxTokens}
onChange={e => setMaxTokens(parseInt(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
</div>
</div>
)}
</div>
{results.some(r => r.reply || r.loading || r.error) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{results.map((result, index) => (
<div key={index} className="bg-card rounded-2xl border border-border overflow-hidden">
<div className="bg-muted/50 px-6 py-3 border-b border-border flex items-center justify-between">
<span className="font-medium text-foreground">{result.label}</span>
<span className="text-xs text-muted-foreground">{result.model}</span>
</div>
<div className="p-6">
{result.loading ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
) : result.error ? (
<p className="text-red-500 text-sm">{result.error}</p>
) : (
<p className="text-foreground leading-relaxed whitespace-pre-wrap">{result.reply}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+584
View File
@@ -0,0 +1,584 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
import Link from 'next/link';
import { useAuth } from '@/lib/auth-context';
import { getToken, apiFetch } from '@/lib/auth';
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface SessionItem {
id: number;
conversationId: string;
model: string;
title: string;
updatedAt: string;
tokens: number;
}
const SCENES = [
{
id: 'general',
name: '通用对话',
icon: '💬',
desc: '日常问答,无所不谈',
systemPrompt: '你是一个智能 AI 助手,请友好、准确地回答用户的问题。',
starters: ['介绍一下你自己', '今天天气怎么样', '讲个笑话'],
},
{
id: 'coding',
name: '编程助手',
icon: '💻',
desc: '写代码、Debug、学编程',
systemPrompt: '你是一名资深软件工程师,擅长编程教学。请用清晰的代码示例和通俗的语言解释技术概念。回答时优先提供可运行的代码。',
starters: ['用 Python 写一个二分查找', 'React 和 Vue 有什么区别', '帮我 Debug 这段代码'],
},
{
id: 'writing',
name: '写作助手',
icon: '✍️',
desc: '文章、文案、报告润色',
systemPrompt: '你是一名专业的写作顾问,擅长各类文体写作。请根据用户需求提供高质量的文字内容,注意逻辑清晰、表达准确。',
starters: ['帮我写一篇产品介绍', '润色这段文字', '写一封工作邮件'],
},
{
id: 'study',
name: '学习辅导',
icon: '📚',
desc: '概念讲解、知识总结',
systemPrompt: '你是一名耐心且知识渊博的老师。请用通俗易懂的方式解释复杂概念,善用类比和例子,鼓励用户深入提问。',
starters: ['解释什么是机器学习', '讲一下 TCP/IP 协议', '怎么理解量子计算'],
},
{
id: 'english',
name: '英语学习',
icon: '🌍',
desc: '翻译、语法、口语练习',
systemPrompt: 'You are an English tutor. Help users improve their English. Respond primarily in Chinese but provide English examples. Correct grammar and offer better expressions.',
starters: ['"However" 和 "Although" 的区别', '帮我翻译这段话', '检查语法错误'],
},
];
function buildSystemMessages(sceneId: string): Message[] {
const scene = SCENES.find(s => s.id === sceneId) || SCENES[0];
return [{ role: 'system', content: scene.systemPrompt }];
}
function extractCodeBlocks(content: string): string[] {
const blocks: string[] = [];
const regex = /```(?:\w+)?\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(content)) !== null) {
const code = match[1].trim();
if (code.length > 0) blocks.push(code);
}
return blocks;
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
export default function SandboxPage() {
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是宇之然 AI 助手。你可以问我任何问题,我会尽力帮你解答。\n\n试试问我关于 AI、编程、写作、办公效率等方面的问题!' },
]);
const [input, setInput] = useState('');
const [model, setModel] = useState(DEFAULT_MODEL);
const [scene, setScene] = useState('general');
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
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 [sessions, setSessions] = useState<SessionItem[]>([]);
const [sessionsOpen, setSessionsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [conversationId, setConversationId] = useState(() => crypto.randomUUID());
const [sessionFeedback, setSessionFeedback] = useState<Record<number, string | null>>({});
const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { isLoggedIn } = useAuth();
useEffect(() => {
const tk = getToken();
if (tk) {
fetch(`${API_BASE}/sandbox/quota`, {
headers: { Authorization: `Bearer ${tk}` },
}).then(r => r.json()).then(data => {
if (data.remaining !== undefined) setQuota(data);
}).catch(() => {});
loadSessions(tk);
}
}, [isLoggedIn]);
useEffect(() => {
if (messages.some(m => m.role === 'user') && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
function loadSessions(tk?: string) {
const token = tk || getToken();
if (!token) return;
const params = searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : '';
fetch(`${API_BASE}/sandbox/sessions${params}`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json()).then(data => {
if (data.items) setSessions(data.items);
}).catch(() => {});
}
function handleSceneChange(sceneId: string) {
const s = SCENES.find(x => x.id === sceneId);
if (!s) return;
setScene(sceneId);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${s.name}** 模式!${s.desc}。试试下面的问题,或者直接输入你的问题吧。` },
]);
}
async function handleSend(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const userMsg: Message = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const tk = getToken();
let reply = '';
if (tk) {
const scenePrefix = buildSystemMessages(scene);
const apiMessages = [
...scenePrefix,
...messages,
userMsg,
].map(m => ({ role: m.role, content: m.content }));
const res = await fetch(`${API_BASE}/sandbox/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
reply = data.reply;
if (data.conversationId) setConversationId(data.conversationId);
if (data.sessionId) {
setCurrentSessionId(data.sessionId);
if (!(data.sessionId in sessionFeedback)) {
setSessionFeedback(prev => ({ ...prev, [data.sessionId]: null }));
}
}
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
loadSessions(tk);
} else {
await new Promise(r => setTimeout(r, 600));
reply = mockReply(text);
}
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (e: any) {
if (e.message.includes('今日沙箱使用次数已用完')) {
setMessages(prev => [...prev, { role: 'assistant', content: '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。') }]);
} else if (e.message.includes('未登录') || e.message.includes('Unauthorized')) {
setMessages(prev => [...prev, { role: 'assistant', content: '登录已过期,请重新登录后再试。' }]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
}
} finally {
setSending(false);
}
}
async function loadSession(sessionId: number) {
const tk = getToken();
if (!tk) return;
try {
const res = await fetch(`${API_BASE}/sandbox/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${tk}` },
});
const data = await res.json();
if (data.messages) {
setMessages(data.messages.filter((m: any) => m.role !== 'system'));
setModel(data.model);
setConversationId(data.conversationId);
setCurrentSessionId(data.id);
setSessionFeedback(prev => ({ ...prev, [data.id]: data.feedback || null }));
}
} catch {}
}
async function deleteSession(sessionId: number) {
const tk = getToken();
if (!tk) return;
try {
await fetch(`${API_BASE}/sandbox/sessions/${sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${tk}` },
});
setSessions(prev => prev.filter(s => s.id !== sessionId));
} catch {}
}
function newChat() {
const s = SCENES.find(x => x.id === scene) || SCENES[0];
setConversationId(crypto.randomUUID());
setCurrentSessionId(null);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${s.name}** 模式!${s.desc}。试试下面的问题,或者直接输入你的问题吧。` },
]);
}
async function handleFeedback(sessionId: number, value: 'LIKE' | 'DISLIKE') {
const tk = getToken();
if (!tk) return;
const newVal = sessionFeedback[sessionId] === value ? null : value;
setSessionFeedback(prev => ({ ...prev, [sessionId]: newVal }));
try {
await fetch(`${API_BASE}/sandbox/sessions/${sessionId}/feedback`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({ feedback: newVal }),
});
} catch {}
}
const currentScene = SCENES.find(s => s.id === scene) || SCENES[0];
const isNewChat = messages.length <= 1 && messages[0]?.role === 'assistant';
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button onClick={() => setSessionsOpen(!sessionsOpen)}
className="lg:hidden p-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-2xl font-bold text-foreground">AI </h1>
<p className="text-sm text-muted-foreground mt-0.5">线 AI </p>
</div>
</div>
<div className="flex items-center gap-3">
<select value={model} onChange={e => setModel(e.target.value)}
className="px-3 py-1.5 border border-border rounded-lg text-sm bg-background focus:outline-none focus:border-brand-400">
{AVAILABLE_MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{!isLoggedIn && (
<Link href="/auth"
className="px-4 py-1.5 text-sm font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50">
使
</Link>
)}
</div>
</div>
<div className="flex gap-4">
{isLoggedIn && (
<>
<div className={`${sessionsOpen ? 'fixed inset-0 z-40 bg-black/50 lg:static lg:bg-transparent' : 'hidden'} lg:block lg:w-72 shrink-0`}>
<div className={`${sessionsOpen ? 'fixed left-0 top-0 bottom-0 w-80 z-50' : ''} lg:static lg:w-72 bg-card border border-border rounded-2xl overflow-hidden flex flex-col`} style={{ maxHeight: '75vh' }}>
<div className="p-3 border-b border-border flex items-center justify-between">
<span className="text-sm font-medium text-foreground"></span>
<button onClick={newChat}
className="text-xs px-3 py-1 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
</button>
</div>
<div className="p-2 border-b border-border">
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索历史..." onKeyDown={e => { if (e.key === 'Enter') loadSessions(); }}
className="w-full px-3 py-1.5 bg-background border border-border rounded-lg text-xs focus:outline-none focus:border-brand-400" />
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
</div>
) : sessions.map(s => (
<div key={s.id} onClick={() => { loadSession(s.id); setSessionsOpen(false); }}
className="group px-3 py-2.5 hover:bg-accent cursor-pointer border-b border-border/50">
<div className="text-xs font-medium text-foreground truncate">{s.title}</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-muted-foreground">{formatTime(s.updatedAt)} · {s.model}</span>
<button onClick={e => { e.stopPropagation(); deleteSession(s.id); }}
className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700">
</button>
</div>
</div>
))}
</div>
</div>
{sessionsOpen && (
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setSessionsOpen(false)} />
)}
</div>
</>
)}
<div className="flex-1 min-w-0">
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
{SCENES.map(s => (
<button key={s.id} onClick={() => handleSceneChange(s.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium whitespace-nowrap border transition-colors shrink-0 ${
scene === s.id
? 'bg-brand-600 text-white border-brand-600'
: 'bg-card text-muted-foreground border-border hover:border-brand-400 hover:text-foreground'
}`}>
<span>{s.icon}</span>
<span>{s.name}</span>
</button>
))}
</div>
<div className="flex items-center justify-between mb-2">
<div />
<button onClick={() => setShowParams(!showParams)}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg border transition-colors ${
showParams
? 'bg-accent text-foreground border-border'
: 'text-muted-foreground border-border hover:text-foreground hover:bg-accent'
}`}>
<svg className={`w-3.5 h-3.5 transition-transform ${showParams ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
</div>
{showParams && (
<div className="bg-card border border-border rounded-xl p-4 mb-3 space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Temperature</label>
<span className="text-xs text-muted-foreground tabular-nums">{temperature.toFixed(1)}</span>
</div>
<input type="range" min="0" max="2" step="0.1" value={temperature}
onChange={e => setTemperature(parseFloat(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span> (0)</span>
<span> (2)</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Top P</label>
<span className="text-xs text-muted-foreground tabular-nums">{topP.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" value={topP}
onChange={e => setTopP(parseFloat(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span> (0)</span>
<span> (1)</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-foreground">Max Tokens</label>
<span className="text-xs text-muted-foreground tabular-nums">{maxTokens}</span>
</div>
<input type="range" min="100" max="8192" step="100" value={maxTokens}
onChange={e => setMaxTokens(parseInt(e.target.value))}
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span> (100)</span>
<span> (8192)</span>
</div>
</div>
</div>
)}
<div className="bg-card rounded-2xl border border-border shadow-sm overflow-hidden flex flex-col" style={{ maxHeight: '65vh' }}>
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{isNewChat && (
<div className="flex flex-wrap gap-2 mb-4">
{currentScene.starters.map((q, i) => (
<button key={i} onClick={() => setInput(q)}
className="px-3 py-1.5 text-xs bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground transition-colors">
{q}
</button>
))}
</div>
)}
{messages.map((msg, i) => {
if (msg.role === 'system') return null;
const isLastAssistant = msg.role === 'assistant' && i === messages.length - 1;
return (
<div key={i}>
<div className={`flex items-start gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
)}
<div className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-brand-600 text-white rounded-tr-none'
: 'bg-muted text-foreground rounded-tl-none'
}`}>
{msg.content}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 bg-muted-foreground/20 rounded-xl flex items-center justify-center text-xs font-bold shrink-0"></div>
)}
</div>
{msg.role === 'assistant' && currentSessionId && isLastAssistant && (
<div className="flex items-center gap-2 mt-1 ml-11">
<button onClick={() => handleFeedback(currentSessionId, 'LIKE')}
className={`text-xs px-2 py-1 rounded-full border transition-colors ${
sessionFeedback[currentSessionId] === 'LIKE'
? 'bg-green-500/10 text-green-600 border-green-300'
: 'text-muted-foreground border-border hover:border-green-300 hover:text-green-600'
}`}>
</button>
<button onClick={() => handleFeedback(currentSessionId, 'DISLIKE')}
className={`text-xs px-2 py-1 rounded-full border transition-colors ${
sessionFeedback[currentSessionId] === 'DISLIKE'
? 'bg-red-500/10 text-red-600 border-red-300'
: 'text-muted-foreground border-border hover:border-red-300 hover:text-red-600'
}`}>
</button>
</div>
)}
{msg.role === 'assistant' && extractCodeBlocks(msg.content).length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 ml-11">
{extractCodeBlocks(msg.content).map((code, ci) => (
<button key={ci} onClick={() => {
const encoded = btoa(code);
window.open(`/sandbox/code?code=${encoded}`, '_blank');
}}
className="text-xs px-2.5 py-1 rounded-full border border-border text-brand-600 hover:bg-accent transition-colors">
</button>
))}
</div>
)}
</div>
);
})}
{sending && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
<div className="bg-muted rounded-2xl rounded-tl-none px-4 py-2.5">
<span className="inline-flex gap-1">
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
{!sending && messages.length > 2 && (
<div className="flex justify-end">
<button onClick={() => {
const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAssistantMsg) shareToCommunity(lastAssistantMsg.content);
}} className="text-xs text-brand-600 hover:underline">
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border p-4">
{quota && (
<div className="text-xs text-muted-foreground mb-2">
{quota.used} {quota.remaining}
</div>
)}
<form onSubmit={handleSend} className="flex gap-2">
<input type="text" value={input} onChange={e => setInput(e.target.value)}
placeholder="输入你的问题..." disabled={sending}
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" />
<button type="submit" disabled={sending || !input.trim()}
className="px-5 py-2.5 bg-brand-600 text-white text-sm font-medium rounded-xl hover:bg-brand-700 disabled:opacity-50">
{sending ? '发送中' : '发送'}
</button>
</form>
</div>
</div>
<div className="mt-4 text-center text-xs text-muted-foreground">
AI {!isLoggedIn && ' 登录后可获得更多使用次数和更多模型选择。'}
</div>
</div>
</div>
</div>
);
async function shareToCommunity(content: string, title?: string) {
if (!getToken()) {
alert('请先登录');
return;
}
try {
await apiFetch('/community/posts', {
method: 'POST',
body: JSON.stringify({
title: title || `AI对话分享 - ${new Date().toLocaleDateString()}`,
content: `【AI沙箱对话分享】\n\n${content}\n\n---\n来自宇之然AI沙箱`,
tags: '沙箱分享,AI对话',
}),
});
alert('分享成功!');
} catch {
alert('分享失败');
}
}
}
function mockReply(text: string): string {
const replies: Record<string, string> = {
: '你好!我是宇之然 AI 助手,很高兴为你服务!有什么我可以帮助你的吗?',
hello: 'Hello! I am YuZhiRan AI assistant. How can I help you today?',
};
for (const [key, reply] of Object.entries(replies)) {
if (text.toLowerCase().includes(key)) return reply;
}
if (text.includes('提示词') || text.includes('prompt')) {
return '好的提示词需要明确角色、任务、输出格式和约束条件。例如:\n\n> 你是一名专业的文案编辑,请帮我优化以下产品描述,要求语言简洁有力,突出产品核心卖点,控制在200字以内。\n\n你也可以在提示词库中找到更多精选模板!';
}
if (text.includes('模型') || text.includes('大模型')) {
return '目前主流的 AI 大模型包括:\n\n• **GPT-4** — OpenAI,综合能力最强\n• **Claude 3.5** — Anthropic,长文本分析出色\n• **Gemini** — Google,多模态能力强\n• **DeepSeek-V3** — 国产开源,性价比高\n• **通义千问** — 阿里云,中文理解优秀\n• **文心一言** — 百度,中文生态完善\n\n各模型在语言理解、代码生成、逻辑推理等方面各有优势,建议根据具体任务选择。';
}
if (text.includes('Python') || text.includes('代码')) {
return '以下是一个 Python 示例代码:\n\n```python\ndef fibonacci(n):\n """生成斐波那契数列的前 n 项"""\n a, b = 0, 1\n result = []\n for _ in range(n):\n result.append(a)\n a, b = b, a + b\n return result\n\nprint(fibonacci(10))\n```\n\n你可以将代码复制到本地运行,或在沙盒中进一步调试。';
}
if (text.includes('AI') || text.includes('人工智能')) {
return '人工智能(AI)是计算机科学的一个重要分支,旨在创建能够模拟人类智能的系统。\n\n**主要分支:**\n• 机器学习 — 让计算机从数据中学习\n• 深度学习 — 使用多层神经网络的机器学习\n• 自然语言处理 — 理解和生成人类语言\n• 计算机视觉 — 理解和分析图像\n\n想了解更多,可以查看我们的 AI 通识课程!';
}
return `关于"${text.slice(0, 30)}..."这个问题,我是宇之然 AI 助手。当前处于演示模式,我的回答能力有限。\n\n**建议:**\n1. 登录后使用更多模型获得更好的回答\n2. 在提示词库中查找相关模板\n3. 学习 AI 通识课程系统提升\n\n有什么我可以进一步帮助你的吗?`;
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { SearchIcon, FileText, BookOpen, Wrench, MessageSquare } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface SearchResult {
id: number; _type: 'course' | 'prompt' | 'tool' | 'content';
title?: string; name?: string; description?: string; summary?: string;
cover?: string; icon?: string; url?: string; model?: string; isFree?: boolean; publishedAt?: string;
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-[60vh]"><div className="space-y-4 w-full max-w-2xl px-4"><Skeleton className="h-8 w-48 mx-auto" /><Skeleton className="h-4 w-32 mx-auto" /><Skeleton className="h-64 w-full" /></div></div>}>
<SearchContent />
</Suspense>
);
}
function SearchContent() {
const searchParams = useSearchParams();
const q = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all';
const [results, setResults] = useState<SearchResult[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [input, setInput] = useState(q);
useEffect(() => {
if (!q) return;
setLoading(true);
fetch(`${API_BASE}/search?q=${encodeURIComponent(q)}&type=${type}`)
.then(r => r.json()).then(data => { setResults(data.results || []); setTotal(data.total || 0); })
.catch(() => {}).finally(() => setLoading(false));
}, [q, type]);
const groups = { course: results.filter(r => r._type === 'course'), prompt: results.filter(r => r._type === 'prompt'), tool: results.filter(r => r._type === 'tool'), content: results.filter(r => r._type === 'content') };
const groupLabels: Record<string, string> = { course: '专题', prompt: '提示词', tool: 'AI 工具', content: '文章' };
const groupIcons: Record<string, any> = { course: BookOpen, prompt: MessageSquare, tool: Wrench, content: FileText };
const groupLinks: Record<string, string> = { course: '/courses/', prompt: '/prompts', tool: '/tools', content: '/contents/' };
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 mb-4"></h1>
<form onSubmit={e => { e.preventDefault(); window.location.href = `/search?q=${encodeURIComponent(input)}`; }}>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<Input type="text" value={input} onChange={e => setInput(e.target.value)}
placeholder="搜索专题、提示词、工具、文章..." className="pl-10 h-12 text-base" />
</div>
</form>
</div>
{!q && (
<div className="text-center py-20 text-muted-foreground">
<SearchIcon className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
</div>
)}
{q && loading && (
<div className="space-y-4">
{[1,2,3].map(i => <Skeleton key={i} className="h-24 w-full rounded-xl" />)}
</div>
)}
{q && !loading && total === 0 && (
<div className="text-center py-20 text-muted-foreground">
<p> "<span className="text-foreground font-medium">{q}</span>" </p>
</div>
)}
{q && !loading && total > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-6"> {total} </p>
<div className="space-y-8">
{Object.entries(groups).map(([key, items]) => {
if (items.length === 0) return null;
const Icon = groupIcons[key];
return (
<div key={key}>
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{groupLabels[key]}</span>
<span className="text-xs text-muted-foreground">{items.length} </span>
</div>
<div className="grid gap-3">
{items.map((item) => (
<Link key={`${key}-${item.id}`}
href={`${groupLinks[key]}${key === 'course' || key === 'content' ? item.id : ''}`}
className="block">
<Card className="p-4 hover:border-brand-200 dark:hover:border-brand-800 transition-colors">
<h3 className="font-semibold">{item.title || item.name}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.description || item.summary}</p>
<div className="flex gap-2 mt-2">
{key === 'course' && (
<Badge variant={item.isFree ? 'success' : 'destructive'}>
{item.isFree ? '免费' : '付费'}
</Badge>
)}
{key === 'prompt' && item.model && (
<span className="text-xs text-muted-foreground">: {item.model}</span>
)}
</div>
</Card>
</Link>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '服务协议 - 宇之然',
description: '宇之然 AI 学习与实践平台服务协议',
};
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-foreground mb-8"></h1>
<p className="text-sm text-muted-foreground mb-8">2025 1 </p>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI "本平台" AI AI AI 使
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed mb-3">使</p>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li></li>
<li></li>
<li>访</li>
<li> AI </li>
<li></li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
使
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<ul className="list-disc pl-6 text-muted-foreground leading-relaxed space-y-1">
<li></li>
<li></li>
<li>退</li>
<li>退</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
AI
AI
AI
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
</p>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3"></h2>
<p className="text-muted-foreground leading-relaxed">
contact@yuzhiran.com
</p>
</section>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Wrench, ExternalLink, Star } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Tool {
id: number; name: string; description: string; url: string;
icon: string | null; isFeatured: boolean; tags: string | null;
}
function ToolSkeleton() {
return (
<Card className="p-5">
<div className="flex gap-2 mb-2">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
<Skeleton className="h-5 w-1/2 mb-1" />
<Skeleton className="h-4 w-full" />
</Card>
);
}
export default function ToolsPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/tools`)
.then(r => r.json()).then(data => setTools(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground">AI </h1>
<p className="mt-2 text-muted-foreground"> AI </p>
</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 => <ToolSkeleton key={i} />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tools.map((tool) => (
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
<div className="flex items-start gap-3 mb-2">
<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">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{tool.name}</h3>
{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>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-1" />
</div>
<div className="flex gap-2 mt-2">
{tool.tags?.split(',').slice(0, 2).map(tag => (
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
))}
</div>
</Card>
</a>
))}
</div>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/users`, {
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
const users = data.items || [];
if (users.length === 0) return [{ id: '1' }];
return users.map((u: any) => ({ id: String(u.id) }));
} catch {
return [{ id: '1' }];
}
}
import UserProfilePage from './user-profile';
export default function Page() {
return <UserProfilePage />;
}
@@ -0,0 +1,215 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface UserProfile {
id: number; nickname: string; avatar?: string; bio?: string;
followerCount: number; followingCount: number; postCount: number;
createdAt: string;
}
interface Post {
id: number; title: string; content: string; tags?: string;
viewCount: number; likeCount: number; commentCount: number;
createdAt: string;
}
export default function UserProfilePage() {
const params = useParams();
const userId = Number(params.id);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [isFollowing, setIsFollowing] = useState(false);
const [activeTab, setActiveTab] = useState<'posts' | 'followers' | 'following'>('posts');
const [followers, setFollowers] = useState<any[]>([]);
const [following, setFollowing] = useState<any[]>([]);
useEffect(() => { loadData(); }, [userId]);
function getToken() { return localStorage.getItem('token'); }
function apiHeaders() {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
const t = getToken();
if (t) h['Authorization'] = `Bearer ${t}`;
return h;
}
async function loadData() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const [profileRes, postsRes] = await Promise.all([
fetch(`${base}/api/v1/community/users/${userId}/profile`),
fetch(`${base}/api/v1/community/posts?userId=${userId}`),
]);
if (profileRes.ok) setProfile(await profileRes.json());
if (postsRes.ok) {
const d = await postsRes.json();
setPosts(d.items || []);
}
const token = getToken();
if (token) {
const followRes = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
headers: { Authorization: `Bearer ${token}` },
});
if (followRes.ok) {
const d = await followRes.json();
setIsFollowing(d.followed);
}
}
} catch (e) { console.error(e) }
}
async function toggleFollow() {
const token = getToken();
if (!token) return;
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
method: isFollowing ? 'DELETE' : 'POST',
headers: apiHeaders(),
});
if (res.ok) {
setIsFollowing(!isFollowing);
setProfile(prev => prev ? {
...prev,
followerCount: prev.followerCount + (isFollowing ? -1 : 1),
} : prev);
}
} catch (e) { console.error(e) }
}
async function loadFollowers() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/followers`);
if (res.ok) {
const d = await res.json();
setFollowers(d.items || []);
}
} catch (e) { console.error(e) }
}
async function loadFollowing() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/following`);
if (res.ok) {
const d = await res.json();
setFollowing(d.items || []);
}
} catch (e) { console.error(e) }
}
useEffect(() => {
if (activeTab === 'followers') loadFollowers();
if (activeTab === 'following') loadFollowing();
}, [activeTab]);
if (!profile) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-card rounded-xl border border-border p-8 mb-8">
<div className="flex items-start justify-between">
<div className="flex items-center gap-6">
<div className="w-20 h-20 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-2xl font-bold">
{profile.nickname?.[0] || 'U'}
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{profile.nickname || '用户'}</h1>
{profile.bio && <p className="text-muted-foreground mt-1">{profile.bio}</p>}
<div className="flex gap-6 mt-3 text-sm text-muted-foreground">
<span><strong className="text-foreground">{profile.postCount}</strong> </span>
<span><strong className="text-foreground">{profile.followerCount}</strong> </span>
<span><strong className="text-foreground">{profile.followingCount}</strong> </span>
</div>
</div>
</div>
<button onClick={toggleFollow}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
isFollowing ? 'bg-muted text-muted-foreground hover:bg-accent' : 'bg-brand-600 text-white hover:bg-brand-700'
}`}>
{isFollowing ? '已关注' : '关注'}
</button>
</div>
</div>
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
{(['posts', 'followers', 'following'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-muted-foreground'
}`}>
{tab === 'posts' ? '帖子' : tab === 'followers' ? '粉丝' : '关注'}
</button>
))}
</div>
{activeTab === 'posts' && (
<div className="space-y-4">
{posts.map(post => (
<Link key={post.id} href={`/community/${post.id}`}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<h3 className="font-semibold text-foreground mb-2">{post.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{post.content}</p>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<span>👁 {post.viewCount}</span>
<span> {post.likeCount}</span>
<span>💬 {post.commentCount}</span>
</div>
</Link>
))}
{posts.length === 0 && <p className="text-center text-muted-foreground py-12"></p>}
</div>
)}
{activeTab === 'followers' && (
<div className="bg-card rounded-xl border border-border divide-y">
{followers.map((u: any) => (
<Link key={u.id} href={`/users/${u.id}`}
className="flex items-center gap-3 px-6 py-4 hover:bg-muted/50">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-sm font-bold">
{u.nickname?.[0] || 'U'}
</div>
<div>
<div className="font-medium text-foreground">{u.nickname || '用户'}</div>
<div className="text-xs text-muted-foreground">{u.followerCount} </div>
</div>
</Link>
))}
{followers.length === 0 && <p className="text-center text-muted-foreground py-8"></p>}
</div>
)}
{activeTab === 'following' && (
<div className="bg-card rounded-xl border border-border divide-y">
{following.map((u: any) => (
<Link key={u.id} href={`/users/${u.id}`}
className="flex items-center gap-3 px-6 py-4 hover:bg-muted/50">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-sm font-bold">
{u.nickname?.[0] || 'U'}
</div>
<div>
<div className="font-medium text-foreground">{u.nickname || '用户'}</div>
<div className="text-xs text-muted-foreground">{u.followerCount} </div>
</div>
</Link>
))}
{following.length === 0 && <p className="text-center text-muted-foreground py-8"></p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Footer } from '../footer';
describe('Footer', () => {
it('should render brand name', () => {
render(<Footer />);
expect(screen.getByText('宇之然 AI')).toBeInTheDocument();
});
it('should render mission statement', () => {
render(<Footer />);
expect(screen.getByText('让每个人都能用好 AI')).toBeInTheDocument();
});
it('should render navigation links', () => {
render(<Footer />);
expect(screen.getByText('专题')).toBeInTheDocument();
expect(screen.getByText('提示词库')).toBeInTheDocument();
expect(screen.getByText('AI 工具')).toBeInTheDocument();
});
it('should render about links', () => {
render(<Footer />);
expect(screen.getByText('关于我们')).toBeInTheDocument();
expect(screen.getByText('隐私政策')).toBeInTheDocument();
expect(screen.getByText('服务协议')).toBeInTheDocument();
expect(screen.getByText('AI 服务协议')).toBeInTheDocument();
});
it('should render contact info', () => {
render(<Footer />);
expect(screen.getByText(/contact@yuzhiran\.com/)).toBeInTheDocument();
expect(screen.getByText('北京宇之然科技中心')).toBeInTheDocument();
});
it('should render ICP link', () => {
render(<Footer />);
const icpLink = screen.getByText(/ICP 备案号/);
expect(icpLink).toBeInTheDocument();
expect(icpLink.closest('a')).toHaveAttribute('href', 'https://beian.miit.gov.cn/');
});
it('should render copyright with current year', () => {
render(<Footer />);
const year = new Date().getFullYear();
expect(screen.getByText(new RegExp(`${year}`))).toBeInTheDocument();
});
it('should have correct links for navigation items', () => {
render(<Footer />);
const coursesLink = screen.getByText('专题').closest('a');
expect(coursesLink).toHaveAttribute('href', '/courses');
const privacyLink = screen.getByText('隐私政策').closest('a');
expect(privacyLink).toHaveAttribute('href', '/privacy');
const termsLink = screen.getByText('服务协议').closest('a');
expect(termsLink).toHaveAttribute('href', '/terms');
const aiAgreementLink = screen.getByText('AI 服务协议').closest('a');
expect(aiAgreementLink).toHaveAttribute('href', '/ai-agreement');
});
});
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import Header from '../header';
describe('Header Component', () => {
it('should render navigation links', () => {
render(<Header />);
// 检查关键导航链接
const homeLink = screen.getByText(/首页|Home/i);
expect(homeLink).toBeInTheDocument();
const coursesLink = screen.getByText(/学堂|课程/i);
expect(coursesLink).toBeInTheDocument();
const promptsLink = screen.getByText(/提示词/i);
expect(promptsLink).toBeInTheDocument();
});
it('should have login link when not logged in', () => {
render(<Header />);
const loginLink = screen.getByText(/登录|Login/i);
expect(loginLink).toBeInTheDocument();
});
});
+56
View File
@@ -0,0 +1,56 @@
import Link from 'next/link';
export function Footer() {
return (
<footer className="border-t border-border bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
<div className="col-span-2 md:col-span-1">
<h3 className="text-lg font-bold bg-gradient-to-r from-brand-600 to-brand-400 bg-clip-text text-transparent mb-4"> AI</h3>
<p className="text-sm text-muted-foreground"> AI</p>
</div>
<div>
<h4 className="text-sm font-semibold mb-3"></h4>
<ul className="space-y-2.5">
<li><Link href="/courses" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/sandbox" className="text-sm text-muted-foreground hover:text-foreground transition-colors">AI </Link></li>
<li><Link href="/prompts" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/models" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/tools" className="text-sm text-muted-foreground hover:text-foreground transition-colors">AI </Link></li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-3"></h4>
<ul className="space-y-2.5">
<li><Link href="/about" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/privacy" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/terms" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/ai-agreement" className="text-sm text-muted-foreground hover:text-foreground transition-colors">AI </Link></li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-3"></h4>
<ul className="space-y-2.5">
<li className="text-sm text-muted-foreground">contact@yuzhiran.com</li>
<li className="text-sm text-muted-foreground"></li>
</ul>
</div>
</div>
<div className="mt-10 pt-8 border-t border-border">
<div className="flex flex-col md:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<p>&copy; {new Date().getFullYear()} </p>
<p>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
ICP ICP备XXXXXXXX号
</a>
<span className="mx-2">|</span>
<a href="https://www.beian.gov.cn/" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
XXXXXXXXXXXX号
</a>
</p>
</div>
</div>
</div>
</footer>
);
}
+201
View File
@@ -0,0 +1,201 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Search, Menu, X, Bell } from 'lucide-react';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
const navItems = [
{ href: '/', label: '首页' },
{ href: '/courses', label: '专题' },
{ href: '/sandbox', label: '沙盒' },
{ href: '/models', label: '模型' },
{ href: '/prompts', label: '提示词' },
{ href: '/contents', label: '文章' },
{ href: '/tools', label: 'AI 工具' },
{ href: '/community', label: '社区' },
];
function NotificationBellComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
async function load() {
try {
const res = await apiFetch('/notifications/unread');
if (res.ok) {
const data = await res.json();
setCount(data.count || 0);
}
} catch {}
}
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, []);
return (
<span className="relative">
<Button variant="ghost" size="icon" className="rounded-full" asChild>
<Link href="/notifications">
<Bell className="h-5 w-5" />
</Link>
</Button>
{count > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center rounded-full px-1">
{count > 99 ? '99+' : count}
</span>
)}
</span>
);
}
export function Header() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const { isLoggedIn, logout } = useAuth();
function isActive(href: string) {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 16);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<header
className={`sticky top-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-background/80 backdrop-blur-xl border-b border-border shadow-sm'
: 'bg-background/50 backdrop-blur-md'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-2 group">
<span className="text-2xl font-bold bg-gradient-to-r from-brand-600 to-brand-400 bg-clip-text text-transparent"></span>
<span className="text-lg text-muted-foreground hidden sm:inline">AI</span>
</Link>
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-all ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{item.label}
</Link>
))}
</nav>
<div className="hidden md:flex items-center gap-2">
<form action="/search" className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="q"
type="text"
placeholder="搜索..."
className="w-36 lg:w-48 pl-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
/>
</form>
<ThemeToggle />
</div>
<div className="hidden md:flex items-center gap-2">
{isLoggedIn ? (
<>
<NotificationBellComponent />
<Button variant="default" size="sm" asChild>
<Link href="/dashboard"></Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { logout(); window.location.href = '/'; }}
>
退
</Button>
</>
) : (
<>
<Button variant="ghost" size="sm" asChild>
<Link href="/auth"></Link>
</Button>
<Button variant="default" size="sm" asChild>
<Link href="/auth?tab=register"></Link>
</Button>
</>
)}
</div>
<div className="flex md:hidden items-center gap-2">
<ThemeToggle />
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-full"
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
</div>
{mobileOpen && (
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block py-2.5 px-2 text-sm rounded-lg transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
<div className="flex gap-3 mt-4">
{isLoggedIn ? (
<>
<Button className="w-full" size="sm" asChild>
<Link href="/dashboard" className="flex-1" onClick={() => setMobileOpen(false)}></Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => { logout(); window.location.href = '/'; }}>
退
</Button>
</>
) : (
<>
<Button variant="outline" className="w-full" size="sm" asChild>
<Link href="/auth" className="flex-1" onClick={() => setMobileOpen(false)}></Link>
</Button>
<Button className="w-full" size="sm" asChild>
<Link href="/auth?tab=register" className="flex-1" onClick={() => setMobileOpen(false)}></Link>
</Button>
</>
)}
</div>
</nav>
)}
</div>
</header>
);
}
@@ -0,0 +1,8 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+39
View File
@@ -0,0 +1,39 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300',
secondary: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
destructive: 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400',
success: 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400',
outline: 'border border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]',
{
variants: {
variant: {
default: 'bg-brand-600 text-white shadow-sm hover:bg-brand-700 dark:bg-brand-500 dark:hover:bg-brand-600',
destructive: 'bg-red-500 text-white shadow-sm hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700',
outline: 'border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-white',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white',
link: 'text-brand-600 underline-offset-4 hover:underline dark:text-brand-400',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-xs',
lg: 'h-11 rounded-lg px-8 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100', className)} {...props} />
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-gray-500 dark:text-gray-400', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+78
View File
@@ -0,0 +1,78 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }
@@ -0,0 +1,83 @@
'use client';
import { useState, useRef } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
interface ImageUploadProps {
onUploaded: (url: string) => void;
defaultImage?: string;
accept?: string;
}
export function ImageUpload({ onUploaded, defaultImage, accept = 'image/*' }: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState(defaultImage || '');
const [error, setError] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const token = localStorage.getItem('token');
if (!token) {
setError('请先登录');
return;
}
setUploading(true);
setError('');
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${API_BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || '上传失败');
}
const data = await res.json();
setPreview(`${API_BASE}${data.url}`);
onUploaded(data.url);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
}
return (
<div className="space-y-2">
<div
className="relative w-32 h-32 border-2 border-dashed border-gray-200 rounded-lg overflow-hidden cursor-pointer hover:border-brand-400 transition-colors bg-gray-50"
onClick={() => inputRef.current?.click()}
>
{preview ? (
<img src={preview} alt="preview" className="w-full h-full object-cover" />
) : (
<div className="flex items-center justify-center w-full h-full text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<input ref={inputRef} type="file" accept={accept} onChange={handleFile} className="hidden" />
{error && <p className="text-xs text-red-500">{error}</p>}
<p className="text-xs text-gray-400"> 5MB jpg/png/gif/webp</p>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-brand-400',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
export { Input }
@@ -0,0 +1,15 @@
'use client'
import { motion } from 'framer-motion'
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-brand-600 transition-all duration-500 dark:bg-brand-400"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-gray-200 dark:bg-gray-700', className)} {...props} />
}
export { Skeleton }
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { Toaster as SonnerToaster } from 'sonner';
type ToasterProps = React.ComponentProps<typeof SonnerToaster>;
function Toaster({ ...props }: ToasterProps) {
return (
<SonnerToaster
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-brand-600 group-[.toast]:text-white',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
}
export { Toaster };
+54
View File
@@ -0,0 +1,54 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,22 @@
'use client'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-full"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
)
}
+27
View File
@@ -0,0 +1,27 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
interface AuthContextType {
isLoggedIn: boolean;
initialized: boolean;
login: (token: string, refreshToken?: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>({
isLoggedIn: false,
initialized: false,
login: () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
setIsLoggedIn(!!localStorage.getItem('token'));
setInitialized(true);
}, []);
const login = useCallback((token: string, refreshTk?: string) => {
localStorage.setItem('token', token);
if (refreshTk) localStorage.setItem('refreshToken', refreshTk);
setIsLoggedIn(true);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
setIsLoggedIn(false);
}, []);
return (
<AuthContext.Provider value={{ isLoggedIn, initialized, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+90
View File
@@ -0,0 +1,90 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
}
export function getAdminToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('adminToken');
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refreshToken');
}
export function setTokens(accessToken: string, refreshToken?: string) {
localStorage.setItem('token', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
}
export function clearTokens() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
}
export function clearAdminToken() {
localStorage.removeItem('adminToken');
}
export function isLoggedIn(): boolean {
return !!getToken();
}
export function isAdminLoggedIn(): boolean {
return !!getAdminToken();
}
export async function adminApiFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const token = getAdminToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(`${API_BASE}${url}`, { ...opts, headers });
}
export async function refreshToken(): Promise<string | null> {
const token = getToken();
const refreshTk = getRefreshToken();
if (!token && !refreshTk) return null;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: token }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
clearTokens();
return null;
}
}
export async function apiFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
if (res.status === 401 && token) {
const newToken = await refreshToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
}
}
return res;
}

Some files were not shown because too many files have changed in this diff Show More