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
+2 -2
View File
@@ -59,8 +59,8 @@ export function getAssistantContext(pathname: string): AssistantContext {
const sorted = Object.keys(PAGE_CONTEXTS).sort((a, b) => b.length - a.length)
for (const key of sorted) {
if (pathname.startsWith(key)) {
return PAGE_CONTEXTS[key]
return { ...PAGE_CONTEXTS[key], page: key }
}
}
return PAGE_CONTEXTS['/']
return { ...PAGE_CONTEXTS['/'], page: '/' }
}
+5 -10
View File
@@ -1,6 +1,7 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { setTokens, clearTokens, initAuth, isLoggedIn, apiLogout, getToken } from './auth';
interface AuthContextType {
isLoggedIn: boolean;
@@ -17,28 +18,22 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
setIsLoggedIn(!!localStorage.getItem('token'));
setInitialized(true);
initAuth().then(() => setInitialized(true));
}, []);
const login = useCallback((token: string, refreshTk?: string) => {
localStorage.setItem('token', token);
if (refreshTk) localStorage.setItem('refreshToken', refreshTk);
setIsLoggedIn(true);
setTokens(token, refreshTk);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
setIsLoggedIn(false);
apiLogout();
}, []);
return (
<AuthContext.Provider value={{ isLoggedIn, initialized, login, logout }}>
<AuthContext.Provider value={{ isLoggedIn: isLoggedIn() || !!getToken(), initialized, login, logout }}>
{children}
</AuthContext.Provider>
);
+52 -15
View File
@@ -1,40 +1,69 @@
import { API_BASE } from '@/lib/config';
let memoryToken: string | null = null;
let memoryRefreshToken: string | null = null;
let memoryAdminToken: string | null = null;
let initPromise: Promise<void> | null = null;
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
return memoryToken;
}
export function getAdminToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('adminToken');
return memoryAdminToken;
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refreshToken');
return memoryRefreshToken;
}
export function setTokens(accessToken: string, refreshToken?: string) {
localStorage.setItem('token', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
memoryToken = accessToken;
if (refreshToken) memoryRefreshToken = refreshToken;
}
export function clearTokens() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
memoryToken = null;
memoryRefreshToken = null;
}
export function setAdminToken(token: string) {
memoryAdminToken = token;
}
export function clearAdminToken() {
localStorage.removeItem('adminToken');
memoryAdminToken = null;
}
export function isLoggedIn(): boolean {
return !!getToken();
return !!memoryToken;
}
export function isAdminLoggedIn(): boolean {
return !!getAdminToken();
return !!memoryAdminToken;
}
export async function initAuth(): Promise<void> {
if (initPromise) return initPromise;
if (typeof window === 'undefined') return;
if (memoryToken) return;
initPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/auth/verify`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.accessToken) memoryToken = data.accessToken;
return;
}
} catch {
}
})();
await initPromise;
initPromise = null;
}
export async function adminApiFetch(url: string, opts: RequestInit = {}): Promise<Response> {
@@ -57,6 +86,7 @@ export async function refreshToken(): Promise<string | null> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: token }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
@@ -76,15 +106,22 @@ export async function apiFetch(url: string, opts: RequestInit = {}): Promise<Res
};
if (token) headers['Authorization'] = `Bearer ${token}`;
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
if (res.status === 401 && token) {
const newToken = await refreshToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
}
}
return res;
}
export async function apiLogout(): Promise<void> {
try {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
} catch {}
clearTokens();
}
+9 -1
View File
@@ -1,4 +1,12 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
export const API_BASE = (() => {
const configured = process.env.NEXT_PUBLIC_API_URL;
// During SSG/build in Node.js, relative paths like /api/v1 won't resolve
// Use the local backend directly (assumes backend runs on port 4000 during build)
if (typeof window === 'undefined' && configured?.startsWith('/')) {
return 'http://localhost:4000/api/v1';
}
return configured || '/api/v1';
})();
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://yuzhiran.com';