P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善

- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
yuzhiran-dev
2026-06-18 18:14:07 +08:00
parent fb8152401b
commit 0f215d2aad
103 changed files with 5240 additions and 778 deletions
+84 -12
View File
@@ -1,12 +1,12 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Search, Menu, X, Bell, Languages } from 'lucide-react';
import { Search, Menu, X, Bell, Languages, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import { useLang, useT } from '@/i18n';
@@ -53,18 +53,35 @@ export function Header() {
const { lang, setLang } = useLang();
const t = useT();
const navItems = [
const mainNavItems = [
{ href: '/', label: t.nav.home },
{ href: '/courses', label: t.nav.courses },
{ href: '/marketplace', label: t.nav.marketplace },
{ href: '/sandbox', label: t.nav.sandbox },
{ href: '/skills', label: t.nav.skills },
{ href: '/models', label: t.nav.models },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/contents', label: t.nav.articles },
{ href: '/tools', label: t.nav.tools },
{ href: '/community', label: t.nav.community },
{ href: '/practices', label: t.nav.practices },
// { href: '/community', label: t.nav.community },
// { href: '/enterprise', label: t.nav.enterprise },
];
const secondaryNavItems = [
{ href: '/courses', label: t.nav.courses },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/models', label: t.nav.models },
{ href: '/tools', label: t.nav.tools },
{ href: '/contents', label: t.nav.articles },
];
const [moreOpen, setMoreOpen] = useState(false);
const moreTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function openMore() {
if (moreTimer.current) clearTimeout(moreTimer.current);
setMoreOpen(true);
}
function closeMore() {
moreTimer.current = setTimeout(() => setMoreOpen(false), 200);
}
function isActive(href: string) {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
@@ -92,7 +109,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -105,6 +122,46 @@ export function Header() {
{item.label}
</Link>
))}
<div
className="relative"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
<button
className={`flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
moreOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{t.nav.more}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${moreOpen ? 'rotate-180' : ''}`} />
</button>
<div
className="absolute right-0 top-full z-50 pt-1"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
{moreOpen && (
<div className="w-40 bg-popover border border-border rounded-lg shadow-lg py-1 animate-fade-in">
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 text-sm transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMoreOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
</div>
</nav>
<div className="hidden md:flex items-center gap-2">
@@ -170,7 +227,22 @@ export function Header() {
{mobileOpen && (
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block py-2.5 px-2 text-sm rounded-lg transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
<div className="border-t border-border my-2 mx-2" />
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -1,7 +1,7 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
import { type ThemeProviderProps } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
+5 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useRef, useState } from 'react'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
@@ -48,7 +49,9 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
} catch (err) {
console.error('Copy failed:', err)
}
}
return (
@@ -61,7 +64,7 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
</button>
</div>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed bg-[#1e1e1e]">
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: highlighted }} />
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(highlighted) }} />
</pre>
</div>
)
+1 -1
View File
@@ -44,7 +44,7 @@ export default function PaymentModal({ open, orderNo, payResult, payChannel, onP
if (payChannel === 'alipay') {
setMessage(t.member.alipayRedirect);
if (redirectUrl && redirectUrl !== 'mock://pay') {
if (redirectUrl) {
window.open(redirectUrl, '_blank');
}
startPolling();