feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
coverage
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
@@ -0,0 +1,2 @@
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,33 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tini
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/next.config*.js ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER node
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('后台管理页面', () => {
|
||||
const adminPages = [
|
||||
'/admin',
|
||||
'/admin/login',
|
||||
'/admin/users',
|
||||
'/admin/courses',
|
||||
'/admin/prompts',
|
||||
'/admin/contents',
|
||||
'/admin/tools',
|
||||
'/admin/orders',
|
||||
'/admin/comments',
|
||||
'/admin/enterprise',
|
||||
];
|
||||
|
||||
for (const path of adminPages) {
|
||||
test.describe(`${path}`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(path);
|
||||
});
|
||||
|
||||
test('页面能正常加载', async ({ page }) => {
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveText(/500/);
|
||||
await expect(page.locator('body')).not.toHaveText(/Application Error/);
|
||||
await expect(page.locator('body')).not.toHaveText(/Cannot read properties/);
|
||||
});
|
||||
|
||||
test('页面有内容展示', async ({ page }) => {
|
||||
const anyHeading = page.locator('h1, h2, h3, h4, .text-2xl, .text-3xl');
|
||||
const headingCount = await anyHeading.count();
|
||||
const bodyText = await page.locator('body').textContent() || '';
|
||||
expect(headingCount > 0 || bodyText.length > 50).toBeTruthy();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('管理后台编辑页面', () => {
|
||||
const editPages = [
|
||||
'/admin/courses/new',
|
||||
'/admin/prompts/new',
|
||||
'/admin/contents/new',
|
||||
'/admin/tools/new',
|
||||
];
|
||||
|
||||
for (const path of editPages) {
|
||||
test(`${path} 渲染正常`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveText(/500/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('暗黑模式切换', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('主题切换按钮存在', async ({ page }) => {
|
||||
const toggleBtn = page.locator('button').filter({ has: page.locator('svg') }).first();
|
||||
await expect(toggleBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('点击主题切换按钮无错误', async ({ page }) => {
|
||||
const toggleBtn = page.locator('button').filter({ has: page.locator('svg') }).first();
|
||||
await expect(toggleBtn).toBeEnabled();
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('html')).toBeAttached();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('功能页面', () => {
|
||||
test.describe('沙盒 (/sandbox)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/sandbox');
|
||||
});
|
||||
|
||||
test('页面渲染正常', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('模型选择器存在', async ({ page }) => {
|
||||
const select = page.locator('select');
|
||||
const count = await select.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('消息输入框存在', async ({ page }) => {
|
||||
await expect(page.locator('textarea, input[type="text"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('发送按钮存在', async ({ page }) => {
|
||||
await expect(page.getByText('发送').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('初始欢迎消息展示', async ({ page }) => {
|
||||
await expect(page.getByText(/你好!我是宇之然 AI 助手/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('对比实验室链接存在', async ({ page }) => {
|
||||
const compareLink = page.getByText('对比实验室').or(page.getByText('模型对比'));
|
||||
const count = await page.getByText('对比实验室').count();
|
||||
if (count > 0) {
|
||||
const link = page.getByText('对比实验室').first();
|
||||
await expect(link).toBeVisible();
|
||||
const href = await link.getAttribute('href');
|
||||
if (href) {
|
||||
await expect(link).toHaveAttribute('href', /\/sandbox\/compare|\/compare/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('对比实验室 (/sandbox/compare)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/sandbox/compare');
|
||||
});
|
||||
|
||||
test('页面渲染正常', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('提示词工坊 (/prompts/workshop)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/prompts/workshop');
|
||||
});
|
||||
|
||||
test('页面渲染正常', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('搜索 (/search)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
});
|
||||
|
||||
test('搜索输入框存在', async ({ page }) => {
|
||||
await expect(page.locator('input[type="text"], input[name="q"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('搜索按钮存在', async ({ page }) => {
|
||||
await expect(page.getByText('搜索').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('发现页及子页', () => {
|
||||
test('/discover 渲染', async ({ page }) => {
|
||||
await page.goto('/discover');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
const dailyLink = page.getByText('每日精选').first();
|
||||
if (await dailyLink.isVisible()) {
|
||||
await expect(dailyLink).toHaveAttribute('href', '/discover/daily');
|
||||
}
|
||||
});
|
||||
|
||||
test('/discover/daily 渲染', async ({ page }) => {
|
||||
await page.goto('/discover/daily');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('/discover/hot 渲染', async ({ page }) => {
|
||||
await page.goto('/discover/hot');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('/discover/tools 渲染', async ({ page }) => {
|
||||
await page.goto('/discover/tools');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('认证页 (/auth)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/auth');
|
||||
});
|
||||
|
||||
test('登录表单可见', async ({ page }) => {
|
||||
await expect(page.getByText('登录').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('注册表单可切换', async ({ page }) => {
|
||||
await page.goto('/auth?tab=register');
|
||||
await expect(page.getByText('注册').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('首页 (/)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('页面标题正确', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/宇之然 AI/);
|
||||
});
|
||||
|
||||
test('Hero 区域渲染', async ({ page }) => {
|
||||
await expect(page.getByText('免费 AI 知识社区')).toBeVisible();
|
||||
await expect(page.getByText('让每个人').first()).toBeVisible();
|
||||
await expect(page.getByText('都能用好 AI').first()).toBeVisible();
|
||||
await expect(page.getByText('宇之然 AI 是面向大众的 AI 工具与知识社区')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hero 按钮存在并指向正确链接', async ({ page }) => {
|
||||
const startBtn = page.getByText('开始探索').first();
|
||||
await expect(startBtn).toBeVisible();
|
||||
await expect(startBtn).toHaveAttribute('href', '/courses');
|
||||
|
||||
const registerBtn = page.getByText('免费注册').first();
|
||||
await expect(registerBtn).toBeVisible();
|
||||
await expect(registerBtn).toHaveAttribute('href', '/auth?tab=register');
|
||||
});
|
||||
|
||||
test('统计数据区域', async ({ page }) => {
|
||||
await expect(page.getByText('50+').first()).toBeVisible();
|
||||
await expect(page.getByText('200+').first()).toBeVisible();
|
||||
await expect(page.getByText('30+').first()).toBeVisible();
|
||||
await expect(page.getByText('10,000+').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('特性区域', async ({ page }) => {
|
||||
await expect(page.getByText('为什么选择宇之然?')).toBeVisible();
|
||||
await expect(page.getByText('分领域指南').first()).toBeVisible();
|
||||
await expect(page.getByText('AI 沙盒实战').first()).toBeVisible();
|
||||
await expect(page.getByText('提示词库').first()).toBeVisible();
|
||||
await expect(page.getByText('持续更新').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('热门专题区域', async ({ page }) => {
|
||||
await expect(page.getByText('热门专题')).toBeVisible();
|
||||
await expect(page.getByText('AI 通识:零基础入门')).toBeVisible();
|
||||
await expect(page.getByText('提示词工程从入门到精通')).toBeVisible();
|
||||
await expect(page.getByText('用 AI 提升 10 倍办公效率')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AI 沙盒预览区域', async ({ page }) => {
|
||||
await expect(page.getByText('AI 沙盒').first()).toBeVisible();
|
||||
await expect(page.getByText('在线体验 AI 对话,边学边练')).toBeVisible();
|
||||
const sandboxLink = page.getByText('打开沙盒').first();
|
||||
await expect(sandboxLink).toHaveAttribute('href', '/sandbox');
|
||||
});
|
||||
|
||||
test('CTA 区域', async ({ page }) => {
|
||||
await expect(page.getByText('准备好开启 AI 之旅了吗?')).toBeVisible();
|
||||
await expect(page.getByText('立即注册,免费探索所有内容')).toBeVisible();
|
||||
});
|
||||
|
||||
test('点击 Hero 开始探索进入课程页', async ({ page }) => {
|
||||
await page.getByText('开始探索').first().click();
|
||||
await expect(page).toHaveURL(/\/courses/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('列表页面', () => {
|
||||
const listingPages = [
|
||||
{ path: '/courses', title: '专题', skeleton: true },
|
||||
{ path: '/prompts', title: '提示词', skeleton: true },
|
||||
{ path: '/community', title: '社区', skeleton: true },
|
||||
{ path: '/tools', title: 'AI 工具', skeleton: true },
|
||||
{ path: '/contents', title: '文章', skeleton: true },
|
||||
{ path: '/models', title: '模型', skeleton: false },
|
||||
{ path: '/circles', title: '圈子', skeleton: false },
|
||||
{ path: '/discover', title: '发现', skeleton: false },
|
||||
{ path: '/dashboard', title: '控制台', skeleton: true },
|
||||
];
|
||||
|
||||
for (const { path, title, skeleton } of listingPages) {
|
||||
test.describe(`${path}`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(path);
|
||||
});
|
||||
|
||||
test('页面标题元素存在', async ({ page }) => {
|
||||
const anyHeading = page.locator('h1, h2, h3, h4, [class*="text-2xl"], [class*="text-3xl"]');
|
||||
const count = await anyHeading.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('页面能正常加载', async ({ page }) => {
|
||||
await expect(page.locator('body')).not.toHaveText(/500/);
|
||||
await expect(page.locator('body')).not.toHaveText(/Application Error/);
|
||||
});
|
||||
|
||||
if (skeleton) {
|
||||
test('加载骨架屏显示', async ({ page }) => {
|
||||
const skeletons = page.locator('[class*="animate-pulse"], [class*="skeleton"]');
|
||||
const count = await skeletons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('导航与页脚', () => {
|
||||
test.describe('Header 导航', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('品牌名称可见', async ({ page }) => {
|
||||
await expect(page.getByText('宇之然').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('主导航链接都可见', async ({ page }) => {
|
||||
const navLinks = ['首页', '专题', '沙盒', '模型', '提示词', '文章', 'AI 工具', '社区'];
|
||||
for (const link of navLinks) {
|
||||
const el = page.getByRole('link', { name: link, exact: true });
|
||||
const count = await el.count();
|
||||
if (count > 1) {
|
||||
await expect(el.first()).toBeVisible();
|
||||
} else if (count === 1) {
|
||||
await expect(el).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('搜索框可见', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder="搜索..."]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
test('主题切换按钮可见', async ({ page }) => {
|
||||
const themeToggle = page.locator('button').filter({ has: page.locator('svg') }).first();
|
||||
await expect(themeToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test('登录/注册按钮可见', async ({ page }) => {
|
||||
await expect(page.getByText('登录').first()).toBeVisible();
|
||||
await expect(page.getByText('注册').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('点击导航链接可跳转', async ({ page }) => {
|
||||
const navMap = [
|
||||
{ label: '专题', url: '/courses' },
|
||||
{ label: '沙盒', url: '/sandbox' },
|
||||
{ label: '模型', url: '/models' },
|
||||
{ label: '提示词', url: '/prompts' },
|
||||
{ label: 'AI 工具', url: '/tools' },
|
||||
{ label: '社区', url: '/community' },
|
||||
{ label: '文章', url: '/contents' },
|
||||
];
|
||||
for (const { label, url } of navMap) {
|
||||
await page.getByRole('link', { name: label }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(url));
|
||||
await page.goBack();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Footer 页脚', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('品牌信息展示', async ({ page }) => {
|
||||
await expect(page.getByText('让每个人都能用好 AI').first()).toBeVisible();
|
||||
const footerHeading = page.locator('footer h3');
|
||||
await expect(footerHeading).toContainText('宇之然 AI');
|
||||
});
|
||||
|
||||
test('导航链接', async ({ page }) => {
|
||||
await expect(page.getByText('专题').last()).toBeVisible();
|
||||
await expect(page.getByText('提示词库').last()).toBeVisible();
|
||||
await expect(page.getByText('AI 工具').last()).toBeVisible();
|
||||
});
|
||||
|
||||
test('关于链接', async ({ page }) => {
|
||||
await expect(page.getByText('关于我们')).toBeVisible();
|
||||
await expect(page.getByText('隐私政策')).toBeVisible();
|
||||
await expect(page.getByText('服务协议').first()).toBeVisible();
|
||||
await expect(page.getByText('AI 服务协议')).toBeVisible();
|
||||
});
|
||||
|
||||
test('联系方式', async ({ page }) => {
|
||||
await expect(page.getByText('contact@yuzhiran.com').first()).toBeVisible();
|
||||
const footerInfo = page.locator('footer li, footer p').filter({ hasText: '北京宇之然科技中心' });
|
||||
await expect(footerInfo.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('ICP 备案号链接', async ({ page }) => {
|
||||
const icpEl = page.locator('footer a').filter({ hasText: /ICP备案|ICP 备案/ });
|
||||
await expect(icpEl).toBeVisible();
|
||||
await expect(icpEl).toHaveAttribute('href', 'https://beian.miit.gov.cn/');
|
||||
});
|
||||
|
||||
test('Footer 链接点击跳转正确', async ({ page }) => {
|
||||
const footerLinks: [string, RegExp][] = [
|
||||
['专题', /\/courses/],
|
||||
['关于我们', /\/about/],
|
||||
['隐私政策', /\/privacy/],
|
||||
['服务协议', /\/terms/],
|
||||
['AI 服务协议', /\/ai-agreement/],
|
||||
];
|
||||
for (const [text, urlPattern] of footerLinks) {
|
||||
const link = page.locator('footer a').filter({ hasText: text });
|
||||
await expect(link.first()).toBeVisible();
|
||||
await link.first().click();
|
||||
await expect(page).toHaveURL(urlPattern);
|
||||
await page.goBack();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('404 页面', () => {
|
||||
test('访问不存在页面显示 404', async ({ page }) => {
|
||||
await page.goto('/this-page-does-not-exist-12345');
|
||||
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const has404 = page.getByText('404').first();
|
||||
const hasNotFound = page.getByText(/找不到|不存在|页面/i).first();
|
||||
const is404 = await has404.isVisible().catch(() => false);
|
||||
const isNotFound = await hasNotFound.isVisible().catch(() => false);
|
||||
|
||||
expect(is404 || isNotFound).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('静态页面', () => {
|
||||
const pages = [
|
||||
{ path: '/about', title: '关于宇之然', checks: ['我们的使命', '北京宇之然科技中心'] },
|
||||
{ path: '/privacy', title: '隐私政策', checks: ['隐私政策'] },
|
||||
{ path: '/terms', title: '服务协议', checks: ['服务协议'] },
|
||||
{ path: '/ai-agreement', title: 'AI 服务协议', checks: ['AI 服务协议'] },
|
||||
];
|
||||
|
||||
for (const { path, title, checks } of pages) {
|
||||
test.describe(`${path}`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(path);
|
||||
});
|
||||
|
||||
test('页面标题正确', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(new RegExp(title));
|
||||
});
|
||||
|
||||
test('页面标题元素存在', async ({ page }) => {
|
||||
const h1 = page.locator('h1');
|
||||
await expect(h1).toContainText(title);
|
||||
});
|
||||
|
||||
for (const check of checks) {
|
||||
test(`包含 "${check}" 内容`, async ({ page }) => {
|
||||
const el = page.getByText(check, { exact: true });
|
||||
const count = await el.count();
|
||||
if (count > 1) {
|
||||
await expect(el.first()).toBeVisible();
|
||||
} else if (count === 0) {
|
||||
await expect(page.locator('main, article, section').filter({ hasText: check })).toBeVisible();
|
||||
} else {
|
||||
await expect(el).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('用户相关页面', () => {
|
||||
const userPages = [
|
||||
{ path: '/my', title: '我的' },
|
||||
{ path: '/my/learning', title: '学习进度' },
|
||||
{ path: '/my/favorites', title: '收藏夹' },
|
||||
{ path: '/my/member', title: '会员中心' },
|
||||
{ path: '/my/settings', title: '设置' },
|
||||
{ path: '/my/dashboard', title: '数据看板' },
|
||||
{ path: '/notifications', title: '通知' },
|
||||
];
|
||||
|
||||
for (const { path, title } of userPages) {
|
||||
test.describe(`${path}`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(path);
|
||||
});
|
||||
|
||||
test('页面标题元素存在', async ({ page }) => {
|
||||
const anyHeading = page.locator('h1, h2, h3, h4, [class*="text-2xl"], [class*="text-3xl"]');
|
||||
const count = await anyHeading.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('无错误展示', async ({ page }) => {
|
||||
await expect(page.locator('body')).not.toHaveText(/500/);
|
||||
await expect(page.locator('body')).not.toHaveText(/Application Error/);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
images: { unoptimized: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://api.yuzhiran.com',
|
||||
NEXT_PUBLIC_SITE_URL: 'https://yuzhiran.com',
|
||||
NEXT_PUBLIC_SITE_NAME: '宇之然 AI',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+5755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@yuzhiran/frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "宇之然 AI - 官网前端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "rm -rf .next",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:e2e:ui": "npx playwright test --ui",
|
||||
"test:e2e:headed": "npx playwright test --headed",
|
||||
"test:e2e:report": "npx playwright show-report"
|
||||
},
|
||||
"keywords": [
|
||||
"yuzhiran",
|
||||
"ai",
|
||||
"learning",
|
||||
"platform"
|
||||
],
|
||||
"author": "北京宇之然科技中心",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/node": "^25.6.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^14.2.35",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.14",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], locale: 'zh-CN' },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 B |
Binary file not shown.
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="2" y="2" width="60" height="60" rx="12" fill="#2563eb"/>
|
||||
<path d="M32 36 L22 18 M32 36 L42 18 M32 36 L32 48" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">👁 {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">← 返回企业列表</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">← 返回组织详情</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">← 返回圈子列表</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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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">← 返回发现</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>
|
||||
);
|
||||
}
|
||||
@@ -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">← 返回社区</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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回课程列表
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回发现
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回发现
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回发现
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function HomePageClient({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -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">
|
||||
← 返回我的
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回我的
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回我的
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回我的
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回我的
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">← 返回</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回提示词库
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 沙盒
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 返回沙箱
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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有什么我可以进一步帮助你的吗?`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user