feat: 落地打赏(个人码捐赠)模式并将付费面改为免费+赞助(无ICP证合规)

- 新增 Donation 模型/迁移/后端 API(GET,POST /donations)
- 新增前端 /donate 页:收款码+感谢留言+感谢墙
- 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口
- 沙箱用量耗尽引导至 /donate 赞助
- 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
This commit is contained in:
TradeMate Dev
2026-07-11 15:02:08 +08:00
parent 31cc1e02ce
commit 00a3904eba
17 changed files with 600 additions and 538 deletions
@@ -0,0 +1,13 @@
-- 支持模式:用户自愿打赏(个人收款码捐赠)留言记录
-- 平台不代收任何费用,此处仅用于展示「感谢墙」,不记录支付流水
CREATE TABLE IF NOT EXISTS `donations` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191),
`message` VARCHAR(280),
`channel` VARCHAR(191) NOT NULL DEFAULT 'ALIPAY',
`amount` DOUBLE,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `donations_created_at_idx` (`created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+12
View File
@@ -718,3 +718,15 @@ model AffiliateClick {
@@index([linkId])
@@map("affiliate_clicks")
}
model Donation {
id Int @id @default(autoincrement())
name String?
message String? @db.VarChar(280)
channel String @default("ALIPAY")
amount Float?
createdAt DateTime @default(now())
@@index([createdAt])
@@map("donations")
}
+2
View File
@@ -25,6 +25,7 @@ import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.module';
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
import { PracticesModule } from './modules/practices/practices.module';
import { DonationsModule } from './modules/donations/donations.module';
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
@Module({
@@ -45,6 +46,7 @@ import { RedisThrottlerStorage } from './common/redis-throttler-storage';
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
AiAssistantModule,
PracticesModule,
DonationsModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
@@ -0,0 +1,20 @@
import { Controller, Get, Post, Body, Query, HttpCode } from '@nestjs/common';
import { DonationsService } from './donations.service';
import { CreateDonationDto } from './dto/create-donation.dto';
@Controller('donations')
export class DonationsController {
constructor(private readonly donationsService: DonationsService) {}
@Get()
getList(@Query('limit') limit?: string) {
const parsed = limit ? Math.min(parseInt(limit, 10) || 20, 50) : 20;
return this.donationsService.getList(parsed);
}
@Post()
@HttpCode(200)
create(@Body() dto: CreateDonationDto) {
return this.donationsService.create(dto);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DonationsService } from './donations.service';
import { DonationsController } from './donations.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DonationsController],
providers: [DonationsService],
exports: [DonationsService],
})
export class DonationsModule {}
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class DonationsService {
constructor(private prisma: PrismaService) {}
async getList(limit = 20) {
const donations = await this.prisma.donation.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
name: true,
message: true,
channel: true,
amount: true,
createdAt: true,
},
});
return {
donations: donations.map((d) => ({
...d,
amount: d.amount != null ? Number(d.amount) : null,
})),
};
}
async create(data: {
name?: string;
message?: string;
channel?: 'ALIPAY' | 'WECHAT';
amount?: number;
}) {
const donation = await this.prisma.donation.create({
data: {
name: data.name ?? null,
message: data.message ?? null,
channel: data.channel ?? 'ALIPAY',
amount: data.amount != null ? data.amount : null,
},
});
return {
success: true,
donation: {
...donation,
amount: donation.amount != null ? Number(donation.amount) : null,
},
};
}
}
@@ -0,0 +1,22 @@
import { IsOptional, IsString, IsIn, IsNumber, MaxLength, Min } from 'class-validator';
export class CreateDonationDto {
@IsOptional()
@IsString()
@MaxLength(40)
name?: string;
@IsOptional()
@IsString()
@MaxLength(280)
message?: string;
@IsOptional()
@IsIn(['ALIPAY', 'WECHAT'])
channel?: 'ALIPAY' | 'WECHAT';
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(0)
amount?: number;
}
+29 -7
View File
@@ -1,17 +1,17 @@
# 宇之然 AI 平台 - 进度追踪
**文档版本:** v1.1.0
**文档版本:** v1.2.0
**最后更新:** 2026-07-11
**状态:** 🟢 联盟返佣系统核心已完成(管理后台 UI 待补)
**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;付费订阅/付费 Skill/用量包已下线
**负责人:** Hermes Agent
---
## 项目概览
- **目标:** 构建联盟返佣系统,并将「技能广场」与推广链接打通,作为当前(无 ICP 经营许可证前)合规的主力变现路径
- **合规定位:** 会员付费/付费 Skill 属经营性收费,需 ICP 经营许可证。在取证前以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现,配合免费内容 + SEO
- **范围:** 数据模型(规范化)、后端 API(ORM,去 SQL 注入)、前端展示与点击追踪、单元测试、种子数据
- **目标:** 在「无 ICP 经营许可证」阶段,将全部经营性收费(会员订阅、付费 Skill、沙箱用量包)下架,改为合规的「打赏(个人码捐赠)+ 联盟返佣」模式
- **合规定位:** 会员付费/付费 Skill/虚拟商品属经营性收费,需 ICP 经营许可证。在取证前以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现;以**打赏/捐赠**(个人收款码,自愿赠与,平台不提供对价)作为补充;所有内容(技能、练习、工具指南)保持免费开放
- **范围:** 打赏数据模型与 API、前端 /donate 页、会员/技能广场/用量包三处付费面改造为免费+赞助、导航与页脚入口、单元测试、种子数据
---
@@ -28,23 +28,44 @@
| 测试 | `affiliate.service.spec.ts` | 4 个用例(聚合/点击/异常/过滤),已通过 |
| 种子 | `prisma/seed-affiliate.ts` | 幂等 upsert 阿里云云大使计划 + 推广链接;`npx ts-node prisma/seed-affiliate.ts` |
### 打赏(个人码捐赠)模式 — v1.2.0
| 模块 | 内容 | 说明 |
|------|------|------|
| 数据模型 | `Donation` | Prisma 模型(`name`/`message`/`channel`/`amount`/`createdAt`),仅用于「感谢墙」展示,不记录支付流水 |
| 迁移 | `prisma/migrations/20260711010000_add_donations` | 建 `donations` 表;部署时 `prisma migrate deploy` |
| 后端服务 | `donations.service.ts` | `getList(limit)` 最近留言、`create(dto)` 保存自愿留言 |
| 后端接口 | `donations.controller.ts` | `GET /donations``POST /donations`(公开,无鉴权) |
| 前端页 | `donate/page.tsx` | 个人收款码(支付宝/微信,环境变量 `NEXT_PUBLIC_DONATE_ALIPAY_QR`/`NEXT_PUBLIC_DONATE_WECHAT_QR`+ 感谢留言表单 + 感谢墙 |
| 会员页 | `my/member/page.tsx` | 移除付费订阅与 PaymentModal,改为免费说明 + 赞助/联盟入口 |
| 技能广场 | `marketplace/page.tsx` | 所有 Skill 免费开放,下架付费购买与 PaymentModal |
| 用量包 | `practices/packages/page.tsx` | 下架付费用量包,仅展示免费配额 + 赞助入口 |
| 沙箱 | `sandbox/page.tsx` | 用量耗尽提示改为引导至 /donate 赞助 |
| 导航/页脚 | `header.tsx` / `footer.tsx` | 新增「赞助」「联盟返佣」入口 |
| i18n | `zh.ts` / `en.ts` | 新增 `donate.*`、导航 `affiliate`/`donate`、说明文案 |
> **说明:** 后端 `orders` / `payment` 模块代码保留(取证后可复用),但前端已不再调用付费购买流程。
---
## 待办(下一阶段)
| 任务 | 说明 |
|------|------|
| 部署配置 | 在部署环境设置支付宝/微信收款码环境变量(`NEXT_PUBLIC_DONATE_*_QR` |
| 管理后台 UI | AffiliateProgram/Link 的列表与编辑页(T-009~T-013 |
| 首页/工具页嵌入 | 在首页与工具详情侧边栏插入「云产品推荐」区块(T-016/T-017 |
| 曝光埋点 | 记录推广位曝光(目前仅记录点击) |
| 后端捐赠单测 | 为 `donations.service.spec.ts` 补充用例(mock Prisma |
---
## 变现模式排序(无 ICP 证阶段)
1. **联盟返佣**(已落地)— 风险最低,第三方结算佣金
2. 免费内容 + SEO 引流
3. (取证后)会员订阅 / 付费 Skill / 企业版
2. **打赏/个人码捐赠**(已落地)— 自愿赠与,平台不提供对价,不构成经营性收费
3. 免费内容 + SEO 引流
4. (取证后)会员订阅 / 付费 Skill / 企业版
---
@@ -54,3 +75,4 @@
|------|------|------|----------|
| 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 |
| 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 |
| 2026-07-11 | v1.2.0 | Hermes Agent | 落地打赏(个人码捐赠)模式:Donation 模型+迁移+API+前端 /donate 页;会员/技能广场/用量包三处付费面改造为免费+赞助;导航页脚入口;i18n 中文化 |
+225
View File
@@ -0,0 +1,225 @@
'use client';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Heart, QrCode, MessageCircle, Send } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { apiFetch } from '@/lib/auth';
import { useT } from '@/i18n';
const ALIPAY_QR = process.env.NEXT_PUBLIC_DONATE_ALIPAY_QR || '';
const WECHAT_QR = process.env.NEXT_PUBLIC_DONATE_WECHAT_QR || '';
interface Donation {
id: number;
name: string | null;
message: string | null;
channel: string;
amount: number | null;
createdAt: string;
}
function channelLabel(t: ReturnType<typeof useT>, channel: string) {
if (channel === 'WECHAT') return t.donate.wechat;
return t.donate.alipay;
}
function QrCard({
t,
label,
src,
}: {
t: ReturnType<typeof useT>;
label: string;
src: string;
}) {
return (
<Card className="p-6 flex flex-col items-center text-center">
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center mb-4">
<QrCode className="w-6 h-6 text-rose-600 dark:text-rose-400" />
</div>
<h3 className="font-semibold text-foreground mb-1">{label}</h3>
{src ? (
<div className="mt-3 border border-border rounded-xl p-3 bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={label} className="w-44 h-44 object-contain mx-auto" />
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">{t.donate.noQr}</p>
)}
</Card>
);
}
export default function DonatePage() {
const t = useT();
const [donations, setDonations] = useState<Donation[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [channel, setChannel] = useState<'ALIPAY' | 'WECHAT'>('ALIPAY');
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/donations`)
.then((r) => r.json())
.then((data) => setDonations(data.donations || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(false);
try {
const res = await apiFetch(`${API_BASE}/donations`, {
method: 'POST',
body: JSON.stringify({
name: name.trim() || undefined,
message: message.trim() || undefined,
channel,
}),
});
if (res.ok) {
setDone(true);
setName('');
setMessage('');
const data = await res.json();
if (data.donation) {
setDonations((prev) => [data.donation, ...prev].slice(0, 20));
}
} else {
setError(true);
}
} catch {
setError(true);
} finally {
setSubmitting(false);
}
}
return (
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-10">
<div className="inline-flex w-14 h-14 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
<Heart className="w-7 h-7 text-rose-600 dark:text-rose-400" />
</div>
<h1 className="text-3xl font-bold text-foreground">{t.donate.title}</h1>
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.donate.desc}</p>
</div>
<Card className="p-8 mb-8 text-center bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background">
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
<p className="mt-4 text-sm text-muted-foreground">{t.donate.qrNote}</p>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
<QrCard t={t} label={t.donate.alipay} src={ALIPAY_QR} />
<QrCard t={t} label={t.donate.wechat} src={WECHAT_QR} />
</div>
<Card className="p-6 mb-10">
<div className="flex items-center gap-2 mb-4">
<MessageCircle className="w-5 h-5 text-brand-600 dark:text-brand-400" />
<h2 className="text-lg font-semibold text-foreground">{t.donate.leaveMessage}</h2>
</div>
{done ? (
<div className="text-center py-6">
<p className="text-green-600 dark:text-green-400 font-medium">{t.donate.submitSuccess}</p>
<Button variant="outline" className="mt-4" onClick={() => setDone(false)}>
{t.donate.leaveMessage}
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.donate.namePlaceholder}
maxLength={40}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500"
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t.donate.messagePlaceholder}
maxLength={280}
rows={3}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500 resize-none"
/>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">{t.donate.channel}</span>
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="radio"
name="channel"
checked={channel === 'ALIPAY'}
onChange={() => setChannel('ALIPAY')}
/>
{t.donate.alipay}
</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="radio"
name="channel"
checked={channel === 'WECHAT'}
onChange={() => setChannel('WECHAT')}
/>
{t.donate.wechat}
</label>
</div>
{error && <p className="text-sm text-red-600">{t.donate.submitFailed}</p>}
<Button type="submit" disabled={submitting} className="gap-2">
<Send className="w-4 h-4" />
{submitting ? t.donate.submitting : t.donate.submit}
</Button>
</form>
)}
</Card>
<div>
<h2 className="text-xl font-semibold text-foreground mb-4">{t.donate.thanksWall}</h2>
<p className="text-sm text-muted-foreground mb-4">{t.donate.thanksWallDesc}</p>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
) : donations.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">{t.common.noData}</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{donations.map((d) => (
<Card key={d.id} className="p-4">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-foreground">
{d.name || t.donate.anonymous}
</span>
<span className="text-xs text-muted-foreground">
{t.donate.via.replace('{channel}', channelLabel(t, d.channel))}
</span>
</div>
{d.message && (
<p className="text-sm text-muted-foreground">
{t.donate.says}{d.message}
</p>
)}
</Card>
))}
</div>
)}
</div>
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
{t.donate.disclaimer}
</p>
</div>
);
}
+66 -183
View File
@@ -2,16 +2,12 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
import { ShoppingBag, CheckCircle, Heart } from 'lucide-react';
interface MarketplaceSkill {
id: string; name: string; description: string; icon: string;
@@ -19,221 +15,108 @@ interface MarketplaceSkill {
price: number | null; purchased: boolean; sortOrder: number;
}
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
export default function MarketplacePage() {
const t = useT();
const router = useRouter();
const { isLoggedIn } = useAuth();
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<FilterMode>('all');
const [payLoading, setPayLoading] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
useEffect(() => {
const url = `${API_BASE}/skills/marketplace`;
fetch(url, { credentials: 'include' })
.then(r => r.json())
.then(data => {
.then((r) => r.json())
.then((data) => {
setSkills(Array.isArray(data) ? data : []);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = skills.filter(s => {
if (filter === 'free') return !s.price;
if (filter === 'premium') return !!s.price;
if (filter === 'purchased') return s.purchased;
return true;
});
async function handleBuy(skill: MarketplaceSkill) {
if (!isLoggedIn) { router.push('/auth'); return; }
setPayLoading(skill.id);
setSuccess(null);
try {
const res = await apiFetch('/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: skill.price,
planType: 'SKILL',
skillId: skill.id,
payChannel: 'alipay',
}),
});
const data = await res.json();
if (data.order && data.payResult) {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if (payUrl || qrCode) {
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel: 'alipay' });
}
}
} catch (e) {
console.error(e);
}
setPayLoading(null);
}
function handlePaymentPaid() {
setPaymentModal(prev => ({ ...prev, open: false }));
setSuccess(t.marketplace.purchaseSuccess);
}
const filters: { key: FilterMode; label: string }[] = [
{ key: 'all', label: t.common.viewAll },
{ key: 'free', label: t.marketplace.free },
{ key: 'premium', label: t.marketplace.locked },
{ key: 'purchased', label: t.marketplace.purchased },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
</div>
<div className="flex flex-wrap gap-2 mb-8">
{filters.map(f => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === f.key
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
<div className="flex items-center justify-between gap-3 bg-rose-50 dark:bg-rose-950/20 border border-rose-200 dark:border-rose-900 rounded-xl p-4 mb-8">
<div className="flex items-center gap-3">
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400 shrink-0" />
<p className="text-sm text-foreground">{t.marketplace.freeNote}</p>
</div>
)}
<Link
href="/donate"
className="shrink-0 text-sm font-medium text-rose-600 dark:text-rose-400 hover:underline"
>
{t.donate.toDonate}
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-52 rounded-2xl" />)}
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-52 rounded-2xl" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map(skill => {
const isPremium = !!skill.price;
const isOwned = skill.purchased;
return (
<div key={skill.id}
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
}`}
>
{isPremium && !isOwned && (
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
<Sparkles className="h-3.5 w-3.5" />
<span>{t.marketplace.locked}</span>
</div>
)}
<div className="flex items-start gap-3 mb-3">
<span className="text-3xl">{skill.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{skill.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{(t.skills as any)[skill.difficulty]}
</span>
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
))}
</div>
<div className="mt-auto flex items-center gap-2">
{isOwned ? (
<>
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3.5 w-3.5" />
{t.marketplace.purchased}
</Badge>
<Button asChild size="sm" variant="default" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>
{t.skills.apply}
</Link>
</Button>
</>
) : isPremium ? (
<>
<span className="text-lg font-bold text-foreground">¥{skill.price}</span>
<Button
size="sm"
onClick={() => handleBuy(skill)}
disabled={payLoading === skill.id}
className="ml-auto"
>
{payLoading === skill.id ? (
<span className="flex items-center gap-1">
<span className="animate-spin h-3 w-3 border-2 border-current border-t-transparent rounded-full" />
{t.marketplace.buying}
</span>
) : (
<span className="flex items-center gap-1">
<ShoppingBag className="h-3.5 w-3.5" />
{t.marketplace.buy.replace('{price}', String(skill.price))}
</span>
)}
</Button>
</>
) : (
<Button asChild size="sm" variant="outline" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>
{t.skills.apply}
</Link>
</Button>
)}
{skills.map((skill) => (
<div
key={skill.id}
className="bg-card rounded-2xl border-2 border-border p-6 transition-all hover:shadow-md flex flex-col"
>
<div className="flex items-start gap-3 mb-3">
<span className="text-3xl">{skill.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{skill.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
</div>
</div>
);
})}
<div className="flex items-center gap-2 mb-3">
<span
className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner'
? 'bg-green-100 text-green-700'
: skill.difficulty === 'intermediate'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}
>
{(t.skills as any)[skill.difficulty]}
</span>
<span className="text-xs text-muted-foreground">
{(t.skills.categories as any)[skill.category] || skill.category}
</span>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{skill.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
{tag}
</span>
))}
</div>
<div className="mt-auto flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3.5 w-3.5" />
{t.marketplace.free}
</Badge>
<Button asChild size="sm" variant="default" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>{t.skills.apply}</Link>
</Button>
</div>
</div>
))}
</div>
)}
{!loading && filtered.length === 0 && (
{!loading && skills.length === 0 && (
<div className="text-center py-20">
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">{t.common.noData}</p>
</div>
)}
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</div>
);
}
+63 -202
View File
@@ -4,240 +4,101 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import PaymentModal from '@/components/ui/payment-modal';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n';
import { toast } from 'sonner';
import { isWeChatBrowser } from '@/lib/wechat';
interface PayResult {
gatewayOrderId?: string;
payUrl?: string;
qrcode?: string;
codeUrl?: string;
redirectUrl?: string;
status?: string;
}
interface Subscription {
id: number; plan: string; startDate: string; endDate: string; status: string;
}
interface Order {
id: number; orderNo: string; amount: number; planType: string; status: string; payChannel?: string; createdAt: string;
}
const PLANS = [
{ id: 'FREE', nameKey: 'planFree', price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] },
{ id: 'MONTHLY', nameKey: 'planMonthly', price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] },
{ id: 'YEARLY', nameKey: 'planYearly', price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] },
] as const;
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
import { Heart, Link2, Sparkles } from 'lucide-react';
export default function MemberPage() {
const t = useT();
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState<string | null>(null);
const [payChannel, setPayChannel] = useState<'wxpay' | 'alipay'>('alipay');
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: PayResult; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
useEffect(() => { loadData(); }, []);
async function loadData() {
try {
const [subRes, ordersRes, quotaRes] = await Promise.all([
apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
apiFetch('/orders'),
apiFetch('/sandbox/quota'),
]);
if (subRes.ok) setSubscription(await (subRes as Response).json());
const ordersData = await ordersRes.json();
setOrders(ordersData.items || []);
const quotaData = await quotaRes.json();
if (quotaData.remaining !== undefined) setQuota(quotaData);
} catch { toast.error("加载失败") }
setLoading(false);
}
async function handleSubscribe(planType: string) {
setPayLoading(planType);
try {
const body: Record<string, any> = {
amount: planType === 'MONTHLY' ? 49.9 : 299,
planType,
payChannel,
};
const res = await apiFetch('/orders/create', {
method: 'POST',
body: JSON.stringify(body),
});
const data = await res.json();
if (data.order && data.payResult) {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if (payUrl || qrCode) {
setPaymentModal({
open: true,
orderNo: data.order.orderNo,
payResult: data.payResult,
payChannel,
});
}
}
} catch { toast.error("加载失败") }
setPayLoading(null);
}
function handlePaymentPaid() {
setPaymentModal(prev => ({ ...prev, open: false }));
loadData();
}
useEffect(() => {
apiFetch('/sandbox/quota')
.then((r) => r.json())
.then((data) => {
if (data.remaining !== undefined) setQuota(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-5 w-64 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{[1,2,3].map(i => <Skeleton key={i} className="h-72 rounded-2xl" />)}
</div>
<Skeleton className="h-40 rounded-2xl mb-6" />
<Skeleton className="h-48 rounded-2xl" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; {t.myLearning.back}</Link>
<h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
<p className="mt-2 text-muted-foreground">{t.member.desc}</p>
</div>
{quota && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<Card className="p-6 mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
<span className="text-sm text-muted-foreground">{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}</span>
<span className="text-sm text-muted-foreground">
{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}
</span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }} />
<div
className="bg-brand-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }}
/>
</div>
</div>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{PLANS.map(plan => {
const isCurrent = subscription?.plan === plan.id;
return (
<div key={plan.id} className={`bg-card rounded-2xl border-2 p-6 flex flex-col ${isCurrent ? 'border-brand-600' : plan.popular ? 'border-brand-400' : 'border-border'}`}>
{plan.popular && !isCurrent && (
<span className="self-start text-xs font-medium px-2 py-0.5 bg-brand-600 text-white rounded-full mb-3">{t.member.popular}</span>
)}
{isCurrent && (
<span className="self-start text-xs font-medium px-2 py-0.5 bg-green-600 text-white rounded-full mb-3">{t.member.currentPlan_badge}</span>
)}
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
<div className="mb-4">
{plan.price > 0 ? (
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{plan.price > 0 ? t.member[plan.period as keyof typeof t.member] : ''}</span></span>
) : (
<span className="text-2xl font-bold text-foreground">¥0</span>
)}
<Card className="p-8 mb-6 bg-gradient-to-br from-brand-50 to-white dark:from-brand-950/20 dark:to-background text-center">
<div className="inline-flex w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl items-center justify-center mb-4">
<Sparkles className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<h2 className="text-xl font-semibold text-foreground">{t.member.freeUser}</h2>
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.member.benefits}</p>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link href="/donate" className="block">
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center">
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400" />
</div>
<div className="space-y-2 flex-1 mb-6">
{(plan.id === 'FREE' ? FEATURE_LABELS : FEATURE_LABELS).map((f, fi) => (
<div key={f} className="grid grid-cols-[1fr_auto] gap-x-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<span className="text-green-600 text-xs shrink-0"></span>
<span className="text-muted-foreground truncate">{t.member[f]}</span>
</div>
<span className="text-xs text-foreground font-medium text-right">{t.member[plan.features[fi]]}</span>
</div>
))}
</div>
{plan.id !== 'FREE' && (
<div className="space-y-3">
{!isCurrent && (
<div className="flex gap-2">
<button
onClick={() => { setPayChannel('alipay'); handleSubscribe(plan.id); }}
disabled={payLoading === plan.id}
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
plan.popular
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'border border-border text-foreground hover:bg-accent'
}`}
>
{payLoading === plan.id ? t.member.processing : t.member.alipay}
</button>
<button
onClick={() => { setPayChannel('wxpay'); handleSubscribe(plan.id); }}
disabled={payLoading === plan.id}
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
plan.popular
? 'bg-brand-600 text-white hover:bg-brand-700'
: 'border border-border text-foreground hover:bg-accent'
}`}
>
{payLoading === plan.id ? t.member.processing : t.member.wechatPay}
</button>
</div>
)}
{isCurrent && (
<button disabled
className="w-full py-2.5 rounded-xl text-sm font-medium bg-muted text-muted-foreground cursor-default">
{t.member.currentPlan_badge}
</button>
)}
</div>
)}
<h3 className="font-semibold text-foreground">{t.donate.supportUs}</h3>
</div>
);
})}
<p className="text-sm text-muted-foreground flex-1">{t.donate.supportDesc}</p>
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
{t.donate.toDonate}
</span>
</Card>
</Link>
<Link href="/affiliate" className="block">
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center">
<Link2 className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="font-semibold text-foreground">{t.donate.affiliate}</h3>
</div>
<p className="text-sm text-muted-foreground flex-1">{t.affiliate.desc}</p>
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
{t.affiliate.title}
</span>
</Card>
</Link>
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2>
{orders.length === 0 ? (
<p className="text-muted-foreground text-center py-8">{t.member.noOrders}</p>
) : (
<div className="space-y-3">
{orders.map(order => (
<div key={order.id} className="flex items-center justify-between p-4 rounded-xl border border-border">
<div>
<div className="font-medium text-foreground">{t.member[order.planType === 'YEARLY' ? 'yearly' : 'monthly']}</div>
<div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div>
</div>
<div className="text-right">
<div className="font-semibold text-foreground">¥{order.amount}</div>
<div className="flex items-center gap-2 justify-end">
{order.payChannel && (
<span className="text-xs text-muted-foreground">{order.payChannel === 'alipay' ? '支付宝' : '微信'}</span>
)}
<span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
{order.status}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onPaid={handlePaymentPaid}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
/>
<p className="mt-8 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
{t.donate.disclaimer}
</p>
</div>
);
}
+55 -119
View File
@@ -4,11 +4,11 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { Zap, CheckCircle } from 'lucide-react';
import { Zap, Heart, ArrowLeft } from 'lucide-react';
interface Quota {
dailyLimit: number;
@@ -18,79 +18,45 @@ interface Quota {
totalRemaining: number;
}
const PACKAGES = [
{ id: 'PACKAGE_10', planType: 'PACKAGE', amount: 4.9, quotaAmount: 10, labelKey: 'package10' as const, descKey: 'package10Desc' as const },
{ id: 'PACKAGE_50', planType: 'PACKAGE', amount: 9.9, quotaAmount: 50, labelKey: 'package50' as const, descKey: 'package50Desc' as const, popular: true },
{ id: 'PACKAGE_300', planType: 'PACKAGE', amount: 49, quotaAmount: 300, labelKey: 'package300' as const, descKey: 'package300Desc' as const },
];
export default function PackagesPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [quota, setQuota] = useState<Quota | null>(null);
const [loading, setLoading] = useState(true);
const [payLoading, setPayLoading] = useState<string | null>(null);
const [payChannel] = useState<'wxpay' | 'alipay'>('alipay');
const [paymentModal, setPaymentModal] = useState<{
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return; }
if (!isLoggedIn) {
setLoading(false);
return;
}
apiFetch('/sandbox/quota')
.then(r => r.json())
.then(data => setQuota(data))
.then((r) => r.json())
.then((data) => setQuota(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [isLoggedIn]);
async function handleBuy(pkg: typeof PACKAGES[0]) {
setPayLoading(pkg.id);
setSuccess(null);
try {
const res = await apiFetch('/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: pkg.amount, planType: pkg.planType, payChannel }),
});
const data = await res.json();
if (data.order && data.payResult) {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if (payUrl || qrCode) {
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel });
}
}
} catch (e) {
console.error(e);
}
setPayLoading(null);
}
function handlePaymentPaid() {
setPaymentModal(prev => ({ ...prev, open: false }));
setSuccess(t.packages.purchaseSuccess.replace('{n}', '50'));
apiFetch('/sandbox/quota').then(r => r.ok && r.json()).then(d => d && setQuota(d));
}
if (!isLoggedIn) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<Zap className="h-12 w-12 text-brand-600 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-foreground mb-2">{t.packages.title}</h1>
<p className="text-muted-foreground mb-6">{t.packages.desc}</p>
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
<Button asChild>
<Link href="/auth">{t.common.login}</Link>
</Button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/practices" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block">
&larr; {t.practices.title}
<Link
href="/practices"
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block"
>
<ArrowLeft className="w-4 h-4 inline mr-1" />
{t.practices.title}
</Link>
<div className="mb-8">
@@ -98,77 +64,47 @@ export default function PackagesPage() {
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
</div>
{quota && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
<div className="flex flex-wrap gap-6">
<div>
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span>
</div>
<div>
<span className="text-sm text-muted-foreground">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>
</div>
</div>
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PACKAGES.map(pkg => (
<div key={pkg.id} className={`bg-card rounded-2xl border-2 p-6 relative transition-all hover:shadow-md ${
pkg.popular ? 'border-brand-600' : 'border-border'
}`}>
{pkg.popular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-medium px-3 py-0.5 rounded-full">
{t.packages.popular}
</span>
)}
<div className="text-center mb-4">
<div className="text-3xl font-bold text-foreground">{t.packages.price.replace('{price}', String(pkg.amount))}</div>
<div className="text-sm text-muted-foreground mt-1">{t.packages.quota.replace('{n}', String(pkg.quotaAmount))}</div>
</div>
<div className="text-center mb-6">
<div className="font-medium text-foreground">{t.packages[pkg.labelKey]}</div>
<div className="text-xs text-muted-foreground mt-1">{t.packages[pkg.descKey]}</div>
</div>
<Button
onClick={() => handleBuy(pkg)}
disabled={payLoading === pkg.id}
className={`w-full ${pkg.popular ? '' : 'variant-outline'}`}
variant={pkg.popular ? 'default' : 'outline'}
>
{payLoading === pkg.id ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
{t.packages.buying}
{loading ? (
<Skeleton className="h-32 rounded-2xl mb-8" />
) : (
quota && (
<Card className="p-6 mb-8">
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
<div className="flex flex-wrap gap-6">
<div>
<span className="text-sm text-muted-foreground">
{t.packages.dailyQuota
.replace('{used}', String(quota.used))
.replace('{limit}', String(quota.dailyLimit))}
</span>
) : t.packages.buy}
</Button>
</div>
))}
</div>
</div>
<div>
<span className="text-sm text-muted-foreground">
{t.packages.extraQuota.replace('{n}', String(quota.extra))}
</span>
</div>
</div>
</Card>
)
)}
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
{t.packages.orUpgrade}{' '}
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link>
</p>
</div>
<Card className="p-8 mb-8 bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background text-center">
<div className="inline-flex w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
<Heart className="w-6 h-6 text-rose-600 dark:text-rose-400" />
</div>
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
<Button asChild className="mt-5 gap-2">
<Link href="/donate">
<Heart className="w-4 h-4" />
{t.donate.toDonate}
</Link>
</Button>
</Card>
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
<p className="text-xs text-muted-foreground text-center max-w-2xl mx-auto">
{t.donate.disclaimer}
</p>
</div>
);
}
+18 -21
View File
@@ -772,29 +772,26 @@ function SandboxPage() {
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border p-4">
{quota && quota.totalRemaining <= 0 ? (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.packages.buyMore}</div>
<div className="border-t border-border p-4">
{quota && quota.totalRemaining <= 0 ? (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.sandbox.quotaExhaustedHint}</div>
</div>
<Link href="/donate"
className="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 shrink-0">
{t.donate.toDonate}
</Link>
</div>
<Link href="/practices/packages"
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
{t.packages.title}
</Link>
</div>
</div>
) : quota && (
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
{quota.dailyRemaining <= 0 && quota.extra > 0 && (
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
)}
</div>
)}
) : quota && (
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
</div>
)}
{uploadedImages.length > 0 && (
<div className="flex gap-2 mb-2 flex-wrap">
{uploadedImages.map((img, i) => (
@@ -77,6 +77,8 @@ export function Footer() {
<li><Link href="/prompts" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.nav.prompts}</Link></li>
<li><Link href="/resources" className="text-sm text-muted-foreground hover:text-foreground transition-colors"></Link></li>
<li><Link href="/tools" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.footer.aiTools}</Link></li>
<li><Link href="/affiliate" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.donate.affiliate}</Link></li>
<li><Link href="/donate" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.donate.supportUs}</Link></li>
</ul>
</div>
<div>
@@ -62,6 +62,8 @@ export function Header() {
{ href: '/prompts', label: t.nav.prompts },
{ href: '/resources', label: '云资源' },
{ href: '/marketplace', label: t.nav.marketplace },
{ href: '/affiliate', label: t.nav.affiliate },
{ href: '/donate', label: t.nav.donate },
{ href: '/contents', label: t.nav.articles },
];
+4 -3
View File
@@ -2,7 +2,7 @@ import type { Translations } from './zh'
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' },
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles', practices: 'Practice', enterprise: 'Enterprise', marketplace: 'Marketplace', more: 'More' },
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles', practices: 'Practice', enterprise: 'Enterprise', marketplace: 'Marketplace', affiliate: 'Affiliate', donate: 'Donate', more: 'More' },
home: { badge: 'AI Tool Guide & Practice', heroHighlight: 'AI Tool Guide', heroRest: 'Practice · Skill Building', desc: 'Curated AI tools with real-world practice to accelerate your AI skills', 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: 'Curated Tools', featureGuideDesc: 'Hand-picked AI tools to help you find the right solution', featureSandbox: 'Practice Sandbox', featureSandboxDesc: 'Practice tool usage skills in the sandbox', 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', featuredToolsTitle: 'Featured Tools', featuredToolsDesc: 'Hand-picked AI tools for you', 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', username: 'Username', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Username / Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', usernameOptional: 'Username (optional, 2-20 chars)', 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', usernameConflict: 'Username already taken', phoneConflict: 'Phone already registered', emailConflict: 'Email already registered' },
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', recentLearning: 'Recent Learning', modelLabel: 'Model: {model}', viewCount: '{n} views', likeCount: '{n} likes', 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' },
@@ -26,16 +26,17 @@ const en: Translations = {
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' },
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', sceneTutor: 'Programming Tutor', 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' },
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', quotaExhaustedHint: 'Free quota exhausted. Donate to support our operation', 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' },
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', payMethod: 'Payment Method', wechatPay: 'WeChat Pay', alipay: 'Alipay', scanQrCode: 'Scan QR code with WeChat', alipayRedirect: 'Redirecting to Alipay...', openAlipay: 'Open Alipay', payFailed: 'Payment failed, please retry', orderPaid: 'Payment successful!', cancelPay: 'Cancel', waitingPay: 'Waiting for payment...' },
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' },
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', marketing: 'Marketing', business: 'Business' } },
marketplace: { title: 'Skill Marketplace', desc: 'One-time purchase, lifetime access — premium AI skills for your workflow', free: 'Free', buy: 'Unlock · ¥{price}', buyNow: 'Buy Now', buying: 'Processing...', purchased: 'Owned', locked: 'Premium', loginRequired: 'Login to purchase', purchaseSuccess: 'Purchased! You can now use this skill', unlockToUse: 'Unlock to use', tryForFree: 'Try Free Skills', orUpgrade: 'Browse more premium skills', skillMarketplace: 'Skill Marketplace' },
marketplace: { title: 'Skill Marketplace', desc: 'An open library of AI skills — premium workflows for your use, free of charge', free: 'Free', freeNote: 'All skills are free. Your support keeps the platform updated', buy: 'Unlock · ¥{price}', buyNow: 'Buy Now', buying: 'Processing...', purchased: 'Owned', locked: 'Premium', loginRequired: 'Login to purchase', purchaseSuccess: 'Purchased! You can now use this skill', unlockToUse: 'Unlock to use', tryForFree: 'Try Free Skills', orUpgrade: 'Browse more premium skills', skillMarketplace: 'Skill Marketplace' },
promptWorkshop: { title: 'Prompt Workshop', desc: 'Write, test, and optimize your prompts', editor: 'Prompt Editor', test: 'Test Prompt', testing: 'Testing...', clear: 'Clear', saveToLibrary: 'Save to Library', saveSuccess: 'Saved successfully!', variables: 'Variables', role: 'Role', task: 'Task', outputFormat: 'Output Format', constraints: 'Constraints', insert: 'Insert', testResult: 'Test Result', saveDialogTitle: 'Save Prompt', saveTitle: 'Title *', saveDesc: 'Description', saveTags: 'Tags', saveTagsPlaceholder: 'e.g. programming,Python,debug', saving: 'Saving...' },
practices: { title: 'AI Practice', desc: 'Practice AI skills with real-world scenarios and get instant scoring feedback', submit: 'Submit Answer', submitting: 'Scoring...', score: 'Score', feedback: 'Feedback', criteria: 'Criteria', duration: 'Duration', seconds: 's', startPractice: 'Start Practice', yourAnswer: 'Your Answer', answerPlaceholder: 'Enter your answer here...', hint: 'Hint', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', categories: { general: 'General', coding: 'Coding', writing: 'Writing', analysis: 'Data Analysis', customer: 'Customer Service', creative: 'Creative' }, loginRequired: 'Login to submit practice', alreadySubmitted: 'View Score', viewSubmission: 'View Score', submissions: 'My Submissions', noSubmissions: 'No submissions yet', goPractice: 'Go Practice', scoreRange: '{score}/{max}', retry: 'Try Again', status: { SUBMITTED: 'Pending', SCORED: 'Scored' } },
packages: { title: 'Quota Packs', desc: 'Purchase extra AI sandbox usage', buy: 'Buy Now', popular: 'Popular', save: 'Save {amount}', quota: '{n} uses', price: '¥{price}', buying: 'Buying...', package10: '10-Use Pack', package10Desc: 'For occasional use', package50: '50-Use Standard Pack', package50Desc: '¥0.20/use, best value', package300: '300-Use Premium Pack', package300Desc: '¥0.17/use, for heavy users', currentQuota: 'Current Quota', dailyQuota: 'Used {used}/{limit} today', extraQuota: 'Extra uses remaining: {n}', quotaExhausted: 'Free quota exhausted', buyMore: 'Buy More', purchaseSuccess: 'Purchase successful! Added {n} uses', purchaseFailed: 'Purchase failed, please retry', orUpgrade: 'Or upgrade to membership for unlimited use' },
donate: { title: 'Donate', desc: 'Yuzhiran stays free and open. Your support keeps us updating.', supportTitle: 'Buy us a coffee to keep us going', supportDesc: 'All skills, practices and tool guides are free. If they help you, feel free to leave a voluntary tip — any amount is welcome.', qrNote: 'Scan the personal QR code below to tip voluntarily', alipay: 'Alipay', wechat: 'WeChat', showQr: 'Show QR', hideQr: 'Hide', noQr: 'QR codes are being set up. You can also leave a message below.', thanksWall: 'Wall of Thanks', thanksWallDesc: 'Thanks to these friends for their generous support', leaveMessage: 'Leave your support', namePlaceholder: 'Nickname (optional)', messagePlaceholder: 'Say something (optional)', channel: 'Channel', submit: 'Send Thanks', submitting: 'Sending...', submitSuccess: 'Thank you for your support!', submitFailed: 'Failed to submit, please retry', says: 'says', anonymous: 'Anonymous friend', disclaimer: 'Tips are voluntary gifts. The platform provides no goods or services in return and charges no operating fees.', via: 'via {channel}', toDonate: 'Donate', supportUs: 'Support Us', affiliate: 'Affiliate' },
enterprise: { title: 'Enterprise', nav: 'Enterprise', desc: 'Secure, controlled AI training for your team', heroTitle: 'Let Your Team Use AI Safely', heroDesc: '75% of employees use AI tools without IT approval. Enterprise edition provides data-isolated AI sandbox environment for your team to learn, practice, and apply AI securely.', heroCta: 'Start Free Trial', heroCtaSub: 'No credit card required', problemTitle: 'Shadow AI Is Threatening Your Data Security', problemDesc: 'More employees are using public AI tools at work, putting company data at unprecedented risk.', statShadowAi: '75%', statShadowAiDesc: 'Employees use unauthorized AI tools', statDataLeak: '48%', statDataLeakDesc: 'Employees paste company data into public AI', statIpLeak: '43%', statIpLeakDesc: 'Companies experienced IP leakage', statTrainingGap: '68%', statTrainingGapDesc: 'Teachers received no AI training', solutionTitle: 'Enterprise Solutions', solutionDesc: 'Data-isolated AI training environment for teams', featureIsolation: 'Data Isolation', featureIsolationDesc: 'All conversations stored in dedicated instance, never used for training, never leaked to public', featureManage: 'Team Management', featureManageDesc: 'Invite members with one click, manage permissions, view team usage', featureAnalytics: 'Learning Analytics', featureAnalyticsDesc: 'Track team AI skill mastery, identify weak areas, improve targeted', featureSandbox: 'Private Sandbox', featureSandboxDesc: 'Practice in secure isolated AI sandbox, supports major models', featureReport: 'Usage Reports', featureReportDesc: 'Auto-generated team AI usage reports with analytics and recommendations', featureCustom: 'Custom Training', featureCustomDesc: 'Customize practice content for your business scenarios', pricingTitle: 'Flexible Pricing', pricingDesc: 'Choose the right plan for your team', pricingFree: 'Free', pricingFreePrice: '¥0', pricingFreeDesc: 'Personal AI learning experience', pricingFreeFeature1: '10 sandbox uses/day', pricingFreeFeature2: '1 model', pricingFreeFeature3: 'Basic exercises', pricingBiz: 'Enterprise', pricingBizPrice: '¥199', pricingBizPerUser: '/user/year', pricingBizDesc: 'Team AI skill development', pricingBizFeature1: 'Unlimited sandbox', pricingBizFeature2: 'All models', pricingBizFeature3: 'Data isolation', pricingBizFeature4: 'Team management', pricingBizFeature5: 'Analytics reports', pricingBizFeature6: 'Dedicated support', pricingCta: 'Contact Sales', pricingCtaFree: 'Start Free', faqTitle: 'FAQ', faq1q: 'What\'s the difference between Enterprise and Free?', faq1a: 'Enterprise provides data-isolated AI environment, team management dashboard, learning analytics reports, and priority support. All conversation data is isolated from model training.', faq2q: 'How do I invite team members?', faq2a: 'After creating your organization, invite members by email or user ID. Members get access to your enterprise sandbox and learning resources.', faq3q: 'How is data security ensured?', faq3a: 'Enterprise data is stored in isolated database instances, separate from public version. AI conversation data is never used for model training.', faq4q: 'Which AI models are supported?', faq4a: 'Enterprise supports all major AI models including GPT-4, Claude, DeepSeek, and more. Team members can freely switch between models.', faq5q: 'Can practice content be customized?', faq5a: 'Yes. Enterprise supports custom practice questions and scoring criteria based on your business scenarios.', dashboard: 'Dashboard', myTeam: 'My Team', createOrg: 'Create Team', orgName: 'Team Name', orgDesc: 'Team Description', members: 'Members', memberCount: '{n} members', inviteMember: 'Invite Member', inviteById: 'Invite by User ID', memberId: 'User ID', invite: 'Invite', removeMember: 'Remove', removeConfirm: 'Remove this member?', usageReport: 'Usage Report', totalMembers: 'Total Members', totalUsage: 'Total Usage', completionRate: 'Completion Rate', noOrg: 'No team yet', createOrgHint: 'Create a team to manage members and AI learning', noMembers: 'No members yet', inviteHint: 'Invite members to join your team', learnMore: 'Learn More', selectOrg: 'Select a team to view details' },
assistant: { title: 'AI Assistant', greeting: 'Hi! I can help you explore and use this site. Try asking:', placeholder: 'Ask me anything...', error: 'Error: {message}', loginPrompt: '📝 Log in to unlock the full AI experience.\n\nClick "Login" or "Register" in the top right corner.' },
}
+4 -3
View File
@@ -1,6 +1,6 @@
const zh = {
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', more: '更多' },
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', affiliate: '联盟返佣', donate: '赞助', more: '更多' },
home: { badge: 'AI 工具指南与实战练习', heroHighlight: 'AI 工具指南', heroRest: '实战练习 · 技能提升', desc: '收录优质 AI 工具,提供真实场景练习,助你快速掌握 AI 技能', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: 'AI 工具精选', featureGuideDesc: '精心收录优质 AI 工具,帮你快速找到合适的工具', featureSandbox: 'AI 沙盒练习', featureSandboxDesc: '在沙盒中实践 AI 工具使用技巧', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', featuredToolsTitle: '精选 AI 工具', featuredToolsDesc: '精心挑选的优质 AI 工具推荐', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', username: '用户名', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '用户名 / 手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', usernameOptional: '用户名(选填,2-20位)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功', usernameConflict: '用户名已被注册', phoneConflict: '手机号已被注册', emailConflict: '邮箱已被注册' },
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', recentLearning: '最近学习', modelLabel: '模型: {model}', viewCount: '{n} 次浏览', likeCount: '{n} 个赞', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
@@ -24,16 +24,17 @@ const zh = {
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', sceneTutor: '编程导师', 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: '登录后可获得更多免费使用次数' },
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: '今日免费次数已用完', quotaExhaustedHint: '今日免费次数已用完,欢迎赞助支持我们持续运营', upgradeNow: '升级会员', quotaUpgradeHint: '升级会员可获得更多使用次数和全部模型', quotaLoginHint: '登录后可获得更多免费使用次数' },
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: '开通月卡 ¥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: '当前方案', payMethod: '支付方式', wechatPay: '微信支付', alipay: '支付宝支付', scanQrCode: '请使用微信扫描二维码', alipayRedirect: '正在跳转到支付宝...', openAlipay: '打开支付宝', payFailed: '支付失败,请重试', orderPaid: '支付成功!', cancelPay: '取消支付', waitingPay: '等待支付中...' },
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: '控制台输出' },
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' } },
marketplace: { title: '技能广场', desc: '一次性购买,终身使用 — 为你的工作流定制的 AI 技能', free: '免费', buy: '解锁 · ¥{price}', buyNow: '立即购买', buying: '购买中...', purchased: '已拥有', locked: '付费', loginRequired: '请登录后购买', purchaseSuccess: '购买成功!现在可以使用此技能了', unlockToUse: '解锁使用', tryForFree: '试试免费技能', orUpgrade: '浏览更多付费技能', skillMarketplace: '技能广场' },
marketplace: { title: '技能广场', desc: '全部开放的 AI 技能库 — 为你的工作流定制的 AI 技能,免费使用', free: '免费', freeNote: '全部技能免费开放,你的支持让平台持续更新', buy: '解锁 · ¥{price}', buyNow: '立即购买', buying: '购买中...', purchased: '已拥有', locked: '付费', loginRequired: '请登录后购买', purchaseSuccess: '购买成功!现在可以使用此技能了', unlockToUse: '解锁使用', tryForFree: '试试免费技能', orUpgrade: '浏览更多付费技能', skillMarketplace: '技能广场' },
promptWorkshop: { title: '提示词工坊', desc: '编写、测试、优化你的提示词', editor: '提示词编辑', test: '测试提示词', testing: '测试中...', clear: '清空', saveToLibrary: '保存到提示词库', saveSuccess: '保存成功!', variables: '变量设置', role: '角色', task: '任务', outputFormat: '输出格式', constraints: '约束条件', insert: '插入', testResult: '测试结果', saveDialogTitle: '保存提示词', saveTitle: '标题 *', saveDesc: '描述', saveTags: '标签', saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试', saving: '保存中...' },
practices: { title: 'AI 练习', desc: '通过实战场景练习 AI 技能,获得即时评分反馈', submit: '提交答案', submitting: '评分中...', score: '评分', feedback: '反馈', criteria: '评分项', duration: '用时', seconds: '秒', startPractice: '开始练习', yourAnswer: '你的回答', answerPlaceholder: '在此输入你的回答...', hint: '提示', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', categories: { general: '通用', coding: '编程', writing: '写作', analysis: '数据分析', customer: '客服', creative: '创意' }, loginRequired: '请登录后提交练习', alreadySubmitted: '已提交,查看评分', viewSubmission: '查看评分', submissions: '我的提交', noSubmissions: '还没有提交记录', goPractice: '去练习', scoreRange: '{score}/{max} 分', retry: '重新练习', status: { SUBMITTED: '待评分', SCORED: '已评分' } },
packages: { title: '用量包', desc: '购买额外 AI 沙盒使用次数', buy: '立即购买', popular: '最受欢迎', save: '省 {amount}', quota: '{n} 次', price: '¥{price}', buying: '购买中...', package10: '10 次体验包', package10Desc: '适合偶尔使用', package50: '50 次标准包', package50Desc: '¥0.20/次,性价比之选', package300: '300 次畅享包', package300Desc: '¥0.17/次,高频用户首选', currentQuota: '当前用量', dailyQuota: '今日已用 {used}/{limit} 次', extraQuota: '剩余额外次数:{n} 次', quotaExhausted: '免费次数已用完', buyMore: '购买额外次数', purchaseSuccess: '购买成功!已增加 {n} 次使用次数', purchaseFailed: '购买失败,请重试', orUpgrade: '或升级会员获取不限次使用' },
donate: { title: '赞助我们', desc: '宇之然坚持免费、开放,你的支持是我们持续更新的动力', supportTitle: '用一杯咖啡,支持我们持续更新', supportDesc: '本平台所有技能、练习、工具指南均免费开放。若它对你有帮助,欢迎自愿打赏表达支持,金额不限。', qrNote: '扫描下方个人收款码,自愿打赏', alipay: '支付宝', wechat: '微信', showQr: '查看收款码', hideQr: '收起', noQr: '收款码配置中,你也可以直接在下方留言表达支持', thanksWall: '感谢墙', thanksWallDesc: '感谢以下朋友的慷慨支持', leaveMessage: '留下你的支持', namePlaceholder: '昵称(选填)', messagePlaceholder: '说点什么吧(选填)', channel: '打赏渠道', submit: '提交感谢', submitting: '提交中...', submitSuccess: '感谢你的支持!', submitFailed: '提交失败,请重试', says: '说', anonymous: '匿名好友', disclaimer: '打赏为自愿赠与行为,平台不提供任何商品或服务对价,不构成经营性收费。', via: '通过{channel}', toDonate: '去赞助', supportUs: '赞助我们', affiliate: '联盟返佣' },
enterprise: { title: '企业版', nav: '企业版', desc: '为团队提供安全、可控的 AI 培训环境', heroTitle: '让团队安全地用好 AI', heroDesc: '75% 的员工在未经IT批准的情况下使用 AI 工具。企业版提供数据隔离的 AI 沙盒环境,让团队在安全可控的范围内学习、练习和应用 AI。', heroCta: '免费试用', heroCtaSub: '无需信用卡', problemTitle: 'Shadow AI 正在威胁你的数据安全', problemDesc: '越来越多的员工在工作中自行使用公共 AI 工具,企业数据面临前所未有的泄漏风险。', statShadowAi: '75%', statShadowAiDesc: '员工使用未经IT批准的AI工具', statDataLeak: '48%', statDataLeakDesc: '员工将公司数据输入公共AI', statIpLeak: '43%', statIpLeakDesc: '企业发生过IP泄漏事件', statTrainingGap: '68%', statTrainingGapDesc: '城市教师未接受AI培训', solutionTitle: '企业版解决方案', solutionDesc: '数据隔离的 AI 培训环境,让团队安全地掌握 AI 技能', featureIsolation: '数据隔离', featureIsolationDesc: '所有对话数据存储在企业专属实例中,不会用于模型训练,也不会泄漏到公共网络', featureManage: '团队管理', featureManageDesc: '一键邀请团队成员,分级管理权限,查看团队使用情况', featureAnalytics: '学习分析', featureAnalyticsDesc: '跟踪团队 AI 技能掌握度,识别薄弱环节,针对性提升', featureSandbox: '专属沙盒', featureSandboxDesc: '成员可在安全隔离的 AI 沙盒中练习,支持主流大模型', featureReport: '使用报告', featureReportDesc: '自动生成团队 AI 使用报告,包含使用量、技能分布和改进建议', featureCustom: '定制培训', featureCustomDesc: '根据业务场景定制练习内容,让 AI 培训与工作直接挂钩', pricingTitle: '灵活定价', pricingDesc: '按团队规模灵活选择', pricingFree: '免费版', pricingFreePrice: '¥0', pricingFreeDesc: '个人体验 AI 学习', pricingFreeFeature1: '10 次/日沙盒使用', pricingFreeFeature2: '1 个模型', pricingFreeFeature3: '基础练习', pricingBiz: '企业版', pricingBizPrice: '¥199', pricingBizPerUser: '/人/年', pricingBizDesc: '团队 AI 技能提升', pricingBizFeature1: '不限次沙盒使用', pricingBizFeature2: '全部模型', pricingBizFeature3: '数据隔离', pricingBizFeature4: '团队管理后台', pricingBizFeature5: '学习分析报告', pricingBizFeature6: '专属客户成功', pricingCta: '联系销售', pricingCtaFree: '免费开始', faqTitle: '常见问题', faq1q: '企业版与免费版有什么区别?', faq1a: '企业版提供数据隔离的专属 AI 环境、团队管理后台、学习分析报告和优先技术支持。所有对话数据不会用于模型训练,确保企业信息安全。', faq2q: '如何邀请团队成员?', faq2a: '创建企业组织后,可以通过邮箱或用户ID邀请成员加入。成员加入后即可使用企业专属的 AI 沙盒和学习资源。', faq3q: '数据安全如何保障?', faq3a: '企业版所有数据存储在独立的数据库实例中,不会与公共版共享。AI 对话数据不会被用于模型训练或改进,确保企业数据隐私。', faq4q: '支持哪些 AI 模型?', faq4a: '企业版支持全部主流 AI 模型,包括 GPT-4、Claude、DeepSeek 等,团队成员可以在沙盒中自由切换对比。', faq5q: '可以按需定制练习内容吗?', faq5a: '可以。企业版支持根据业务场景定制练习题目和评分标准,让 AI 培训与工作实际需求紧密结合。', dashboard: '管理后台', myTeam: '我的团队', createOrg: '创建团队', orgName: '团队名称', orgDesc: '团队描述', members: '成员管理', memberCount: '{n} 人', inviteMember: '邀请成员', inviteById: '用户ID邀请', memberId: '用户ID', invite: '邀请', removeMember: '移除', removeConfirm: '确认移除该成员?', usageReport: '使用报告', totalMembers: '总成员', totalUsage: '总使用次数', completionRate: '完成率', noOrg: '还没有创建团队', createOrgHint: '创建一个团队,开始管理成员和 AI 学习', noMembers: '暂无成员', inviteHint: '邀请成员加入你的团队', learnMore: '了解更多', selectOrg: '选择一个团队查看详情' },
assistant: { title: 'AI 助手', greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:', placeholder: '输入你的问题...', error: '出错啦:{message}', loginPrompt: '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。' },
}