feat: AI沙箱流式对话+引导学习+静态服务优化
- 后端: AI沙箱流式对话(SSE), reasoning_content 回退支持 - 前端: 沙箱页流式渲染, 引导学习面板, 默认场景/Starter兜底 - 优化: 前端改用静态文件服务器(node server.js)替代next dev, CSS永不丢失 - 修复: 通用模型默认改为可用模型, predev不再删.next缓存
This commit is contained in:
@@ -107,13 +107,18 @@ export class OperationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('config/:key')
|
@Put('config/:key')
|
||||||
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
|
async updateConfig(@Param('key') key: string, @Body() body: { value: string; description?: string; category?: string }) {
|
||||||
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
|
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value } });
|
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value, ...(body.description !== undefined ? { description: body.description } : {}) } });
|
||||||
}
|
}
|
||||||
return this.prisma.systemConfig.create({
|
return this.prisma.systemConfig.create({
|
||||||
data: { key, value: body.value, category: 'other' },
|
data: { key, value: body.value, category: body.category || 'other', description: body.description || '' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('config/:key')
|
||||||
|
async deleteConfig(@Param('key') key: string) {
|
||||||
|
return this.prisma.systemConfig.delete({ where: { key } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ export interface ModelInfo {
|
|||||||
interface AIProvider {
|
interface AIProvider {
|
||||||
name: string;
|
name: string;
|
||||||
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
|
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
|
||||||
|
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODEL_CATALOG: Record<string, ModelInfo> = {
|
const MODEL_CATALOG: Record<string, ModelInfo> = {
|
||||||
@@ -114,6 +115,54 @@ export class AIGatewayService {
|
|||||||
return this.fallback(messages);
|
return this.fallback(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
|
||||||
|
const modelMap: Record<string, string> = {
|
||||||
|
'general': 'openai',
|
||||||
|
'openai': 'openai',
|
||||||
|
'gpt-3.5': 'openai',
|
||||||
|
'gpt-4': 'openai',
|
||||||
|
'longcat': 'openai',
|
||||||
|
'meituan/longcat-flash-lite': 'openai',
|
||||||
|
'deepseek-v4-flash': 'deepseek-v4-flash',
|
||||||
|
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
||||||
|
'sensenova-u1-fast': 'sensenova-u1-fast',
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
|
||||||
|
const provider = this.providers.get(providerKey);
|
||||||
|
|
||||||
|
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
|
||||||
|
this.usageStats[providerKey].total++;
|
||||||
|
|
||||||
|
if (provider?.chatStream) {
|
||||||
|
try {
|
||||||
|
let hasContent = false;
|
||||||
|
for await (const chunk of provider.chatStream(messages, options)) {
|
||||||
|
hasContent = true;
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
if (!hasContent) throw new Error('AI 返回内容为空');
|
||||||
|
this.usageStats[providerKey].success++;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`${provider.name} 流式调用失败: ${err.message}`);
|
||||||
|
this.usageStats[providerKey].failed++;
|
||||||
|
yield this.fallback(messages);
|
||||||
|
}
|
||||||
|
} else if (provider) {
|
||||||
|
try {
|
||||||
|
const reply = await provider.chat(messages, options);
|
||||||
|
this.usageStats[providerKey].success++;
|
||||||
|
yield reply;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
|
||||||
|
this.usageStats[providerKey].failed++;
|
||||||
|
yield this.fallback(messages);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yield this.fallback(messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fallback(messages: ChatMessage[]): string {
|
private fallback(messages: ChatMessage[]): string {
|
||||||
const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
|
const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
|
||||||
? messages[messages.length - 1]?.content as string : '';
|
? messages[messages.length - 1]?.content as string : '';
|
||||||
@@ -188,6 +237,55 @@ class OpenAICompatibleProvider implements AIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as any;
|
const data = await res.json() as any;
|
||||||
return data.choices[0].message.content;
|
const msg = data.choices[0].message;
|
||||||
|
return msg.content || msg.reasoning_content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
|
||||||
|
const res = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.defaultModel,
|
||||||
|
messages,
|
||||||
|
temperature: options?.temperature ?? 0.7,
|
||||||
|
top_p: options?.top_p ?? 1,
|
||||||
|
max_tokens: options?.max_tokens ?? 2000,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
||||||
|
const data = trimmed.slice(5).trim();
|
||||||
|
if (data === '[DONE]') return;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
const delta = json.choices?.[0]?.delta || {};
|
||||||
|
const content = delta.content || delta.reasoning_content || '';
|
||||||
|
if (content) yield content;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe } from '@nestjs/common';
|
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe, Res } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { SandboxService } from './sandbox.service';
|
import { SandboxService } from './sandbox.service';
|
||||||
import { ChatOptions } from '../ai/ai-gateway.service';
|
import { ChatOptions } from '../ai/ai-gateway.service';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
@ApiTags('AI沙箱')
|
@ApiTags('AI沙箱')
|
||||||
@Controller('sandbox')
|
@Controller('sandbox')
|
||||||
@@ -12,9 +13,25 @@ export class SandboxController {
|
|||||||
constructor(private sandboxService: SandboxService) {}
|
constructor(private sandboxService: SandboxService) {}
|
||||||
|
|
||||||
@Post('chat')
|
@Post('chat')
|
||||||
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000 } } })
|
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000, stream: false } } })
|
||||||
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[] } & ChatOptions) {
|
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[]; stream?: boolean } & ChatOptions, @Res() res: Response) {
|
||||||
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
|
if (body.stream) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of this.sandboxService.chatStream(req.user.userId, body.conversationId, body.model, body.messages, body, body.images)) {
|
||||||
|
res.write(`data: ${chunk}\n\n`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
||||||
import { randomUUID, createHmac } from 'crypto';
|
import { randomUUID, createHmac } from 'crypto';
|
||||||
|
|
||||||
|
interface StreamResult {
|
||||||
|
type: 'text' | 'done' | 'error';
|
||||||
|
content?: string;
|
||||||
|
sessionId?: number;
|
||||||
|
conversationId?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SandboxService {
|
export class SandboxService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -78,6 +86,84 @@ export class SandboxService {
|
|||||||
return { reply, conversationId: convId, sessionId: session.id };
|
return { reply, conversationId: convId, sessionId: session.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *chatStream(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions, images?: string[]): AsyncGenerator<string> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || user.status !== 'ACTIVE') {
|
||||||
|
yield JSON.stringify({ type: 'error', message: '用户不可用' } as StreamResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convId = conversationId || randomUUID();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const existing = await this.prisma.sandboxSession.findUnique({
|
||||||
|
where: { userId_conversationId: { userId, conversationId: convId } },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
const todayCount = await this.prisma.sandboxSession.count({
|
||||||
|
where: { userId, createdAt: { gte: today } },
|
||||||
|
});
|
||||||
|
if (todayCount >= (user.sandboxDaily || 10)) {
|
||||||
|
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aiMessages = messages as any[];
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
aiMessages = messages.map(m => {
|
||||||
|
if (m.role === 'user' && m === messages[messages.length - 1]) {
|
||||||
|
const parts: any[] = [{ type: 'text', text: m.content }];
|
||||||
|
for (const img of images) {
|
||||||
|
parts.push({ type: 'image_url', image_url: { url: img } });
|
||||||
|
}
|
||||||
|
return { role: m.role, content: parts };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullReply = '';
|
||||||
|
try {
|
||||||
|
for await (const chunk of this.aiGateway.chatStream(model, aiMessages, options)) {
|
||||||
|
fullReply += chunk;
|
||||||
|
yield JSON.stringify({ type: 'text', content: chunk } as StreamResult);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
yield JSON.stringify({ type: 'error', message: err.message } as StreamResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fullReply !== 'string' || fullReply.length === 0) {
|
||||||
|
fullReply = '抱歉,AI 返回了无效的回复,请重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMessages = messages.concat({ role: 'assistant', content: fullReply });
|
||||||
|
const firstUserMsg = messages.find(m => m.role === 'user');
|
||||||
|
const title = firstUserMsg ? firstUserMsg.content.slice(0, 80) : 'AI 对话';
|
||||||
|
|
||||||
|
const session = await this.prisma.sandboxSession.upsert({
|
||||||
|
where: { userId_conversationId: { userId, conversationId: convId } },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
conversationId: convId,
|
||||||
|
model,
|
||||||
|
title,
|
||||||
|
messages: JSON.stringify(allMessages),
|
||||||
|
tokens: Math.ceil(fullReply.length / 2),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
model,
|
||||||
|
title,
|
||||||
|
messages: JSON.stringify(allMessages),
|
||||||
|
tokens: Math.ceil(fullReply.length / 2),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
yield JSON.stringify({ type: 'done', sessionId: session.id, conversationId: convId } as StreamResult);
|
||||||
|
}
|
||||||
|
|
||||||
async getSessions(userId: number, params: { page?: number; pageSize?: number; search?: string }) {
|
async getSessions(userId: number, params: { page?: number; pageSize?: number; search?: string }) {
|
||||||
const page = Number(params.page ?? 1);
|
const page = Number(params.page ?? 1);
|
||||||
const pageSize = Number(params.pageSize ?? 50);
|
const pageSize = Number(params.pageSize ?? 50);
|
||||||
|
|||||||
@@ -66,3 +66,44 @@
|
|||||||
- 沙盒 JWT 认证保留
|
- 沙盒 JWT 认证保留
|
||||||
- Mock 支付自动完成闭环,无需手动触发回调;真实微信支付上线后自动切换
|
- Mock 支付自动完成闭环,无需手动触发回调;真实微信支付上线后自动切换
|
||||||
- 运营助手采用 Tool Calling 架构:system prompt 描述工具 → AI 返回 JSON 工具调用 → 后端执行 → 结果喂回 AI 总结 → 展示给用户
|
- 运营助手采用 Tool Calling 架构:system prompt 描述工具 → AI 返回 JSON 工具调用 → 后端执行 → 结果喂回 AI 总结 → 展示给用户
|
||||||
|
|
||||||
|
## 2026-05-27 Session
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
- **Fixed OpenAI default model**: Changed `OPENAI_MODEL` in `.env` from `meituan/longcat-flash-lite` (invalid, returned 400) to `deepseek/deepseek-v4-flash` (confirmed working on qnaigc API). The "通用模式" (general model) now returns real AI responses instead of mock fallback.
|
||||||
|
- **Fixed empty content handling**: Updated `OpenAICompatibleProvider.chat()` to fall back to `reasoning_content` when `content` is empty (SenseNova models return content in `reasoning_content`). Same fix applied in `chatStream()` for delta content.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Backend successfully registers: `OpenAI 兼容接口已注册: deepseek/deepseek-v4-flash`
|
||||||
|
- 5 test chat requests to `POST /api/v1/sandbox/chat` with `model:"general"` all returned 201 in 1.3-12.7s (vs. previous mock/fallback)
|
||||||
|
- No more `AIGatewayService` "调用失败" errors in logs for OpenAI provider
|
||||||
|
- Backend runs under PM2 (v7.0.1), managed via `pm2 restart backend --update-env`
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- SenseNova API (`token.sensenova.cn`) still returns 404 "model is not found" for `deepseek-v4-flash` et al — models might need different naming on that endpoint
|
||||||
|
- Server: backend `node dist/main.js` on port 4000, frontend Next.js on port 3000, both managed by PM2
|
||||||
|
|
||||||
|
### 2026-05-27 后续优化
|
||||||
|
|
||||||
|
**彻底修复 CSS 丢失问题**
|
||||||
|
|
||||||
|
根本原因:PM2 用 `npm run dev`(`next dev`)运行前端,每次重启都会重新编译,`.next` 缓存被销毁导致 CSS 丢失。
|
||||||
|
|
||||||
|
解决方案:改用 `output: 'export'` 静态导出 + 独立 HTTP 服务器。
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/server.js` | **新增** — Node.js 静态文件服务器,支持 Next.js 静态导出 URL 模式(`/about` → `about.html`),CSS/JS 缓存一年,HTML 不缓存 |
|
||||||
|
| `ecosystem.config.js` | 前端从 `npm run dev` 改为 `node server.js`,NODE_ENV=production |
|
||||||
|
| `frontend/package.json` | `predev` 改为仅删 `out/`,不再删 `.next/` |
|
||||||
|
|
||||||
|
部署流程:
|
||||||
|
1. `npm run build` → 生成 `out/` 静态目录
|
||||||
|
2. PM2 运行 `node server.js` → 直接提供静态文件
|
||||||
|
3. 重启后 CSS 不变,无需重新编译
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
- 前端 `localhost:3000` (static server, 无编译延迟)
|
||||||
|
- 后端 `localhost:4000` (NestJS)
|
||||||
|
- 所有路由正常 (/, /about, /sandbox 均为 200)
|
||||||
|
- CSS 持久化,重启后不丢失
|
||||||
|
|||||||
+7
-3
@@ -16,12 +16,16 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
name: 'frontend',
|
name: 'frontend',
|
||||||
cwd: '/home/wlt/ai-learning-platform/frontend',
|
cwd: '/home/wlt/ai-learning-platform/frontend',
|
||||||
script: 'npm',
|
script: 'node',
|
||||||
args: 'run dev',
|
args: 'server.js',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
watch: false,
|
||||||
max_restarts: 10
|
max_restarts: 3,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
"description": "宇之然 AI - 官网前端",
|
"description": "宇之然 AI - 官网前端",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "rm -rf .next",
|
"predev": "rm -rf out",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const ROOT = path.join(__dirname, 'out');
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'application/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
'.webmanifest': 'application/manifest+json',
|
||||||
|
'.map': 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePath(url) {
|
||||||
|
const decoded = decodeURIComponent(url).split('?')[0];
|
||||||
|
if (decoded === '/') return path.join(ROOT, 'index.html');
|
||||||
|
|
||||||
|
const ext = path.extname(decoded);
|
||||||
|
if (ext) return path.join(ROOT, decoded);
|
||||||
|
|
||||||
|
const asHtml = path.join(ROOT, decoded + '.html');
|
||||||
|
if (fs.existsSync(asHtml)) return asHtml;
|
||||||
|
|
||||||
|
const asIndex = path.join(ROOT, decoded, 'index.html');
|
||||||
|
if (fs.existsSync(asIndex)) return asIndex;
|
||||||
|
|
||||||
|
return path.join(ROOT, decoded + '.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(res, filePath, statusCode) {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const ct = MIME[ext] || 'application/octet-stream';
|
||||||
|
const isHtml = ext === '.html';
|
||||||
|
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
'Content-Type': ct,
|
||||||
|
'Cache-Control': isHtml ? 'no-cache' : 'public, max-age=31536000, immutable',
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serve(req, res) {
|
||||||
|
const filePath = path.normalize(resolvePath(req.url));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(ROOT)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.access(filePath, fs.constants.F_OK, (err) => {
|
||||||
|
if (err) {
|
||||||
|
const four04 = path.join(ROOT, '404.html');
|
||||||
|
fs.access(four04, fs.constants.F_OK, (err2) => {
|
||||||
|
if (err2) {
|
||||||
|
sendFile(res, path.join(ROOT, 'index.html'), 200);
|
||||||
|
} else {
|
||||||
|
sendFile(res, four04, 404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendFile(res, filePath, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(serve);
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Static server running at http://localhost:${PORT} (serving ${ROOT})`);
|
||||||
|
});
|
||||||
@@ -1,120 +1,95 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import * as Dialog from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface Config { key: string; value: string; description: string; category: string }
|
interface Config {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORIES = ['site', 'ai', 'member'];
|
const CATEGORIES = ['site', 'ai', 'member'];
|
||||||
|
|
||||||
const CATEGORY_NAMES: Record<string, string> = { site: '站点设置', ai: 'AI 配置', member: '会员设置' };
|
const CATEGORY_NAMES: Record<string, string> = { site: '站点设置', ai: 'AI 配置', member: '会员设置' };
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, { key: string; label: string; type: string; placeholder: string }[]> = {
|
function getAuthHeaders() {
|
||||||
site: [
|
const token = localStorage.getItem('adminToken');
|
||||||
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
|
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||||
{ key: 'site_logo', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
}
|
||||||
{ key: 'icp_number', label: '备案号', type: 'text', placeholder: '京ICP备...' },
|
|
||||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', placeholder: 'admin@example.com' },
|
|
||||||
{ key: 'contact_phone', label: '联系电话', type: 'text', placeholder: '010-...' },
|
|
||||||
{ key: 'company_name', label: '公司名称', type: 'text', placeholder: '北京宇之然科技中心' },
|
|
||||||
],
|
|
||||||
ai: [
|
|
||||||
{ key: 'default_model', label: '默认模型', type: 'text', placeholder: 'general' },
|
|
||||||
{ key: 'available_models', label: '可用模型(逗号分隔)', type: 'text', placeholder: 'general,deepseek-v4-flash' },
|
|
||||||
{ key: 'daily_quota_free', label: '免费用户日配额', type: 'number', placeholder: '10' },
|
|
||||||
{ key: 'daily_quota_monthly', label: '月卡用户日配额', type: 'number', placeholder: '100' },
|
|
||||||
{ key: 'daily_quota_yearly', label: '年卡用户日配额', type: 'number', placeholder: '200' },
|
|
||||||
{ key: 'openai_api_key', label: 'OpenAI API Key', type: 'password', placeholder: 'sk-...' },
|
|
||||||
{ key: 'openai_api_url', label: 'OpenAI API URL', type: 'text', placeholder: 'https://api.openai.com/v1' },
|
|
||||||
{ key: 'sensenova_api_key', label: '商汤 API Key', type: 'password', placeholder: 'sk-...' },
|
|
||||||
{ key: 'sensenova_api_url', label: '商汤 API URL', type: 'text', placeholder: 'https://token.sensenova.cn/v1' },
|
|
||||||
{ key: 'max_tokens', label: '最大 Token 数', type: 'number', placeholder: '2000' },
|
|
||||||
{ key: 'temperature', label: '默认 Temperature', type: 'text', placeholder: '0.7' },
|
|
||||||
],
|
|
||||||
member: [
|
|
||||||
{ key: 'price_monthly', label: '月卡价格(元)', type: 'number', placeholder: '29.9' },
|
|
||||||
{ key: 'price_yearly', label: '年卡价格(元)', type: 'number', placeholder: '199' },
|
|
||||||
{ key: 'quota_monthly', label: '月卡日配额', type: 'number', placeholder: '100' },
|
|
||||||
{ key: 'quota_yearly', label: '年卡日配额', type: 'number', placeholder: '200' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const [configs, setConfigs] = useState<Config[]>([]);
|
const [configs, setConfigs] = useState<Config[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [category, setCategory] = useState('site');
|
const [category, setCategory] = useState('ai');
|
||||||
const [form, setForm] = useState<Record<string, string>>({});
|
|
||||||
const [saveMsg, setSaveMsg] = useState('');
|
|
||||||
const [showNewKey, setShowNewKey] = useState(false);
|
|
||||||
const [newKey, setNewKey] = useState('');
|
|
||||||
const [newValue, setNewValue] = useState('');
|
|
||||||
const [newDesc, setNewDesc] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => { loadConfigs(); }, [category]);
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [formKey, setFormKey] = useState('');
|
||||||
|
const [formValue, setFormValue] = useState('');
|
||||||
|
const [formDesc, setFormDesc] = useState('');
|
||||||
|
|
||||||
async function loadConfigs() {
|
const loadConfigs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: getAuthHeaders() });
|
||||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const configMap: Record<string, string> = {};
|
|
||||||
(data.items || []).forEach((c: Config) => { configMap[c.key] = c.value; });
|
|
||||||
setConfigs(data.items || []);
|
setConfigs(data.items || []);
|
||||||
setForm(configMap);
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => { loadConfigs(); }, [loadConfigs]);
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
setEditingKey(null);
|
||||||
|
setFormKey('');
|
||||||
|
setFormValue('');
|
||||||
|
setFormDesc('');
|
||||||
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(key: string) {
|
function openEditDialog(c: Config) {
|
||||||
const token = localStorage.getItem('adminToken');
|
setEditingKey(c.key);
|
||||||
try {
|
setFormKey(c.key);
|
||||||
const res = await fetch(`${API_BASE}/admin/config/${key}`, {
|
setFormValue(c.value);
|
||||||
method: 'PUT',
|
setFormDesc(c.description || '');
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
setDialogOpen(true);
|
||||||
body: JSON.stringify({ value: form[key] || '' }),
|
|
||||||
});
|
|
||||||
if (res.ok) { setSaveMsg('保存成功'); setTimeout(() => setSaveMsg(''), 2000); }
|
|
||||||
else { setSaveMsg('保存失败'); }
|
|
||||||
} catch { setSaveMsg('保存失败'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNewConfig() {
|
async function handleSave() {
|
||||||
if (!newKey.trim()) return;
|
if (!formKey.trim()) return;
|
||||||
const token = localStorage.getItem('adminToken');
|
const key = editingKey || formKey;
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/admin/config/${newKey}`, {
|
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ value: newValue, description: newDesc, category }),
|
body: JSON.stringify({ value: formValue, description: formDesc, category }),
|
||||||
});
|
});
|
||||||
setShowNewKey(false); setNewKey(''); setNewValue(''); setNewDesc('');
|
setDialogOpen(false);
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLabel(key: string): string | undefined {
|
async function handleDelete(key: string) {
|
||||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
if (!window.confirm(`确定删除配置项 "${key}" 吗?`)) return;
|
||||||
const found = cat.find(f => f.key === key);
|
try {
|
||||||
if (found) return found.label;
|
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||||
}
|
method: 'DELETE',
|
||||||
return key;
|
headers: getAuthHeaders(),
|
||||||
}
|
});
|
||||||
|
loadConfigs();
|
||||||
function getPlaceholder(key: string): string | undefined {
|
} catch {}
|
||||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
|
||||||
const found = cat.find(f => f.key === key);
|
|
||||||
if (found) return found.placeholder;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="p-6">加载中...</div>;
|
if (loading) return <div className="p-6">加载中...</div>;
|
||||||
|
|
||||||
const allKeys = [...new Set([...(CATEGORY_LABELS[category] || []).map(f => f.key), ...configs.map(c => c.key)])];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -122,28 +97,9 @@ export default function ConfigPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">系统配置</h1>
|
<h1 className="text-2xl font-bold text-foreground">系统配置</h1>
|
||||||
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowNewKey(!showNewKey)}
|
<Button onClick={openAddDialog}>+ 新增配置</Button>
|
||||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
|
|
||||||
{showNewKey ? '取消' : '+ 新增配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveMsg && (
|
|
||||||
<div className={`mb-4 px-4 py-2 rounded-lg text-sm ${saveMsg === '保存成功' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
|
||||||
{saveMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewKey && (
|
|
||||||
<div className="bg-card border border-border rounded-xl p-4 mb-6 space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">新增配置项</h3>
|
|
||||||
<input value={newKey} onChange={e => setNewKey(e.target.value)} placeholder="配置键名" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
|
||||||
<input value={newValue} onChange={e => setNewValue(e.target.value)} placeholder="配置值" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
|
||||||
<input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="描述(可选)" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
|
||||||
<button onClick={addNewConfig} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">创建</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6">
|
||||||
{CATEGORIES.map(cat => (
|
{CATEGORIES.map(cat => (
|
||||||
<button key={cat} onClick={() => setCategory(cat)}
|
<button key={cat} onClick={() => setCategory(cat)}
|
||||||
@@ -153,24 +109,65 @@ export default function ConfigPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
{allKeys.map(key => (
|
<table className="w-full">
|
||||||
<div key={key} className="grid grid-cols-3 gap-4 items-center">
|
<thead className="bg-muted/50 border-b border-border">
|
||||||
<label className="text-sm text-muted-foreground">{getLabel(key) || key}</label>
|
<tr>
|
||||||
<div className="col-span-2 flex gap-2">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">配置项</th>
|
||||||
<input
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">描述</th>
|
||||||
type="text"
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">值</th>
|
||||||
value={form[key] || ''}
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">操作</th>
|
||||||
onChange={e => setForm({ ...form, [key]: e.target.value })}
|
</tr>
|
||||||
placeholder={getPlaceholder(key)}
|
</thead>
|
||||||
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm"
|
<tbody className="divide-y divide-border">
|
||||||
/>
|
{configs.map(c => (
|
||||||
<button onClick={() => saveConfig(key)} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm whitespace-nowrap hover:bg-brand-700">保存</button>
|
<tr key={c.key} className="hover:bg-accent/50">
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-foreground">{c.key}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground">{c.description || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-foreground max-w-xs truncate">{c.value}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-right whitespace-nowrap">
|
||||||
|
<button onClick={() => openEditDialog(c)} className="text-brand-600 hover:underline text-sm mr-3">编辑</button>
|
||||||
|
<button onClick={() => handleDelete(c.key)} className="text-red-500 hover:underline text-sm">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{configs.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-sm text-muted-foreground">暂无配置项</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<Dialog.DialogContent>
|
||||||
|
<Dialog.DialogHeader>
|
||||||
|
<Dialog.DialogTitle>{editingKey ? '编辑配置' : '新增配置'}</Dialog.DialogTitle>
|
||||||
|
<Dialog.DialogDescription>
|
||||||
|
{editingKey ? `修改配置项 "${editingKey}"` : '添加一个新的系统配置项'}
|
||||||
|
</Dialog.DialogDescription>
|
||||||
|
</Dialog.DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!editingKey && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">配置键名</label>
|
||||||
|
<Input value={formKey} onChange={e => setFormKey(e.target.value)} placeholder="例如:site_name" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">配置值</label>
|
||||||
|
<Input value={formValue} onChange={e => setFormValue(e.target.value)} placeholder="配置值" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">描述</label>
|
||||||
|
<Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="配置项描述(可选)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Dialog.DialogFooter>
|
||||||
{allKeys.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">暂无配置项</p>}
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||||
</div>
|
<Button onClick={handleSave}>保存</Button>
|
||||||
|
</Dialog.DialogFooter>
|
||||||
|
</Dialog.DialogContent>
|
||||||
|
</Dialog.Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ interface Order {
|
|||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
|
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
|
||||||
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 29.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
|
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
|
||||||
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 199, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
|
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
||||||
@@ -72,7 +72,7 @@ export default function MemberPage() {
|
|||||||
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
|
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
|
||||||
|
|
||||||
const body: Record<string, any> = {
|
const body: Record<string, any> = {
|
||||||
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
amount: planType === 'MONTHLY' ? 49.9 : 299,
|
||||||
planType, payChannel: 'wxpay', tradeType,
|
planType, payChannel: 'wxpay', tradeType,
|
||||||
};
|
};
|
||||||
if (useJsapi && openid) body.openid = openid;
|
if (useJsapi && openid) body.openid = openid;
|
||||||
@@ -147,7 +147,7 @@ export default function MemberPage() {
|
|||||||
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{plan.price > 0 ? (
|
{plan.price > 0 ? (
|
||||||
<span className="text-3xl font-bold text-foreground">{plan.price === 29.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{t.member[plan.period]}</span></span>
|
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{t.member[plan.period]}</span></span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold text-foreground">¥0</span>
|
<span className="text-2xl font-bold text-foreground">¥0</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { DEFAULT_MODEL } from '@/lib/models';
|
|||||||
import { ModelSelector } from '@/components/ui/model-selector';
|
import { ModelSelector } from '@/components/ui/model-selector';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { CodeBlock } from '@/components/ui/code-block';
|
import { CodeBlock } from '@/components/ui/code-block';
|
||||||
|
import LearningPath, { LEARNING_STAGES } from '@/components/sandbox/learning-path';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
@@ -25,6 +27,43 @@ interface SessionItem {
|
|||||||
tokens: number;
|
tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GuideTask {
|
||||||
|
taskKey: string;
|
||||||
|
hint: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_GUIDES: Record<string, GuideTask[]> = {
|
||||||
|
'welcome': [
|
||||||
|
{ taskKey: 'taskSendMsg', hint: '在下方输入框中输入任意问题(如"什么是 AI?"),然后按回车或点击发送按钮', actionLabel: '已发送第一条消息' },
|
||||||
|
{ taskKey: 'taskTryStarter', hint: '点击下方任意 Starter 气泡(如"帮我写一封邮件"),快速开始一次对话', actionLabel: '已尝试 Starter' },
|
||||||
|
{ taskKey: 'taskReadReply', hint: '阅读 AI 回复的内容和格式,观察它如何组织语言、分段和排版', actionLabel: '已理解回复特点' },
|
||||||
|
],
|
||||||
|
'scene': [
|
||||||
|
{ taskKey: 'taskSwitchCoding', hint: '点击右侧场景下拉菜单 👉,选择「编程助手」,体验编程场景的专属提示', actionLabel: '已切换到编程' },
|
||||||
|
{ taskKey: 'taskSwitchWriting', hint: '再次打开场景菜单,选择「写作助手」,试问"帮我润色这段文字"', actionLabel: '已切换到写作' },
|
||||||
|
{ taskKey: 'taskSwitchStudy', hint: '切换到「学习辅导」场景,提问"请解释一下什么是机器学习"', actionLabel: '已切换到学习' },
|
||||||
|
],
|
||||||
|
'params': [
|
||||||
|
{ taskKey: 'taskHighTemp', hint: '点击右上角的 ⚙ 高级参数按钮,将 Temperature 滑动到 0.9,然后发送一个问题', actionLabel: '已调高 Temperature' },
|
||||||
|
{ taskKey: 'taskLowTemp', hint: '将 Temperature 滑动到 0.1,发送同样的问题,观察两次回复的差异', actionLabel: '已调低 Temperature' },
|
||||||
|
{ taskKey: 'taskCompareTemp', hint: '对比高低 Temperature 的回复:高值更富创意多样,低值更保守聚焦', actionLabel: '已理解区别' },
|
||||||
|
],
|
||||||
|
'models': [
|
||||||
|
{ taskKey: 'taskSwitchModel', hint: '在顶部的模型选择器中切换到 DeepSeek V4 Flash 模型', actionLabel: '已切换模型' },
|
||||||
|
{ taskKey: 'taskCompareModel', hint: '向两个模型问同样的问题,观察他们在回复风格、详细程度上的差异', actionLabel: '已对比模型' },
|
||||||
|
],
|
||||||
|
'prompts': [
|
||||||
|
{ taskKey: 'taskRolePrompt', hint: '输入一条包含角色设定的提示词,例如:"你是一名资深编辑,请帮我审稿这篇文字,指出改进方向"', actionLabel: '已使用角色提示' },
|
||||||
|
{ taskKey: 'taskStructured', hint: '尝试使用结构化提示:分步骤说明任务,例如:"第一步:概括要点;第二步:分析优缺点;第三步:给出改进建议"', actionLabel: '已完成结构化提示' },
|
||||||
|
],
|
||||||
|
'master': [
|
||||||
|
{ taskKey: 'taskTryStarter', hint: '选择一个 Starter 问题开始你的综合实战练习', actionLabel: '已选择问题' },
|
||||||
|
{ taskKey: 'taskCodeExec', hint: '当 AI 生成代码后,点击消息下方的「在代码沙盒中运行」按钮,打开代码沙箱执行代码', actionLabel: '已运行代码' },
|
||||||
|
{ taskKey: 'taskCommunity', hint: '点击「分享到社区」按钮,将你的对话分享到宇之然社区', actionLabel: '已分享到社区' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
function extractCodeBlocks(content: string): string[] {
|
function extractCodeBlocks(content: string): string[] {
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [];
|
||||||
const regex = /```(?:\w+)?\n([\s\S]*?)```/g;
|
const regex = /```(?:\w+)?\n([\s\S]*?)```/g;
|
||||||
@@ -67,6 +106,7 @@ function SandboxPage() {
|
|||||||
const [maxTokens, setMaxTokens] = useState(2000);
|
const [maxTokens, setMaxTokens] = useState(2000);
|
||||||
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
|
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
|
||||||
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
const [sessionsOpen, setSessionsOpen] = useState(false);
|
const [sessionsOpen, setSessionsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [conversationId, setConversationId] = useState(() => crypto.randomUUID());
|
const [conversationId, setConversationId] = useState(() => crypto.randomUUID());
|
||||||
@@ -76,9 +116,14 @@ function SandboxPage() {
|
|||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [mode, setMode] = useState<'free' | 'learn'>('learn');
|
||||||
|
const [guidedStageId, setGuidedStageId] = useState<string | null>(null);
|
||||||
|
const [guidedTaskIdx, setGuidedTaskIdx] = useState(0);
|
||||||
|
const [guidedDone, setGuidedDone] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isStreamingRef = useRef(false);
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,15 +139,27 @@ function SandboxPage() {
|
|||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const DEFAULT_SCENES = [
|
||||||
|
{ id: 'general-chat', name: '通用对话', icon: '💬', systemPrompt: '你是一个智能 AI 助手', starters: ['什么是 AI?', '帮我写一封邮件', '解释一下量子计算', '推荐一本好书'] },
|
||||||
|
{ id: 'coding', name: '编程助手', icon: '💻', systemPrompt: '你是一个编程专家,擅长解答编程问题和编写代码', starters: ['用 Python 写一个斐波那契数列', '解释 RESTful API 设计原则', '帮我调试这段代码', '什么是闭包?'] },
|
||||||
|
{ id: 'writing', name: '写作助手', icon: '✍️', systemPrompt: '你是一个专业的写作助手,擅长润色和创作各类文本', starters: ['帮我润色这段文字', '写一篇产品介绍', '如何写好工作总结?', '帮我拟一份会议邀请'] },
|
||||||
|
{ id: 'study', name: '学习辅导', icon: '📚', systemPrompt: '你是一个耐心的学习辅导员,善于解释复杂概念', starters: ['什么是机器学习?', '解释 HTTP 与 HTTPS 的区别', '帮我理解微积分', '英语单词记忆技巧'] },
|
||||||
|
];
|
||||||
|
|
||||||
fetch(`${API_BASE}/skills`)
|
fetch(`${API_BASE}/skills`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const scenes = (data.items || []).map((s: any) => ({ id: s.id, name: s.name, icon: s.icon, systemPrompt: s.systemPrompt, starters: s.starters }));
|
const scenes = (data.items || []).map((s: any) => ({ id: s.id, name: s.name, icon: s.icon, systemPrompt: s.systemPrompt, starters: s.starters }));
|
||||||
setSCENES(scenes);
|
const all = scenes.length > 0 ? scenes : DEFAULT_SCENES;
|
||||||
|
setSCENES(all);
|
||||||
const skillParam = searchParams.get('skill');
|
const skillParam = searchParams.get('skill');
|
||||||
const initialScene = scenes.find((s: any) => s.id === skillParam) ? skillParam : (scenes[0]?.id || '');
|
const initialScene = all.find((s: any) => s.id === skillParam) ? skillParam : (all[0]?.id || '');
|
||||||
setScene(initialScene);
|
setScene(initialScene);
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setSCENES(DEFAULT_SCENES);
|
||||||
|
setScene(DEFAULT_SCENES[0].id);
|
||||||
|
})
|
||||||
.finally(() => setSkillsLoading(false));
|
.finally(() => setSkillsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -115,12 +172,13 @@ function SandboxPage() {
|
|||||||
function loadSessions(tk?: string) {
|
function loadSessions(tk?: string) {
|
||||||
const token = tk || getToken();
|
const token = tk || getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
setSessionsLoading(true);
|
||||||
const params = searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : '';
|
const params = searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : '';
|
||||||
fetch(`${API_BASE}/sandbox/sessions${params}`, {
|
fetch(`${API_BASE}/sandbox/sessions${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
if (data.items) setSessions(data.items);
|
if (data.items) setSessions(data.items);
|
||||||
}).catch(() => {});
|
}).catch(() => {}).finally(() => setSessionsLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSceneChange(sceneId: string) {
|
function handleSceneChange(sceneId: string) {
|
||||||
@@ -131,6 +189,68 @@ function SandboxPage() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStartStage(stage: typeof LEARNING_STAGES[0]) {
|
||||||
|
const s = SCENES.find(x => x.id === stage.sceneId);
|
||||||
|
const stageName = t.sandbox[stage.descKey as keyof typeof t.sandbox] as string;
|
||||||
|
const stageDesc = t.sandbox[stage.descDescKey as keyof typeof t.sandbox] as string;
|
||||||
|
if (s) {
|
||||||
|
setScene(stage.sceneId);
|
||||||
|
setModel(stage.model);
|
||||||
|
setTemperature(stage.temperature);
|
||||||
|
setTopP(1);
|
||||||
|
setMaxTokens(2000);
|
||||||
|
setConversationId(crypto.randomUUID());
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
setMessages([
|
||||||
|
{ role: 'assistant', content: `📚 **${stageName}**\n\n${stageDesc}\n\n开始练习吧!按照左侧引导逐步完成本阶段任务。` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setMode('free');
|
||||||
|
setGuidedStageId(stage.id);
|
||||||
|
setGuidedTaskIdx(0);
|
||||||
|
setGuidedDone(false);
|
||||||
|
if (stage.id === 'params') setShowParams(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCompleteTask() {
|
||||||
|
const guide = guidedStageId ? STAGE_GUIDES[guidedStageId] : null;
|
||||||
|
if (!guide) return;
|
||||||
|
if (guidedTaskIdx < guide.length - 1) {
|
||||||
|
setGuidedTaskIdx(guidedTaskIdx + 1);
|
||||||
|
} else {
|
||||||
|
setGuidedDone(true);
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('sandbox_learning_progress');
|
||||||
|
const progress = raw ? JSON.parse(raw) : { done: [] };
|
||||||
|
if (!progress.done.includes(guidedStageId)) {
|
||||||
|
progress.done.push(guidedStageId);
|
||||||
|
for (const task of guide) {
|
||||||
|
if (!progress.done.includes(task.taskKey)) progress.done.push(task.taskKey);
|
||||||
|
}
|
||||||
|
localStorage.setItem('sandbox_learning_progress', JSON.stringify(progress));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNextStage() {
|
||||||
|
if (!guidedStageId) return;
|
||||||
|
const idx = LEARNING_STAGES.findIndex(s => s.id === guidedStageId);
|
||||||
|
if (idx >= 0 && idx < LEARNING_STAGES.length - 1) {
|
||||||
|
handleStartStage(LEARNING_STAGES[idx + 1]);
|
||||||
|
} else {
|
||||||
|
setGuidedStageId(null);
|
||||||
|
setGuidedTaskIdx(0);
|
||||||
|
setGuidedDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExitGuide() {
|
||||||
|
setGuidedStageId(null);
|
||||||
|
setGuidedTaskIdx(0);
|
||||||
|
setGuidedDone(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSend(e: FormEvent) {
|
async function handleSend(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
@@ -143,7 +263,6 @@ function SandboxPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tk = getToken();
|
const tk = getToken();
|
||||||
let reply = '';
|
|
||||||
if (tk) {
|
if (tk) {
|
||||||
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
|
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
|
||||||
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
|
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
|
||||||
@@ -159,31 +278,84 @@ function SandboxPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${tk}`,
|
Authorization: `Bearer ${tk}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens, ...(uploadedImages.length > 0 ? { images: uploadedImages } : {}) }),
|
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens, stream: true, ...(uploadedImages.length > 0 ? { images: uploadedImages } : {}) }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.message || '请求失败');
|
if ((res.headers.get('content-type') || '').includes('text/event-stream')) {
|
||||||
reply = data.reply;
|
isStreamingRef.current = true;
|
||||||
if (data.conversationId) setConversationId(data.conversationId);
|
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
||||||
if (data.sessionId) setCurrentSessionId(data.sessionId);
|
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(line.slice(6));
|
||||||
|
if (d.type === 'text') {
|
||||||
|
setMessages(prev => {
|
||||||
|
const msgs = [...prev];
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
const last = msgs[msgs.length - 1];
|
||||||
|
if (last.role === 'assistant') {
|
||||||
|
msgs[msgs.length - 1] = { ...last, content: last.content + d.content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
});
|
||||||
|
} else if (d.type === 'done') {
|
||||||
|
if (d.sessionId) setCurrentSessionId(d.sessionId);
|
||||||
|
if (d.conversationId) setConversationId(d.conversationId);
|
||||||
|
} else if (d.type === 'error') {
|
||||||
|
throw new Error(d.message);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || '请求失败');
|
||||||
|
if (data.conversationId) setConversationId(data.conversationId);
|
||||||
|
if (data.sessionId) setCurrentSessionId(data.sessionId);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: data.reply }]);
|
||||||
|
}
|
||||||
|
|
||||||
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
|
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
|
||||||
setUploadedImages([]);
|
setUploadedImages([]);
|
||||||
loadSessions(tk);
|
loadSessions(tk);
|
||||||
} else {
|
} else {
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
reply = '📝 注册登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用。';
|
setMessages(prev => [...prev, { role: 'assistant', content: '📝 注册登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用。' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message.includes('今日沙箱使用次数已用完')) {
|
const errMsg = e.message.includes('今日沙箱使用次数已用完')
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。') }]);
|
? '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。')
|
||||||
} else if (e.message.includes('未登录') || e.message.includes('Unauthorized')) {
|
: e.message.includes('未登录') || e.message.includes('Unauthorized')
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: '登录已过期,请重新登录后再试。' }]);
|
? '登录已过期,请重新登录后再试。'
|
||||||
|
: `出错啦:${e.message}`;
|
||||||
|
|
||||||
|
if (isStreamingRef.current) {
|
||||||
|
setMessages(prev => {
|
||||||
|
const msgs = [...prev];
|
||||||
|
const last = msgs[msgs.length - 1];
|
||||||
|
if (last?.role === 'assistant' && last.content === '') {
|
||||||
|
msgs[msgs.length - 1] = { role: 'assistant', content: errMsg };
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
return [...prev, { role: 'assistant', content: errMsg }];
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
|
setMessages(prev => [...prev, { role: 'assistant', content: errMsg }]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
isStreamingRef.current = false;
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +438,7 @@ function SandboxPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function shareSessionLink() {
|
async function shareSessionLink() {
|
||||||
if (!getToken() || !currentSessionId) { alert('请先登录'); return; }
|
if (!getToken() || !currentSessionId) { toast.error('请先登录'); return; }
|
||||||
try {
|
try {
|
||||||
const tk = getToken();
|
const tk = getToken();
|
||||||
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
|
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
|
||||||
@@ -275,13 +447,13 @@ function SandboxPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.shareUrl) {
|
if (data.shareUrl) {
|
||||||
await navigator.clipboard.writeText(data.shareUrl);
|
await navigator.clipboard.writeText(data.shareUrl);
|
||||||
alert('链接已复制');
|
toast.success(t.sandbox.linkCopied);
|
||||||
}
|
}
|
||||||
} catch { alert('生成分享链接失败'); }
|
} catch { toast.error('生成分享链接失败'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shareToCommunity(content: string, title?: string) {
|
function shareToCommunity(content: string, title?: string) {
|
||||||
if (!getToken()) { alert('请先登录'); return; }
|
if (!getToken()) { toast.error('请先登录'); return; }
|
||||||
apiFetch('/community/posts', {
|
apiFetch('/community/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -289,7 +461,7 @@ function SandboxPage() {
|
|||||||
content: `【AI沙箱对话分享】\n\n${content}\n\n---\n来自宇之然AI沙箱`,
|
content: `【AI沙箱对话分享】\n\n${content}\n\n---\n来自宇之然AI沙箱`,
|
||||||
tags: '沙箱分享,AI对话',
|
tags: '沙箱分享,AI对话',
|
||||||
}),
|
}),
|
||||||
}).then(() => alert('分享成功!')).catch(() => alert('分享失败'));
|
}).then(() => toast.success(t.sandbox.shareSuccess)).catch(() => toast.error(t.sandbox.shareFailed));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentScene = SCENES.find(s => s.id === scene) || SCENES[0] || null;
|
const currentScene = SCENES.find(s => s.id === scene) || SCENES[0] || null;
|
||||||
@@ -320,6 +492,12 @@ function SandboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||||
|
<button onClick={() => setShowParams(!showParams)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
|
||||||
|
showParams ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border hover:text-foreground'
|
||||||
|
}`}>
|
||||||
|
⚙ {t.sandbox.advancedParams}
|
||||||
|
</button>
|
||||||
<ModelSelector value={model} onChange={setModel} className="w-40 sm:w-48" />
|
<ModelSelector value={model} onChange={setModel} className="w-40 sm:w-48" />
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
<Link href="/auth"
|
<Link href="/auth"
|
||||||
@@ -348,7 +526,13 @@ function SandboxPage() {
|
|||||||
className="w-full px-3 py-1.5 bg-background border border-border rounded-lg text-xs focus:outline-none focus:border-brand-400" />
|
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>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sessions.length === 0 ? (
|
{sessionsLoading ? (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="h-12 bg-muted rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-xs text-muted-foreground">{t.sandbox.noHistory}</div>
|
<div className="p-4 text-center text-xs text-muted-foreground">{t.sandbox.noHistory}</div>
|
||||||
) : sessions.map(s => (
|
) : sessions.map(s => (
|
||||||
<div key={s.id} onClick={() => { if (renamingId !== s.id) { loadSession(s.id); setSessionsOpen(false); } }}
|
<div key={s.id} onClick={() => { if (renamingId !== s.id) { loadSession(s.id); setSessionsOpen(false); } }}
|
||||||
@@ -382,54 +566,119 @@ function SandboxPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
{SCENES.map(s => (
|
<button onClick={() => setMode('learn')}
|
||||||
<button key={s.id} onClick={() => handleSceneChange(s.id)}
|
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
|
||||||
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 ${
|
mode === 'learn' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border hover:text-foreground'
|
||||||
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">
|
📚 {t.sandbox.learnMode}
|
||||||
<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>
|
|
||||||
{t.sandbox.advancedParams}
|
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setMode('free')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
|
||||||
|
mode === 'free' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border hover:text-foreground'
|
||||||
|
}`}>
|
||||||
|
🎯 {t.sandbox.freeMode}
|
||||||
|
</button>
|
||||||
|
{mode === 'free' && SCENES.length > 0 && (
|
||||||
|
<select value={scene} onChange={e => handleSceneChange(e.target.value)}
|
||||||
|
className="ml-2 px-2 py-1.5 text-xs bg-card border border-border rounded-lg text-foreground focus:outline-none focus:border-brand-400">
|
||||||
|
{SCENES.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.icon} {s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showParams && (
|
{showParams && (
|
||||||
<div className="bg-card border border-border rounded-xl p-4 mb-3 space-y-3">
|
<div className="bg-card border border-border rounded-2xl p-4 mb-4">
|
||||||
{[
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{ label: t.sandbox.temperature, min: 0, max: 2, step: 0.1, val: temperature, set: setTemperature, fmt: (v: number) => v.toFixed(1) },
|
<div>
|
||||||
{ label: t.sandbox.topP, min: 0, max: 1, step: 0.05, val: topP, set: setTopP, fmt: (v: number) => v.toFixed(2) },
|
<label className="text-xs text-muted-foreground block mb-1">{t.sandbox.temperature} ({temperature})</label>
|
||||||
{ label: t.sandbox.maxTokens, min: 100, max: 8192, step: 100, val: maxTokens, set: setMaxTokens, fmt: (v: number) => String(v) },
|
<input type="range" min="0" max="2" step="0.1" value={temperature}
|
||||||
].map(p => (
|
onChange={e => setTemperature(parseFloat(e.target.value))}
|
||||||
<div key={p.label}>
|
className="w-full accent-brand-600" />
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="text-xs font-medium text-foreground">{p.label}</label>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{p.fmt(p.val)}</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" min={p.min} max={p.max} step={p.step} value={p.val}
|
|
||||||
onChange={e => p.set(parseFloat(e.target.value))}
|
|
||||||
className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-brand-600" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground block mb-1">{t.sandbox.topP} ({topP})</label>
|
||||||
|
<input type="range" min="0" max="1" step="0.05" value={topP}
|
||||||
|
onChange={e => setTopP(parseFloat(e.target.value))}
|
||||||
|
className="w-full accent-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground block mb-1">{t.sandbox.maxTokens} ({maxTokens})</label>
|
||||||
|
<input type="range" min="256" max="4096" step="256" value={maxTokens}
|
||||||
|
onChange={e => setMaxTokens(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-brand-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-card rounded-2xl border border-border shadow-sm overflow-hidden flex flex-col" style={{ maxHeight: '65vh' }}>
|
{guidedStageId && mode === 'free' && (() => {
|
||||||
|
const guide = STAGE_GUIDES[guidedStageId];
|
||||||
|
const stage = LEARNING_STAGES.find(s => s.id === guidedStageId);
|
||||||
|
const stageNum = LEARNING_STAGES.findIndex(s => s.id === guidedStageId) + 1;
|
||||||
|
const task = guide?.[guidedTaskIdx];
|
||||||
|
const isLast = guidedTaskIdx >= guide.length - 1;
|
||||||
|
const progressPct = ((guidedDone ? guide.length : guidedTaskIdx) / guide.length) * 100;
|
||||||
|
return (
|
||||||
|
<div className="bg-brand-600/5 border border-brand-200 dark:border-brand-800 rounded-2xl p-4 mb-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-bold text-brand-600 uppercase tracking-wider">{stageNum} / {LEARNING_STAGES.length}</span>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t.sandbox[stage?.descKey as keyof typeof t.sandbox] as string}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-1 mb-3">
|
||||||
|
<div className="bg-brand-600 h-1 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
{guidedDone ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🎉</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{t.sandbox.stageDone}!本阶段全部完成</p>
|
||||||
|
<p className="text-xs text-muted-foreground">你已经掌握了这一阶段的核心技能,继续前进吧!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : task ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-brand-600 text-white flex items-center justify-center text-[10px] font-bold shrink-0">{guidedTaskIdx + 1}</span>
|
||||||
|
<span className="text-xs font-medium text-foreground">{t.sandbox[task.taskKey as keyof typeof t.sandbox] as string}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground ml-7 mb-2">{task.hint}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{!guidedDone ? (
|
||||||
|
<button onClick={handleCompleteTask}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-xl hover:bg-brand-700 transition-colors">
|
||||||
|
{isLast ? '✅ 全部完成!' : '✅ 完成,下一步'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleNextStage}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-xl hover:bg-brand-700 transition-colors">
|
||||||
|
{stageNum < LEARNING_STAGES.length ? '➡️ 下一阶段' : '🎊 完成全部!'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExitGuide} className="px-3 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors">退出引导</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{mode === 'learn' && (
|
||||||
|
<div className="bg-card rounded-2xl border border-border shadow-sm p-4 sm:p-6 overflow-y-auto" style={{ maxHeight: '75vh' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t.sandbox.learnPath}</h2>
|
||||||
|
</div>
|
||||||
|
<LearningPath onStartStage={handleStartStage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'free' && (<>
|
||||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{isNewChat && (
|
{isNewChat && (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
@@ -523,7 +772,20 @@ function SandboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border p-4">
|
<div className="border-t border-border p-4">
|
||||||
{quota && (
|
{quota && quota.remaining <= 0 ? (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
|
||||||
|
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.sandbox.quotaUpgradeHint}</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/my/member"
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
|
||||||
|
{t.sandbox.upgradeNow}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : quota && (
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
|
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
|
||||||
</div>
|
</div>
|
||||||
@@ -574,8 +836,8 @@ function SandboxPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</>)}
|
||||||
<div className="mt-4 text-center text-xs text-muted-foreground">
|
<div className="mt-4 text-center text-xs text-muted-foreground">
|
||||||
{t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`}
|
{t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useT } from '@/i18n';
|
||||||
|
|
||||||
|
export interface LearningStage {
|
||||||
|
id: string;
|
||||||
|
tasks: string[];
|
||||||
|
sceneId: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEARNING_STAGES: (LearningStage & { descKey: string; descDescKey: string; taskKeys: string[] })[] = [
|
||||||
|
{ id: 'welcome', descKey: 'stageWelcome', descDescKey: 'stageWelcomeDesc', taskKeys: ['taskSendMsg', 'taskTryStarter', 'taskReadReply'], sceneId: 'general-chat', model: 'general', temperature: 0.7, tasks: [] },
|
||||||
|
{ id: 'scene', descKey: 'stageScene', descDescKey: 'stageSceneDesc', taskKeys: ['taskSwitchCoding', 'taskSwitchWriting', 'taskSwitchStudy'], sceneId: 'coding', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
|
||||||
|
{ id: 'params', descKey: 'stageParams', descDescKey: 'stageParamsDesc', taskKeys: ['taskHighTemp', 'taskLowTemp', 'taskCompareTemp'], sceneId: 'general-chat', model: 'general', temperature: 0.9, tasks: [] },
|
||||||
|
{ id: 'models', descKey: 'stageModels', descDescKey: 'stageModelsDesc', taskKeys: ['taskSwitchModel', 'taskCompareModel'], sceneId: 'general-chat', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
|
||||||
|
{ id: 'prompts', descKey: 'stagePrompts', descDescKey: 'stagePromptsDesc', taskKeys: ['taskRolePrompt', 'taskStructured'], sceneId: 'general-chat', model: 'general', temperature: 0.5, tasks: [] },
|
||||||
|
{ id: 'master', descKey: 'stageMaster', descDescKey: 'stageMasterDesc', taskKeys: ['taskTryStarter', 'taskCodeExec', 'taskCommunity'], sceneId: 'coding', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sandbox_learning_progress';
|
||||||
|
|
||||||
|
interface StageProgress {
|
||||||
|
done: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProgress(): StageProgress {
|
||||||
|
if (typeof window === 'undefined') return { done: [] };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
return { done: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProgress(p: StageProgress) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onStartStage: (stage: (typeof LEARNING_STAGES)[0]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LearningPath({ onStartStage }: Props) {
|
||||||
|
const t = useT();
|
||||||
|
const [progress, setProgress] = useState<StageProgress>({ done: [] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProgress(loadProgress());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function markDone(stageId: string) {
|
||||||
|
const next = { ...progress, done: progress.done.includes(stageId) ? progress.done : [...progress.done, stageId] };
|
||||||
|
setProgress(next);
|
||||||
|
saveProgress(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
const next = { done: [] };
|
||||||
|
setProgress(next);
|
||||||
|
saveProgress(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">{t.sandbox.learnPathDesc}</p>
|
||||||
|
<button onClick={resetAll} className="text-xs text-muted-foreground hover:text-foreground underline shrink-0 ml-2">重新开始</button>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
|
<div className="bg-brand-600 h-1.5 rounded-full transition-all" style={{ width: `${(progress.done.length / LEARNING_STAGES.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{LEARNING_STAGES.map((stage, i) => {
|
||||||
|
const unlocked = i === 0 || progress.done.includes(LEARNING_STAGES[i - 1].id);
|
||||||
|
const completed = progress.done.includes(stage.id);
|
||||||
|
return (
|
||||||
|
<div key={stage.id}
|
||||||
|
className={`rounded-xl border p-3 transition-colors ${completed ? 'bg-green-500/5 border-green-200 dark:border-green-800' : unlocked ? 'bg-card border-border hover:border-brand-400' : 'bg-muted/30 border-border/50 opacity-50'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${completed ? 'bg-green-500 text-white' : unlocked ? 'bg-brand-600 text-white' : 'bg-muted-foreground/30 text-muted-foreground'}`}>
|
||||||
|
{completed ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => unlocked && !completed && onStartStage(stage)} disabled={!unlocked || completed}
|
||||||
|
className={`min-w-0 text-left ${unlocked && !completed ? 'cursor-pointer hover:opacity-80' : 'cursor-default'}`}>
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
|
{t.sandbox[stage.descKey as keyof typeof t.sandbox] as string}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{t.sandbox[stage.descDescKey as keyof typeof t.sandbox] as string}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{completed ? (
|
||||||
|
<span className="text-xs text-green-600 font-medium shrink-0">{t.sandbox.stageDone}</span>
|
||||||
|
) : unlocked ? (
|
||||||
|
<button onClick={() => onStartStage(stage)}
|
||||||
|
className="text-xs px-2.5 py-1 bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
|
||||||
|
{t.sandbox.startPractice}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">🔒</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{unlocked && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{stage.taskKeys.map(tk => (
|
||||||
|
<label key={tk} className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={progress.done.includes(tk)} onChange={() => {
|
||||||
|
const tasks = [...progress.done];
|
||||||
|
if (tasks.includes(tk)) {
|
||||||
|
const idx = tasks.indexOf(tk);
|
||||||
|
tasks.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
tasks.push(tk);
|
||||||
|
}
|
||||||
|
const next = { ...progress, done: tasks };
|
||||||
|
setProgress(next);
|
||||||
|
saveProgress(next);
|
||||||
|
if (stage.taskKeys.every(k => next.done.includes(k)) && !next.done.includes(stage.id)) {
|
||||||
|
markDone(stage.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-3 h-3 rounded border-border accent-brand-600" />
|
||||||
|
{t.sandbox[tk as keyof typeof t.sandbox] as string}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { Translations } from './zh'
|
|||||||
|
|
||||||
const en: Translations = {
|
const en: Translations = {
|
||||||
common: { loading: 'Loading...', save: 'Save', cancel: 'Cancel', delete: 'Delete', confirm: 'Confirm', search: 'Search', back: 'Back', login: 'Login', register: 'Register', logout: 'Logout', retry: 'Retry', noData: 'No data', viewAll: 'View all' },
|
common: { loading: 'Loading...', save: 'Save', cancel: 'Cancel', delete: 'Delete', confirm: 'Confirm', search: 'Search', back: 'Back', login: 'Login', register: 'Register', logout: 'Logout', retry: 'Retry', noData: 'No data', viewAll: 'View all' },
|
||||||
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'AI Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles' },
|
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles' },
|
||||||
home: { badge: 'Free AI Learning Community', heroHighlight: 'Empower Everyone', heroRest: 'to Master AI', desc: 'AI knowledge, prompt engineering, sandbox practice & model encyclopedia', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Guided Learning', featureGuideDesc: 'Content organized by role and scenario', featureSandbox: 'AI Sandbox', featureSandboxDesc: 'Built-in AI sandbox to learn by doing', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
|
home: { badge: 'Free AI Learning Community', heroHighlight: 'Empower Everyone', heroRest: 'to Master AI', desc: 'AI knowledge, prompt engineering, sandbox practice & model encyclopedia', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Guided Learning', featureGuideDesc: 'Content organized by role and scenario', featureSandbox: 'AI Sandbox', featureSandboxDesc: 'Built-in AI sandbox to learn by doing', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
|
||||||
auth: { loginTitle: 'Login', registerTitle: 'Register', phone: 'Phone', password: 'Password', nickname: 'Nickname', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', passwordHint: 'Password (min 6 characters)', confirmPassword: 'Confirm password', registering: 'Registering...', agreePrefix: 'By registering, you agree to our', termsOfService: 'Terms of Service', privacyPolicy: 'Privacy Policy', aiAgreement: 'AI Service Agreement', fillAccountAndPassword: 'Please enter account and password', fillPhoneOrEmail: 'Please enter phone or email', fillPassword: 'Please enter password', passwordMinLength: 'Password must be at least 6 characters', passwordsNotMatch: 'Passwords do not match', loginFailed: 'Login failed', registerFailed: 'Registration failed', loginSuccess: 'Login successful', registerSuccess: 'Registration successful' },
|
auth: { loginTitle: 'Login', registerTitle: 'Register', phone: 'Phone', password: 'Password', nickname: 'Nickname', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', passwordHint: 'Password (min 6 characters)', confirmPassword: 'Confirm password', registering: 'Registering...', agreePrefix: 'By registering, you agree to our', termsOfService: 'Terms of Service', privacyPolicy: 'Privacy Policy', aiAgreement: 'AI Service Agreement', fillAccountAndPassword: 'Please enter account and password', fillPhoneOrEmail: 'Please enter phone or email', fillPassword: 'Please enter password', passwordMinLength: 'Password must be at least 6 characters', passwordsNotMatch: 'Passwords do not match', loginFailed: 'Login failed', registerFailed: 'Registration failed', loginSuccess: 'Login successful', registerSuccess: 'Registration successful' },
|
||||||
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
|
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
|
||||||
@@ -23,9 +23,10 @@ const en: Translations = {
|
|||||||
notFound: { title: '404', desc: 'Page not found', backToHome: 'Back to Home' },
|
notFound: { title: '404', desc: 'Page not found', backToHome: 'Back to Home' },
|
||||||
share: { missingToken: 'Missing share token', invalidLink: 'Invalid share link', notAvailable: 'Shared content not available', expired: 'This share link may have expired', goToSandbox: 'Go to AI Sandbox', backToSandbox: 'AI Sandbox', modelInfo: 'Model: {model} · {date}' },
|
share: { missingToken: 'Missing share token', invalidLink: 'Invalid share link', notAvailable: 'Shared content not available', expired: 'This share link may have expired', goToSandbox: 'Go to AI Sandbox', backToSandbox: 'AI Sandbox', modelInfo: 'Model: {model} · {date}' },
|
||||||
path: { back: 'Back', totalProgress: 'Total Progress', taskCount: '{completed}/{total} tasks' },
|
path: { back: 'Back', totalProgress: 'Total Progress', taskCount: '{completed}/{total} tasks' },
|
||||||
sandbox: { title: 'AI Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago' },
|
sandbox: { title: 'Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', shareSuccess: 'Shared successfully', shareFailed: 'Share failed', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago',
|
||||||
|
freeMode: 'Free Mode', learnMode: 'Learning Mode', learnPath: 'Learning Path', learnPathDesc: 'From zero to pro in 6 steps. Click each stage to practice in free mode, check tasks when done to proceed.', stage: 'Step {n}', stageProgress: '{done}/{total} done', startPractice: 'Start Practice', stageDone: 'Completed', stageLocked: 'Locked', stageWelcome: 'First Contact', stageWelcomeDesc: 'Start your first AI conversation', stageScene: 'Scene Practice', stageSceneDesc: 'Practice in different role scenarios', stageParams: 'Parameter Tuning', stageParamsDesc: 'Adjust Temperature and see what changes', stageModels: 'Model Comparison', stageModelsDesc: 'Switch models to compare their styles', stagePrompts: 'Advanced Prompting', stagePromptsDesc: 'Learn role-setting, structured prompts', stageMaster: 'Final Challenge', stageMasterDesc: 'Apply everything in a real-world task', taskSendMsg: 'Send your first message', taskTryStarter: 'Try a starter question', taskReadReply: 'Understand AI reply characteristics', taskSwitchCoding: 'Switch to Coding scene', taskSwitchWriting: 'Switch to Writing scene', taskSwitchStudy: 'Switch to Study scene', taskHighTemp: 'Try Temperature at 0.9', taskLowTemp: 'Try Temperature at 0.1', taskCompareTemp: 'Compare the differences', taskSwitchModel: 'Switch to DeepSeek model', taskCompareModel: 'Compare model reply styles', taskRolePrompt: 'Write a prompt with role-setting', taskStructured: 'Try structured step-by-step prompts', taskCodeExec: 'Run AI-generated code in Code Sandbox', taskCommunity: 'Share a conversation to Community', quotaExhausted: 'Free quota exhausted', upgradeNow: 'Upgrade', quotaUpgradeHint: 'Upgrade for more quota and all models', quotaLoginHint: 'Login for more free daily quota' },
|
||||||
learning: { analytics: 'Learning Analytics', analyticsDesc: 'Analyze your learning based on AI conversations', path: 'Learning Path', pathDesc: 'Master AI skills systematically', totalSessions: 'AI Sessions', domainsCovered: 'Domains Covered', avgMastery: 'Avg Mastery', knowledgeDomains: 'Knowledge Domains', weakAreas: 'Weak Areas', weakDesc: 'Consider strengthening these areas:', recommendations: 'Recommendations', recDesc: 'Based on your weak areas', toStrengthen: 'To Strengthen', conversations: '{count} conversations', clickToGo: 'Go' },
|
learning: { analytics: 'Learning Analytics', analyticsDesc: 'Analyze your learning based on AI conversations', path: 'Learning Path', pathDesc: 'Master AI skills systematically', totalSessions: 'AI Sessions', domainsCovered: 'Domains Covered', avgMastery: 'Avg Mastery', knowledgeDomains: 'Knowledge Domains', weakAreas: 'Weak Areas', weakDesc: 'Consider strengthening these areas:', recommendations: 'Recommendations', recDesc: 'Based on your weak areas', toStrengthen: 'To Strengthen', conversations: '{count} conversations', clickToGo: 'Go' },
|
||||||
member: { title: 'Membership', desc: 'Manage your subscription', currentPlan: 'Current Plan', freeUser: 'You are on the Free plan', monthly: 'Monthly', yearly: 'Yearly', monthlyPrice: '¥29.9/month', yearlyPrice: '¥199/year', expires: 'Expires: {date}', benefits: 'All courses + unlimited sandbox + premium prompts + ad-free', orderHistory: 'Order History', noOrders: 'No orders yet', processing: 'Processing...', planFree: 'Free', planMonthly: 'Monthly', planYearly: 'Yearly', priceMonthly: '¥29.9', priceYearly: '¥199', perMonth: '/mo', perYear: '/yr', popular: 'Popular', featureSandbox: 'AI Sandbox', featureSandboxFree: '10/day', featureSandboxPro: '100/day', featureSandboxUnlimited: 'Unlimited', featureModels: 'Models', featureModelsFree: '1 model', featureModelsPro: '2 models', featureModelsPremium: 'All models', featurePrompts: 'Prompts', featurePromptsFree: 'Basic', featurePromptsPro: 'All', featurePromptsPremium: 'All + Exclusive', featureCourses: 'Courses', featureCoursesFree: 'Partial', featureCoursesPro: 'All', featureCoursesPremium: 'All', featureAds: 'Ads', featureAdsFree: 'Ads', featureAdsPro: 'Ad-free', featureAdsPremium: 'Ad-free', dailyQuota: 'Daily Quota', used: '{n} used', subscribe: 'Subscribe', currentPlan_badge: 'Current' },
|
member: { title: 'Membership', desc: 'Manage your subscription', currentPlan: 'Current Plan', freeUser: 'You are on the Free plan', monthly: 'Monthly', yearly: 'Yearly', monthlyPrice: '¥49.9/month', yearlyPrice: '¥299/year', expires: 'Expires: {date}', benefits: 'All courses + unlimited sandbox + premium prompts + ad-free', orderHistory: 'Order History', noOrders: 'No orders yet', processing: 'Processing...', planFree: 'Free', planMonthly: 'Monthly', planYearly: 'Yearly', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/mo', perYear: '/yr', popular: 'Popular', featureSandbox: 'AI Sandbox', featureSandboxFree: '10/day', featureSandboxPro: '100/day', featureSandboxUnlimited: 'Unlimited', featureModels: 'Models', featureModelsFree: '1 model', featureModelsPro: '2 models', featureModelsPremium: 'All models', featurePrompts: 'Prompts', featurePromptsFree: 'Basic', featurePromptsPro: 'All', featurePromptsPremium: 'All + Exclusive', featureCourses: 'Courses', featureCoursesFree: 'Partial', featureCoursesPro: 'All', featureCoursesPremium: 'All', featureAds: 'Ads', featureAdsFree: 'Ads', featureAdsPro: 'Ad-free', featureAdsPremium: 'Ad-free', dailyQuota: 'Daily Quota', used: '{n} used', subscribe: 'Subscribe', currentPlan_badge: 'Current' },
|
||||||
compare: { title: 'Compare Lab', desc: 'Compare how different models respond', placeholder: 'Enter a question or prompt to compare...', startCompare: 'Start Compare', comparing: 'Comparing...', backToSandbox: 'Back to Sandbox', noResponse: 'No response' },
|
compare: { title: 'Compare Lab', desc: 'Compare how different models respond', placeholder: 'Enter a question or prompt to compare...', startCompare: 'Start Compare', comparing: 'Comparing...', backToSandbox: 'Back to Sandbox', noResponse: 'No response' },
|
||||||
codeSandbox: { title: 'Code Sandbox', run: 'Run', runShortcut: 'Run (⌘⏎)', template: 'Template...', blank: 'Blank', react: 'React (CDN)', chart: 'Chart (Chart.js)', three: '3D (Three.js)', console: 'Console' },
|
codeSandbox: { title: 'Code Sandbox', run: 'Run', runShortcut: 'Run (⌘⏎)', template: 'Template...', blank: 'Blank', react: 'React (CDN)', chart: 'Chart (Chart.js)', three: '3D (Three.js)', console: 'Console' },
|
||||||
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career' } },
|
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career' } },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const zh = {
|
const zh = {
|
||||||
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
|
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
|
||||||
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: 'AI 沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章' },
|
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章' },
|
||||||
home: { badge: '免费 AI 知识社区', heroHighlight: '让每个人', heroRest: '都能用好 AI', desc: '涵盖 AI 通识、提示词工程、沙盒实战、模型百科', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: '分领域指南', featureGuideDesc: '按职业和场景分类内容,学即所用', featureSandbox: 'AI 沙盒实战', featureSandboxDesc: '内置 AI 对话沙盒,边学边练', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
|
home: { badge: '免费 AI 知识社区', heroHighlight: '让每个人', heroRest: '都能用好 AI', desc: '涵盖 AI 通识、提示词工程、沙盒实战、模型百科', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: '分领域指南', featureGuideDesc: '按职业和场景分类内容,学即所用', featureSandbox: 'AI 沙盒实战', featureSandboxDesc: '内置 AI 对话沙盒,边学边练', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
|
||||||
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功' },
|
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功' },
|
||||||
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
|
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
|
||||||
@@ -21,9 +21,10 @@ const zh = {
|
|||||||
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
|
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
|
||||||
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
|
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
|
||||||
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
|
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
|
||||||
sandbox: { title: 'AI 沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前' },
|
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', shareSuccess: '分享成功', shareFailed: '分享失败', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
|
||||||
|
freeMode: '自由模式', learnMode: '学习模式', learnPath: '学习路径', learnPathDesc: '从零到精通,6 步掌握 AI 对话。点击每项文字进入自由模式练习,学会后打钩确认进入下一项', stage: '第 {n} 步', stageProgress: '{done}/{total} 已完成', startPractice: '开始练习', stageDone: '已完成', stageLocked: '未解锁', stageWelcome: 'AI 初体验', stageWelcomeDesc: '了解 AI 能做什么,发起第一次对话', stageScene: '场景实战', stageSceneDesc: '在不同角色场景中练习对话技巧', stageParams: '参数调优', stageParamsDesc: '调节 Temperature 等参数,观察回复变化', stageModels: '模型对比', stageModelsDesc: '切换不同模型,了解各自特点与差异', stagePrompts: '提示词进阶', stagePromptsDesc: '学习角色设定、结构化提示等高级技巧', stageMaster: '综合实战', stageMasterDesc: '综合运用所学,完成一个完整的实战任务', taskSendMsg: '发送第一条消息', taskTryStarter: '尝试一个 Starter 问题', taskReadReply: '理解 AI 回复的特点', taskSwitchCoding: '切换到编程助手场景', taskSwitchWriting: '切换到写作助手场景', taskSwitchStudy: '切换到学习辅导场景', taskHighTemp: '调高 Temperature 到 0.9 试试', taskLowTemp: '调低 Temperature 到 0.1 对比', taskCompareTemp: '对比两次回复的差异', taskSwitchModel: '切换到 DeepSeek 模型', taskCompareModel: '对比不同模型的回复风格', taskRolePrompt: '使用角色设定写一条提示词', taskStructured: '使用结构化提示(步骤化)', taskCodeExec: '在代码沙盒中运行 AI 生成的代码', taskCommunity: '将对话分享到社区', quotaExhausted: '今日免费次数已用完', upgradeNow: '升级会员', quotaUpgradeHint: '升级会员可获得更多使用次数和全部模型', quotaLoginHint: '登录后可获得更多免费使用次数' },
|
||||||
learning: { analytics: '学情分析', analyticsDesc: '基于 AI 沙盒对话分析你的学习情况', path: '学习路径', pathDesc: '从入门到精通,系统掌握 AI 技能', totalSessions: 'AI 对话次数', domainsCovered: '涉及知识领域', avgMastery: '平均掌握度', knowledgeDomains: '知识领域覆盖', weakAreas: '薄弱环节', weakDesc: '以下领域你较少涉及,建议加强学习:', recommendations: '推荐学习', recDesc: '根据你的薄弱环节推荐以下内容', toStrengthen: '待加强', conversations: '{count} 次对话', clickToGo: '点击前往' },
|
learning: { analytics: '学情分析', analyticsDesc: '基于 AI 沙盒对话分析你的学习情况', path: '学习路径', pathDesc: '从入门到精通,系统掌握 AI 技能', totalSessions: 'AI 对话次数', domainsCovered: '涉及知识领域', avgMastery: '平均掌握度', knowledgeDomains: '知识领域覆盖', weakAreas: '薄弱环节', weakDesc: '以下领域你较少涉及,建议加强学习:', recommendations: '推荐学习', recDesc: '根据你的薄弱环节推荐以下内容', toStrengthen: '待加强', conversations: '{count} 次对话', clickToGo: '点击前往' },
|
||||||
member: { title: '会员中心', desc: '管理你的会员订阅', currentPlan: '当前会员', freeUser: '你当前是免费用户', monthly: '月卡会员', yearly: '年卡会员', monthlyPrice: '开通月卡 ¥29.9/月', yearlyPrice: '开通年卡 ¥199/年', expires: '到期时间:{date}', benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告', orderHistory: '订单记录', noOrders: '暂无订单记录', processing: '处理中...', planFree: '免费', planMonthly: '月卡', planYearly: '年卡', priceMonthly: '¥29.9', priceYearly: '¥199', perMonth: '/月', perYear: '/年', popular: '最受欢迎', featureSandbox: 'AI 沙盒', featureSandboxFree: '10 次/日', featureSandboxPro: '100 次/日', featureSandboxUnlimited: '不限次', featureModels: '模型选择', featureModelsFree: '1 个模型', featureModelsPro: '2 个模型', featureModelsPremium: '全部模型', featurePrompts: '提示词库', featurePromptsFree: '基础', featurePromptsPro: '全部', featurePromptsPremium: '全部 + 专属', featureCourses: '课程学习', featureCoursesFree: '部分免费', featureCoursesPro: '全部', featureCoursesPremium: '全部', featureAds: '广告', featureAdsFree: '有广告', featureAdsPro: '去广告', featureAdsPremium: '去广告', dailyQuota: '日配额用量', used: '已用 {n} 次', subscribe: '开通', currentPlan_badge: '当前方案' },
|
member: { title: '会员中心', desc: '管理你的会员订阅', currentPlan: '当前会员', freeUser: '你当前是免费用户', monthly: '月卡会员', yearly: '年卡会员', monthlyPrice: '开通月卡 ¥49.9/月', yearlyPrice: '开通年卡 ¥299/年', expires: '到期时间:{date}', benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告', orderHistory: '订单记录', noOrders: '暂无订单记录', processing: '处理中...', planFree: '免费', planMonthly: '月卡', planYearly: '年卡', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/月', perYear: '/年', popular: '最受欢迎', featureSandbox: 'AI 沙盒', featureSandboxFree: '10 次/日', featureSandboxPro: '100 次/日', featureSandboxUnlimited: '不限次', featureModels: '模型选择', featureModelsFree: '1 个模型', featureModelsPro: '2 个模型', featureModelsPremium: '全部模型', featurePrompts: '提示词库', featurePromptsFree: '基础', featurePromptsPro: '全部', featurePromptsPremium: '全部 + 专属', featureCourses: '课程学习', featureCoursesFree: '部分免费', featureCoursesPro: '全部', featureCoursesPremium: '全部', featureAds: '广告', featureAdsFree: '有广告', featureAdsPro: '去广告', featureAdsPremium: '去广告', dailyQuota: '日配额用量', used: '已用 {n} 次', subscribe: '开通', currentPlan_badge: '当前方案' },
|
||||||
compare: { title: '对比实验室', desc: '同题对比不同模型的表现', placeholder: '输入你想对比的问题或提示词...', startCompare: '开始对比', comparing: '对比中...', backToSandbox: '返回沙箱', noResponse: '无响应' },
|
compare: { title: '对比实验室', desc: '同题对比不同模型的表现', placeholder: '输入你想对比的问题或提示词...', startCompare: '开始对比', comparing: '对比中...', backToSandbox: '返回沙箱', noResponse: '无响应' },
|
||||||
codeSandbox: { title: '代码沙盒', run: '运行', runShortcut: '运行 (⌘⏎)', template: '模板...', blank: '空白', react: 'React (CDN)', chart: '图表 (Chart.js)', three: '3D (Three.js)', console: '控制台输出' },
|
codeSandbox: { title: '代码沙盒', run: '运行', runShortcut: '运行 (⌘⏎)', template: '模板...', blank: '空白', react: 'React (CDN)', chart: '图表 (Chart.js)', three: '3D (Three.js)', console: '控制台输出' },
|
||||||
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' } },
|
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user