feat: AI沙箱流式对话+引导学习+静态服务优化
- 后端: AI沙箱流式对话(SSE), reasoning_content 回退支持 - 前端: 沙箱页流式渲染, 引导学习面板, 默认场景/Starter兜底 - 优化: 前端改用静态文件服务器(node server.js)替代next dev, CSS永不丢失 - 修复: 通用模型默认改为可用模型, predev不再删.next缓存
This commit is contained in:
@@ -1,120 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 CATEGORY_NAMES: Record<string, string> = { site: '站点设置', ai: 'AI 配置', member: '会员设置' };
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { key: string; label: string; type: string; placeholder: string }[]> = {
|
||||
site: [
|
||||
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
|
||||
{ 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' },
|
||||
],
|
||||
};
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<Config[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [category, setCategory] = useState('site');
|
||||
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('');
|
||||
const [category, setCategory] = useState('ai');
|
||||
|
||||
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);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: getAuthHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const configMap: Record<string, string> = {};
|
||||
(data.items || []).forEach((c: Config) => { configMap[c.key] = c.value; });
|
||||
setConfigs(data.items || []);
|
||||
setForm(configMap);
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => { loadConfigs(); }, [loadConfigs]);
|
||||
|
||||
function openAddDialog() {
|
||||
setEditingKey(null);
|
||||
setFormKey('');
|
||||
setFormValue('');
|
||||
setFormDesc('');
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveConfig(key: string) {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: form[key] || '' }),
|
||||
});
|
||||
if (res.ok) { setSaveMsg('保存成功'); setTimeout(() => setSaveMsg(''), 2000); }
|
||||
else { setSaveMsg('保存失败'); }
|
||||
} catch { setSaveMsg('保存失败'); }
|
||||
function openEditDialog(c: Config) {
|
||||
setEditingKey(c.key);
|
||||
setFormKey(c.key);
|
||||
setFormValue(c.value);
|
||||
setFormDesc(c.description || '');
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function addNewConfig() {
|
||||
if (!newKey.trim()) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
async function handleSave() {
|
||||
if (!formKey.trim()) return;
|
||||
const key = editingKey || formKey;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/config/${newKey}`, {
|
||||
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: newValue, description: newDesc, category }),
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ value: formValue, description: formDesc, category }),
|
||||
});
|
||||
setShowNewKey(false); setNewKey(''); setNewValue(''); setNewDesc('');
|
||||
setDialogOpen(false);
|
||||
loadConfigs();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getLabel(key: string): string | undefined {
|
||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||
const found = cat.find(f => f.key === key);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function getPlaceholder(key: string): string | undefined {
|
||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||
const found = cat.find(f => f.key === key);
|
||||
if (found) return found.placeholder;
|
||||
}
|
||||
return '';
|
||||
async function handleDelete(key: string) {
|
||||
if (!window.confirm(`确定删除配置项 "${key}" 吗?`)) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
loadConfigs();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-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>
|
||||
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
||||
</div>
|
||||
<button onClick={() => setShowNewKey(!showNewKey)}
|
||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
|
||||
{showNewKey ? '取消' : '+ 新增配置'}
|
||||
</button>
|
||||
<Button onClick={openAddDialog}>+ 新增配置</Button>
|
||||
</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">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button key={cat} onClick={() => setCategory(cat)}
|
||||
@@ -153,24 +109,65 @@ export default function ConfigPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||
{allKeys.map(key => (
|
||||
<div key={key} className="grid grid-cols-3 gap-4 items-center">
|
||||
<label className="text-sm text-muted-foreground">{getLabel(key) || key}</label>
|
||||
<div className="col-span-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form[key] || ''}
|
||||
onChange={e => setForm({ ...form, [key]: e.target.value })}
|
||||
placeholder={getPlaceholder(key)}
|
||||
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm"
|
||||
/>
|
||||
<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>
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">配置项</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">描述</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">值</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{configs.map(c => (
|
||||
<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>
|
||||
))}
|
||||
{allKeys.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">暂无配置项</p>}
|
||||
</div>
|
||||
<Dialog.DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</Dialog.DialogFooter>
|
||||
</Dialog.DialogContent>
|
||||
</Dialog.Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ interface Order {
|
||||
|
||||
const PLANS = [
|
||||
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
|
||||
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 29.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: 'MONTHLY', nameKey: 'planMonthly' as const, price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
|
||||
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
|
||||
];
|
||||
|
||||
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
||||
@@ -72,7 +72,7 @@ export default function MemberPage() {
|
||||
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
|
||||
|
||||
const body: Record<string, any> = {
|
||||
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
||||
amount: planType === 'MONTHLY' ? 49.9 : 299,
|
||||
planType, payChannel: 'wxpay', tradeType,
|
||||
};
|
||||
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>
|
||||
<div className="mb-4">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { DEFAULT_MODEL } from '@/lib/models';
|
||||
import { ModelSelector } from '@/components/ui/model-selector';
|
||||
import { useT } from '@/i18n';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
import LearningPath, { LEARNING_STAGES } from '@/components/sandbox/learning-path';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -25,6 +27,43 @@ interface SessionItem {
|
||||
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[] {
|
||||
const blocks: string[] = [];
|
||||
const regex = /```(?:\w+)?\n([\s\S]*?)```/g;
|
||||
@@ -67,6 +106,7 @@ function SandboxPage() {
|
||||
const [maxTokens, setMaxTokens] = useState(2000);
|
||||
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [sessionsOpen, setSessionsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [conversationId, setConversationId] = useState(() => crypto.randomUUID());
|
||||
@@ -76,9 +116,14 @@ function SandboxPage() {
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isStreamingRef = useRef(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,15 +139,27 @@ function SandboxPage() {
|
||||
}, [isLoggedIn]);
|
||||
|
||||
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`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
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 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);
|
||||
})
|
||||
.catch(() => {
|
||||
setSCENES(DEFAULT_SCENES);
|
||||
setScene(DEFAULT_SCENES[0].id);
|
||||
})
|
||||
.finally(() => setSkillsLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -115,12 +172,13 @@ function SandboxPage() {
|
||||
function loadSessions(tk?: string) {
|
||||
const token = tk || getToken();
|
||||
if (!token) return;
|
||||
setSessionsLoading(true);
|
||||
const params = searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : '';
|
||||
fetch(`${API_BASE}/sandbox/sessions${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.items) setSessions(data.items);
|
||||
}).catch(() => {});
|
||||
}).catch(() => {}).finally(() => setSessionsLoading(false));
|
||||
}
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -143,7 +263,6 @@ function SandboxPage() {
|
||||
|
||||
try {
|
||||
const tk = getToken();
|
||||
let reply = '';
|
||||
if (tk) {
|
||||
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
|
||||
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
|
||||
@@ -159,31 +278,84 @@ function SandboxPage() {
|
||||
'Content-Type': 'application/json',
|
||||
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 || '请求失败');
|
||||
reply = data.reply;
|
||||
if (data.conversationId) setConversationId(data.conversationId);
|
||||
if (data.sessionId) setCurrentSessionId(data.sessionId);
|
||||
|
||||
if ((res.headers.get('content-type') || '').includes('text/event-stream')) {
|
||||
isStreamingRef.current = true;
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
||||
|
||||
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 });
|
||||
setUploadedImages([]);
|
||||
loadSessions(tk);
|
||||
} else {
|
||||
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) {
|
||||
if (e.message.includes('今日沙箱使用次数已用完')) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。') }]);
|
||||
} else if (e.message.includes('未登录') || e.message.includes('Unauthorized')) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: '登录已过期,请重新登录后再试。' }]);
|
||||
const errMsg = e.message.includes('今日沙箱使用次数已用完')
|
||||
? '今日沙箱使用次数已用完。' + (isLoggedIn ? '' : ' 登录后可获得更多使用次数。')
|
||||
: e.message.includes('未登录') || e.message.includes('Unauthorized')
|
||||
? '登录已过期,请重新登录后再试。'
|
||||
: `出错啦:${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 {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: errMsg }]);
|
||||
}
|
||||
} finally {
|
||||
isStreamingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
@@ -266,7 +438,7 @@ function SandboxPage() {
|
||||
}
|
||||
|
||||
async function shareSessionLink() {
|
||||
if (!getToken() || !currentSessionId) { alert('请先登录'); return; }
|
||||
if (!getToken() || !currentSessionId) { toast.error('请先登录'); return; }
|
||||
try {
|
||||
const tk = getToken();
|
||||
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
|
||||
@@ -275,13 +447,13 @@ function SandboxPage() {
|
||||
const data = await res.json();
|
||||
if (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) {
|
||||
if (!getToken()) { alert('请先登录'); return; }
|
||||
if (!getToken()) { toast.error('请先登录'); return; }
|
||||
apiFetch('/community/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -289,7 +461,7 @@ function SandboxPage() {
|
||||
content: `【AI沙箱对话分享】\n\n${content}\n\n---\n来自宇之然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;
|
||||
@@ -320,6 +492,12 @@ function SandboxPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
{!isLoggedIn && (
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
) : sessions.map(s => (
|
||||
<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 gap-2 mb-4 overflow-x-auto pb-1">
|
||||
{SCENES.map(s => (
|
||||
<button key={s.id} onClick={() => handleSceneChange(s.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium whitespace-nowrap border transition-colors shrink-0 ${
|
||||
scene === s.id
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-card text-muted-foreground border-border hover:border-brand-400 hover:text-foreground'
|
||||
}`}>
|
||||
<span>{s.icon}</span>
|
||||
<span>{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div />
|
||||
<button onClick={() => setShowParams(!showParams)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg border transition-colors ${
|
||||
showParams ? 'bg-accent text-foreground border-border' : 'text-muted-foreground border-border hover:text-foreground hover:bg-accent'
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button onClick={() => setMode('learn')}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
|
||||
mode === 'learn' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border hover:text-foreground'
|
||||
}`}>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${showParams ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
{t.sandbox.advancedParams}
|
||||
📚 {t.sandbox.learnMode}
|
||||
</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>
|
||||
|
||||
{showParams && (
|
||||
<div className="bg-card border border-border rounded-xl p-4 mb-3 space-y-3">
|
||||
{[
|
||||
{ label: t.sandbox.temperature, min: 0, max: 2, step: 0.1, val: temperature, set: setTemperature, fmt: (v: number) => v.toFixed(1) },
|
||||
{ label: t.sandbox.topP, min: 0, max: 1, step: 0.05, val: topP, set: setTopP, fmt: (v: number) => v.toFixed(2) },
|
||||
{ label: t.sandbox.maxTokens, min: 100, max: 8192, step: 100, val: maxTokens, set: setMaxTokens, fmt: (v: number) => String(v) },
|
||||
].map(p => (
|
||||
<div key={p.label}>
|
||||
<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 className="bg-card border border-border rounded-2xl p-4 mb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">{t.sandbox.temperature} ({temperature})</label>
|
||||
<input type="range" min="0" max="2" step="0.1" value={temperature}
|
||||
onChange={e => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full accent-brand-600" />
|
||||
</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 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">
|
||||
{isNewChat && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
@@ -523,7 +772,20 @@ function SandboxPage() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
|
||||
</div>
|
||||
@@ -574,8 +836,8 @@ function SandboxPage() {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>)}
|
||||
<div className="mt-4 text-center text-xs text-muted-foreground">
|
||||
{t.sandbox.aiReplyDisclaimer}{!isLoggedIn && ` ${t.sandbox.loginForMoreQuota}`}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user