Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6472
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@types/three": "^0.183.1",
"antd": "^6.3.1",
"axios": "^1.13.6",
"gsap": "^3.14.2",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^17.0.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+209
View File
@@ -0,0 +1,209 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { ConfigProvider, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import AppLayout from './components/Layout';
import DistrictList from './pages/DistrictList';
import DistrictSchools from './pages/DistrictSchools';
import Era2ReportPreview from './pages/Era2ReportPreview';
import History from './pages/History';
import DataTraceView from './components/DataTrace/DataTraceView';
import Login from './pages/Login';
import { verifyToken, getToken, clearAuth, logout as doLogout } from './services/auth';
// ==================== Auth Context ====================
interface AuthContextType {
isAuthenticated: boolean;
username: string;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
username: '',
logout: async () => {},
});
export const useAuth = () => useContext(AuthContext);
// ==================== 路由守卫 ====================
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
// ==================== App ====================
function AppContent() {
const [authState, setAuthState] = useState<{
checking: boolean;
isAuthenticated: boolean;
username: string;
}>({ checking: true, isAuthenticated: false, username: '' });
// 检查认证状态
useEffect(() => {
const checkAuth = async () => {
const token = getToken();
if (!token) {
setAuthState({ checking: false, isAuthenticated: false, username: '' });
return;
}
try {
const valid = await verifyToken();
if (valid) {
// 从 verify 接口获取用户名
const res = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setAuthState({
checking: false,
isAuthenticated: true,
username: data.username || 'admin',
});
} else {
clearAuth();
setAuthState({ checking: false, isAuthenticated: false, username: '' });
}
} catch {
clearAuth();
setAuthState({ checking: false, isAuthenticated: false, username: '' });
}
};
checkAuth();
}, []);
// 监听 localStorage 变化(登录成功后更新状态)
useEffect(() => {
const handleStorage = () => {
const token = getToken();
if (token && !authState.isAuthenticated) {
setAuthState(prev => ({ ...prev, checking: true }));
verifyToken().then(valid => {
setAuthState({
checking: false,
isAuthenticated: valid,
username: valid ? 'admin' : '',
});
});
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, [authState.isAuthenticated]);
// 登录成功后由 Login 页面 navigate 到 /,此时需要重新检测
const location = useLocation();
useEffect(() => {
if (location.pathname !== '/login') {
const token = getToken();
if (token && !authState.isAuthenticated && !authState.checking) {
setAuthState(prev => ({ ...prev, checking: true }));
verifyToken().then(valid => {
setAuthState({
checking: false,
isAuthenticated: valid,
username: valid ? 'admin' : '',
});
});
}
}
}, [location.pathname]);
const handleLogout = useCallback(async () => {
await doLogout();
setAuthState({ checking: false, isAuthenticated: false, username: '' });
}, []);
if (authState.checking) {
return <AuthCheckingSpinner />;
}
return (
<AuthContext.Provider value={{
isAuthenticated: authState.isAuthenticated,
username: authState.username,
logout: handleLogout,
}}>
<Routes>
{/* 登录页 — 公开 */}
<Route path="/login" element={
authState.isAuthenticated
? <Navigate to="/" replace />
: <Login />
} />
{/* 受保护的路由 */}
<Route element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}>
<Route path="/" element={<DistrictList />} />
<Route path="/district/:district" element={<DistrictSchools />} />
<Route path="/district/:district/report/:school" element={<Era2ReportPreview />} />
<Route path="/history" element={<History />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
{/* 数据溯源 — 也需要认证 */}
<Route path="/district/:district/trace/:school" element={
<RequireAuth>
<DataTraceView />
</RequireAuth>
} />
</Routes>
</AuthContext.Provider>
);
}
function AuthCheckingSpinner() {
const { t } = useTranslation();
return (
<div style={{
height: '100vh', display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: '#f0f2f5',
}}>
<Spin size="large" tip={t('common.verifying_login')} />
</div>
);
}
function AppWithLocale() {
const { i18n } = useTranslation();
const antdLocale = i18n.language?.startsWith('en') ? enUS : zhCN;
return (
<ConfigProvider
locale={antdLocale}
theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 8,
},
}}
>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</ConfigProvider>
);
}
function App() {
return <AppWithLocale />;
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+459
View File
@@ -0,0 +1,459 @@
/**
* ChatFab — 右下角浮动AI助理组件
* 自动绑定当前报告的区+学校上下文,无需手动选择
* - SSE 流式输出(带 buffer 处理跨 chunk 断行)
* - Markdown 渲染(react-markdown
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Input, Button, Spin, Badge } from 'antd';
import {
RobotOutlined, SendOutlined, UserOutlined,
CloseOutlined, DeleteOutlined,
} from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { sendChatMessage } from '../services/era2Api';
const { TextArea } = Input;
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatFabProps {
school: string;
district: string;
/** 报告语言;用于切换 system prompt 和 UI 文案 */
lang?: 'zh' | 'en';
}
const QUICK_PROMPTS_ZH = [
'总体表现如何?',
'最大的短板是什么?',
'最紧迫应该改进什么?',
'与同类学校对比如何?',
];
const QUICK_PROMPTS_EN = [
'How does the school perform overall?',
'What are the most significant gaps?',
'Which improvements are most urgent?',
'How does it compare with peer-type schools?',
];
/* ====== Markdown 气泡样式 ====== */
const markdownBubbleStyle: React.CSSProperties = {
padding: '12px 16px',
borderRadius: '4px 12px 12px 12px',
background: '#fff',
border: '1px solid #e5e7eb',
fontSize: 15,
lineHeight: 1.75,
wordBreak: 'break-word',
overflow: 'hidden',
};
/** 流式请求超时 (ms):2 分钟 — 留足 LLM 生成时间 */
const CHAT_TIMEOUT_MS = 120_000;
export default function ChatFab({ school, district, lang = 'zh' }: ChatFabProps) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<any>(null);
/** 当前请求的 AbortController,用于取消/超时 */
const abortRef = useRef<AbortController | null>(null);
const QUICK_PROMPTS = lang === 'en' ? QUICK_PROMPTS_EN : QUICK_PROMPTS_ZH;
// 学校 / 语言变化时清空对话 & 取消进行中的请求
useEffect(() => {
abortRef.current?.abort();
setMessages([]);
setInput('');
}, [school, district, lang]);
// 组件卸载时取消请求
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
// 自动滚动到底部
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, [messages]);
// 打开时聚焦输入框
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 300);
}
}, [open]);
const handleSend = useCallback(async (text?: string) => {
const msg = text || input.trim();
if (!msg || loading) return;
// 取消上一次未完成的请求
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
// 超时自动中断
const timeoutId = setTimeout(() => controller.abort(), CHAT_TIMEOUT_MS);
const userMsg: ChatMessage = { role: 'user', content: msg };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
const history = messages.map(m => ({ role: m.role, content: m.content }));
try {
const response = await sendChatMessage(
msg, school, district, history, controller.signal, lang,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error('No readable stream');
const decoder = new TextDecoder();
let assistantContent = '';
let sseBuffer = ''; // 缓冲跨 chunk 的不完整 SSE 行
// 先插入一条空的 assistant 消息占位
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
// 按换行分割,最后一段可能不完整,留在 buffer 中
const parts = sseBuffer.split('\n');
sseBuffer = parts.pop() || '';
for (const line of parts) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
assistantContent += `\n⚠️ ${parsed.error}`;
} else if (parsed.content) {
assistantContent += parsed.content;
}
// 实时更新最后一条 assistant 消息
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
return updated;
});
} catch {
/* 跳过非JSON行 */
}
}
}
// 处理 buffer 中剩余的最后一行
if (sseBuffer.trim().startsWith('data: ')) {
const data = sseBuffer.trim().slice(6);
if (data !== '[DONE]') {
try {
const parsed = JSON.parse(data);
if (parsed.content) {
assistantContent += parsed.content;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
return updated;
});
}
} catch { /* ignore */ }
}
}
// 如果最终没有任何内容
if (!assistantContent) {
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: '⚠️ 未收到有效回复,请重试。' };
return updated;
});
}
} catch (err: unknown) {
// 用户主动取消或组件卸载 — 静默处理
if (err instanceof DOMException && err.name === 'AbortError') {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant' && !last.content) {
// 清除空占位
return prev.slice(0, -1);
}
return prev;
});
return;
}
const errorMessage = err instanceof Error ? err.message : String(err);
const failPrefix = lang === 'en' ? '⚠️ Request failed: ' : '⚠️ 请求失败: ';
setMessages(prev => {
const last = prev[prev.length - 1];
// 如果已有占位 assistant 消息,更新它;否则追加
if (last?.role === 'assistant' && !last.content) {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: `${failPrefix}${errorMessage}`,
};
return updated;
}
return [...prev, { role: 'assistant', content: `${failPrefix}${errorMessage}` }];
});
} finally {
clearTimeout(timeoutId);
setLoading(false);
}
}, [input, loading, messages, school, district, lang]);
/* ====== 渲染单条消息内容 ====== */
const renderContent = (msg: ChatMessage, idx: number) => {
if (!msg.content && loading && idx === messages.length - 1) {
return <Spin size="small" />;
}
// 用户消息:纯文本
if (msg.role === 'user') {
return msg.content;
}
// AI消息:Markdown 渲染
return <ReactMarkdown>{msg.content}</ReactMarkdown>;
};
return (
<>
{/* FAB 按钮 */}
<div
onClick={() => setOpen(v => !v)}
style={{
position: 'fixed', bottom: 32, right: 32, zIndex: 1000,
width: 56, height: 56, borderRadius: '50%',
background: 'linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 4px 20px rgba(37,99,235,0.4)',
transition: 'transform 0.2s, box-shadow 0.2s',
fontSize: 24,
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.08)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 6px 28px rgba(37,99,235,0.55)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 20px rgba(37,99,235,0.4)';
}}
title={lang === 'en' ? 'AI Report Assistant' : 'AI 报告助手'}
>
<Badge count={messages.length > 0 && !open ? messages.length : 0} size="small" offset={[-2, 2]}>
<RobotOutlined style={{ fontSize: 26, color: '#fff' }} />
</Badge>
</div>
{/* 对话面板 */}
{open && (
<div style={{
position: 'fixed', bottom: 100, right: 32, zIndex: 1001,
width: 'min(560px, calc(100vw - 64px))',
height: 'min(780px, calc(100vh - 140px))',
background: '#fff', borderRadius: 16,
boxShadow: '0 12px 48px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
animation: 'chatSlideUp 0.3s ease',
}}>
{/* 头部 */}
<div style={{
background: 'linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)',
color: '#fff', padding: '14px 20px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18,
}}>🤖</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>{lang === 'en' ? 'AI Report Assistant' : 'AI 报告助手'}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>{district} · {school}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Button type="text" size="small" icon={<DeleteOutlined />}
style={{ color: 'rgba(255,255,255,0.8)' }}
onClick={() => setMessages([])} title={lang === 'en' ? 'Clear conversation' : '清空对话'} />
<Button type="text" size="small" icon={<CloseOutlined />}
style={{ color: 'rgba(255,255,255,0.8)' }}
onClick={() => setOpen(false)} title={lang === 'en' ? 'Close' : '关闭'} />
</div>
</div>
{/* 消息区 */}
<div ref={messagesRef} style={{
flex: 1, overflowY: 'auto', padding: 16,
display: 'flex', flexDirection: 'column', gap: 12,
minHeight: 200, background: '#f8fafc',
}}>
{/* 欢迎消息 */}
{messages.length === 0 && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
background: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
color: '#1e40af', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14,
}}>🤖</div>
<div style={markdownBubbleStyle}>
{lang === 'en' ? (
<>
<p style={{ margin: '2px 0' }}>Hello! I have full access to the curriculum-implementation monitoring data for <strong>{school}</strong>.</p>
<p style={{ margin: '2px 0' }}>Ask any question directly, or pick a quick prompt below to begin.</p>
</>
) : (
<>
<p style={{ margin: '2px 0' }}> <strong>{school}</strong> </p>
<p style={{ margin: '2px 0' }}></p>
</>
)}
</div>
</div>
)}
{messages.map((msg, idx) => (
<div key={idx} style={{
display: 'flex', gap: 8, maxWidth: '88%',
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
background: msg.role === 'user'
? 'linear-gradient(135deg, #d1fae5, #a7f3d0)'
: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
color: msg.role === 'user' ? '#065f46' : '#1e40af',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14,
}}>
{msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
</div>
<div
className={msg.role === 'assistant' ? 'chat-md-bubble' : undefined}
style={{
padding: '12px 16px',
borderRadius: msg.role === 'user' ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
background: msg.role === 'user'
? 'linear-gradient(135deg, #2563eb, #3b82f6)'
: '#fff',
color: msg.role === 'user' ? '#fff' : '#1f2937',
border: msg.role === 'assistant' ? '1px solid #e5e7eb' : 'none',
fontSize: 15, lineHeight: 1.75, wordBreak: 'break-word',
overflow: 'hidden',
}}
>
{renderContent(msg, idx)}
</div>
</div>
))}
<div ref={chatEndRef} />
</div>
{/* 快捷问题 */}
{messages.length === 0 && (
<div style={{
padding: '8px 16px', display: 'flex', flexWrap: 'wrap', gap: 6,
background: '#f8fafc', borderTop: '1px solid #f1f5f9', flexShrink: 0,
}}>
{QUICK_PROMPTS.map(p => (
<Button key={p} size="small" onClick={() => handleSend(p)}
style={{ borderRadius: 16, fontSize: 13, height: 28 }}>{p}</Button>
))}
</div>
)}
{/* 输入区 */}
<div style={{
padding: '10px 14px', borderTop: '1px solid #e5e7eb',
display: 'flex', gap: 8, alignItems: 'flex-end', background: '#fff',
flexShrink: 0,
}}>
<TextArea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
placeholder={lang === 'en' ? 'Type your question...' : '输入问题...'}
autoSize={{ minRows: 1, maxRows: 3 }}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
disabled={loading}
style={{ flex: 1, borderRadius: 10, fontSize: 14 }}
/>
<Button type="primary" icon={<SendOutlined />}
onClick={() => handleSend()} loading={loading}
style={{ borderRadius: 10, height: 36, width: 36, padding: 0 }} />
</div>
</div>
)}
<style>{`
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media print { [class*="ChatFab"] { display: none !important; } }
/* Markdown 气泡内的排版 */
.chat-md-bubble p { margin: 5px 0; text-indent: 0; }
.chat-md-bubble ul, .chat-md-bubble ol { margin: 5px 0 5px 1.4em; padding: 0; }
.chat-md-bubble li { margin: 3px 0; }
.chat-md-bubble li > p { margin: 0; }
.chat-md-bubble strong { color: #1a56db; }
.chat-md-bubble h1, .chat-md-bubble h2, .chat-md-bubble h3 {
font-size: 16px; font-weight: 700; margin: 10px 0 5px 0;
color: #1e3a5f;
}
.chat-md-bubble table { border-collapse: collapse; margin: 8px 0; font-size: 13.5px; width: 100%; }
.chat-md-bubble th, .chat-md-bubble td {
border: 1px solid #e5e7eb; padding: 4px 8px; text-align: left;
}
.chat-md-bubble th { background: #f1f5f9; font-weight: 600; }
.chat-md-bubble code {
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
font-size: 13.5px; color: #c2410c;
}
.chat-md-bubble blockquote {
border-left: 3px solid #3b82f6; margin: 6px 0; padding: 4px 10px;
background: #eff6ff; color: #1e40af;
}
.chat-md-bubble hr { border: none; border-top: 1px solid #e5e7eb; margin: 8px 0; }
@media (max-width: 768px) {
/* 移动端自适应 — 对话面板全宽 */
}
`}</style>
</>
);
}
@@ -0,0 +1,126 @@
/**
* CameraController — gsap驱动的丝滑相机飞行
*
* 镜头策略:
* 初始 → 对准第一个阶段(x=-35),近景
* 切换阶段 → 相机飞到该阶段上方,拉远到能看清标题(y=22)
* 最后一个阶段(step=5) → 1.5s后自动斜视回全局俯瞰
* 点击节点 → 聚焦飞过去
*/
import { useEffect, useRef, useCallback } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import gsap from 'gsap';
import type { StageIndex } from './types';
import { STAGE_POSITIONS } from './types';
interface Props {
currentStep: StageIndex;
focusTarget?: { x: number; y: number; z: number } | null;
onFocusDone?: () => void;
}
export default function CameraController({ currentStep, focusTarget, onFocusDone }: Props) {
const { camera } = useThree();
const controlsRef = useRef<any>(null);
const tweenRef = useRef<gsap.core.Timeline | null>(null);
const overviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstMount = useRef(true);
useFrame((state) => {
if (!controlsRef.current && (state as any).controls) {
controlsRef.current = (state as any).controls;
}
});
const flyTo = useCallback((
pos: { x: number; y: number; z: number },
lookAt: { x: number; y: number; z: number },
duration = 1.5,
) => {
if (tweenRef.current) {
tweenRef.current.kill();
}
const tl = gsap.timeline();
tweenRef.current = tl;
tl.to(camera.position, {
x: pos.x, y: pos.y, z: pos.z,
duration, ease: 'power2.inOut',
}, 0);
if (controlsRef.current?.target) {
tl.to(controlsRef.current.target, {
x: lookAt.x, y: lookAt.y, z: lookAt.z,
duration, ease: 'power2.inOut',
}, 0);
}
return tl;
}, [camera]);
// 阶段切换 → 相机飞行
useEffect(() => {
// 清除之前的全局俯瞰定时器
if (overviewTimerRef.current) {
clearTimeout(overviewTimerRef.current);
overviewTimerRef.current = null;
}
const centerX = STAGE_POSITIONS[currentStep];
if (isFirstMount.current) {
// ====== 初始:偏左看第一个阶段全貌 ======
isFirstMount.current = false;
camera.position.set(centerX - 18, 22, 55); // 左移18+后移55,看到节点1完整圆盘和文件夹
if (controlsRef.current?.target) {
controlsRef.current.target.set(centerX, 8, 0);
}
return;
}
if (currentStep === 5) {
// ====== 最后阶段:先飞到Stage5,然后2s后斜视全局 ======
flyTo(
{ x: centerX, y: 25, z: 45 },
{ x: centerX, y: 8, z: 0 },
);
overviewTimerRef.current = setTimeout(() => {
// 从偏右上方俯瞰全局(场景x:-35~35,需要足够远才能全部看到)
flyTo(
{ x: 25, y: 35, z: 75 }, // 右移+拉高+拉远,确保6个阶段全部入镜
{ x: 0, y: 6, z: 0 }, // 看向全局中心
2.5,
);
}, 2500);
} else {
// ====== 普通阶段:飞到该阶段,拉远够高以看到标题(y=22) ======
flyTo(
{ x: centerX, y: 25, z: 45 }, // y=25看得到y=22标题, z=45够远
{ x: centerX, y: 8, z: 0 }, // 看向阶段主体中心
);
}
return () => {
if (overviewTimerRef.current) {
clearTimeout(overviewTimerRef.current);
overviewTimerRef.current = null;
}
};
}, [currentStep, flyTo, camera]);
// 点击节点聚焦
useEffect(() => {
if (!focusTarget) return;
flyTo(
{ x: focusTarget.x, y: focusTarget.y + 10, z: focusTarget.z + 22 },
{ x: focusTarget.x, y: focusTarget.y, z: focusTarget.z },
1.0,
).then(() => {
onFocusDone?.();
});
}, [focusTarget, flyTo, onFocusDone]);
return null;
}
@@ -0,0 +1,259 @@
/**
* DataTraceView — 数据溯源可视化主容器
*
* 路由: /district/:district/trace/:school
* 职责: Canvas + 6个阶段 + 粒子 + 管道 + 相机飞行 + 交互 + UI叠加层
*/
import { useState, useCallback, Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Scene from './Scene';
import CameraController from './CameraController';
import Interaction, { type TooltipData } from './Interaction';
import { useTraceData } from './hooks/useTraceData';
import { useStepControl } from './hooks/useStepControl';
import { STAGE_COLORS } from './types';
// Stages
import RawDataStage from './stages/RawDataStage';
import ScoringStage from './stages/ScoringStage';
import PCAStage from './stages/PCAStage';
import StandardStage from './stages/StandardStage';
import LevelStage from './stages/LevelStage';
import DimensionStage from './stages/DimensionStage';
// Effects
import Particles from './effects/Particles';
import Connections from './effects/Connections';
// UI
import StepBar from './ui/StepBar';
import InfoPanel from './ui/InfoPanel';
import Tooltip from './ui/Tooltip';
function LoadingFallback() {
return (
<mesh>
<sphereGeometry args={[1, 16, 16]} />
<meshStandardMaterial color="#4fc3f7" wireframe />
</mesh>
);
}
export default function DataTraceView() {
const { district, school } = useParams<{ district: string; school: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const isEn = i18n.language?.startsWith('en');
const { data, loading, error } = useTraceData(district ?? '', school ?? '');
const {
currentStep, autoPlaying,
goToStep, nextStep, prevStep, reset, toggleAutoPlay,
} = useStepControl();
// 交互状态
const [tooltip, setTooltip] = useState<TooltipData>({ text: '', x: 0, y: 0, visible: false });
const [focusTarget, setFocusTarget] = useState<{ x: number; y: number; z: number } | null>(null);
const handleNodeClick = useCallback((worldPos: { x: number; y: number; z: number }, _userData: any) => {
setFocusTarget(worldPos);
}, []);
const handleFocusDone = useCallback(() => {
setFocusTarget(null);
}, []);
// 加载状态
if (loading || !data) {
return (
<div style={{
width: '100vw', height: '100vh',
background: STAGE_COLORS.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#4fc3f7', fontSize: 18,
}}>
{loading ? (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}></div>
<div>
{isEn
? `Loading computation pipeline for ${school}...`
: `正在加载 ${school} 的计算链路...`}
</div>
</div>
) : error ? (
<div style={{ textAlign: 'center', color: '#ef5350' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}></div>
<div>{isEn ? `Failed to load: ${error}` : `加载失败: ${error}`}</div>
<button
onClick={() => navigate(-1)}
style={{
marginTop: 16, padding: '8px 20px',
background: 'transparent', border: '1px solid #ef5350',
color: '#ef5350', borderRadius: 8, cursor: 'pointer',
}}
>
{t('common.back')}
</button>
</div>
) : null}
</div>
);
}
return (
<div style={{ width: '100vw', height: '100vh', background: STAGE_COLORS.bg, position: 'relative' }}>
{/* 顶部标题栏 */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0,
height: 64, padding: '0 24px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'rgba(10,14,23,0.85)',
borderBottom: '1px solid rgba(79,195,247,0.15)',
zIndex: 100,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<button
onClick={() => navigate(-1)}
style={{
background: 'transparent', border: '1px solid rgba(79,195,247,0.3)',
color: '#4fc3f7', borderRadius: 8, padding: '6px 14px',
cursor: 'pointer', fontSize: 14,
}}
>
{t('trace.back')}
</button>
<span style={{ color: '#ffffff', fontSize: 18, fontWeight: 600 }}>
{t('trace.title')}
</span>
<span style={{ color: '#4fc3f7', fontSize: 16 }}>
{data.school}
</span>
</div>
<div style={{ color: '#667788', fontSize: 13 }}>
{isEn ? 'Score: ' : '总分: '}
<span style={{ color: '#e53935', fontSize: 18, fontWeight: 700 }}>
{data.stages.overall.score?.toFixed(1)}
</span>
<span style={{ marginLeft: 12 }}>
{isEn
? `${data.city_school_count} citywide · ${data.district_school_count} in district`
: `全市${data.city_school_count}校 · 区内${data.district_school_count}`}
</span>
</div>
</div>
{/* Three.js Canvas */}
<Canvas
camera={{
position: [-53, 22, 55], // 初始偏左+后移,看到第一个阶段完整全貌
fov: 50,
near: 0.1,
far: 500,
}}
shadows
gl={{
antialias: true,
toneMapping: 6, // ACESFilmic
toneMappingExposure: 1.2,
}}
style={{ width: '100%', height: '100%' }}
>
<Suspense fallback={<LoadingFallback />}>
<Scene />
{/* 相机飞行控制器 */}
<CameraController
currentStep={currentStep}
focusTarget={focusTarget}
onFocusDone={handleFocusDone}
/>
{/* Raycaster交互 */}
<Interaction
onTooltipChange={setTooltip}
onNodeClick={handleNodeClick}
/>
{/* 6个阶段 */}
<RawDataStage data={data.stages.raw_data} visible={currentStep >= 0} />
<ScoringStage scoring={data.stages.scoring} visible={currentStep >= 1} />
<PCAStage pcaDetails={data.stages.pca_detail} visible={currentStep >= 2} />
<StandardStage standardized={data.stages.standardized} visible={currentStep >= 3} />
<LevelStage levels={data.stages.levels} visible={currentStep >= 4} />
<DimensionStage
dimensions={data.stages.dimensions}
overall={data.stages.overall}
school={data.school}
visible={currentStep >= 5}
/>
{/* 粒子系统(管道流动+汇聚+shader辉光) */}
<Particles currentStep={currentStep} />
{/* CatmullRom管道连接 */}
<Connections currentStep={currentStep} />
</Suspense>
</Canvas>
{/* UI叠加层 */}
<InfoPanel currentStep={currentStep} data={data} />
<Tooltip data={tooltip} />
<StepBar
currentStep={currentStep}
onStepChange={goToStep}
autoPlaying={autoPlaying}
onToggleAutoPlay={toggleAutoPlay}
onReset={reset}
onNext={nextStep}
onPrev={prevStep}
/>
{/* 右上角操作说明 */}
<div style={{
position: 'absolute', top: 76, right: 16,
background: 'rgba(10,14,23,0.75)',
border: '1px solid rgba(79,195,247,0.15)',
borderRadius: 8, padding: '10px 14px',
color: '#667788', fontSize: 12, lineHeight: 1.8,
zIndex: 100, pointerEvents: 'none',
}}>
<div style={{ color: '#8899aa', fontSize: 11, marginBottom: 4, fontWeight: 600 }}>
🖱 {isEn ? 'Mouse' : '鼠标操作'}
</div>
{isEn ? (
<>
<div>Drag (left) Rotate view</div>
<div>Drag (right) Pan</div>
<div>Scroll Zoom</div>
<div>Click node Focus</div>
</>
) : (
<>
<div> </div>
<div> </div>
<div> </div>
<div> </div>
</>
)}
<div style={{ color: '#8899aa', fontSize: 11, marginTop: 6, marginBottom: 4, fontWeight: 600 }}>
{isEn ? 'Shortcuts' : '快捷键'}
</div>
{isEn ? (
<>
<div> Switch stage</div>
<div>Space Next step</div>
<div>R Reset</div>
</>
) : (
<>
<div> </div>
<div> </div>
<div>R </div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
/**
* Interaction.tsx — Raycaster 交互系统
*
* 功能:
* 1. 悬浮高亮(emissiveIntensity 增强 + scale 放大)
* 2. 点击节点 → 通知父组件飞行聚焦
* 3. 暴露tooltip数据给UI层
*
* 策略:不在R3F内部做tooltip DOM,而是通过回调把hover数据传给外层HTML
*/
import { useRef, useCallback } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import gsap from 'gsap';
import * as THREE from 'three';
export interface TooltipData {
text: string;
x: number; // screen px
y: number;
visible: boolean;
}
interface Props {
onTooltipChange: (data: TooltipData) => void;
onNodeClick: (worldPos: { x: number; y: number; z: number }, userData: any) => void;
}
export default function Interaction({ onTooltipChange, onNodeClick }: Props) {
const { camera, scene, gl } = useThree();
const raycaster = useRef(new THREE.Raycaster());
const mouse = useRef(new THREE.Vector2(-999, -999));
const hoveredRef = useRef<THREE.Mesh | null>(null);
const origEmissiveRef = useRef<number>(0.3);
// 鼠标移动
const onPointerMove = useCallback((e: PointerEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}, [gl]);
// 鼠标点击
const onPointerDown = useCallback(() => {
if (hoveredRef.current) {
const pos = new THREE.Vector3();
hoveredRef.current.getWorldPosition(pos);
onNodeClick(
{ x: pos.x, y: pos.y, z: pos.z },
hoveredRef.current.userData,
);
}
}, [onNodeClick]);
// 绑定DOM事件
useFrame(() => {
const dom = gl.domElement;
// 这里用原始addEventListener来确保每帧检测
// 但只绑定一次(通过userData标记)
if (!(dom as any).__interactionBound) {
dom.addEventListener('pointermove', onPointerMove);
dom.addEventListener('pointerdown', onPointerDown);
(dom as any).__interactionBound = true;
}
});
// 每帧 raycast
useFrame(() => {
raycaster.current.setFromCamera(mouse.current, camera);
// 收集所有可交互的mesh
const interactables: THREE.Mesh[] = [];
scene.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh && obj.userData?.tooltip) {
interactables.push(obj as THREE.Mesh);
}
});
const intersects = raycaster.current.intersectObjects(interactables, false);
if (intersects.length > 0) {
const mesh = intersects[0].object as THREE.Mesh;
// 新悬浮对象
if (hoveredRef.current !== mesh) {
// 取消上一个高亮
if (hoveredRef.current) {
_unhighlight(hoveredRef.current, origEmissiveRef.current);
}
hoveredRef.current = mesh;
origEmissiveRef.current = (mesh.material as THREE.MeshStandardMaterial)?.emissiveIntensity ?? 0.3;
_highlight(mesh);
}
// tooltip位置(屏幕坐标)
const worldPos = new THREE.Vector3();
mesh.getWorldPosition(worldPos);
const screenPos = worldPos.clone().project(camera);
const rect = gl.domElement.getBoundingClientRect();
const sx = (screenPos.x * 0.5 + 0.5) * rect.width + rect.left + 15;
const sy = (-screenPos.y * 0.5 + 0.5) * rect.height + rect.top - 10;
onTooltipChange({
text: mesh.userData.tooltip,
x: sx,
y: sy,
visible: true,
});
gl.domElement.style.cursor = 'pointer';
} else {
if (hoveredRef.current) {
_unhighlight(hoveredRef.current, origEmissiveRef.current);
hoveredRef.current = null;
}
onTooltipChange({ text: '', x: 0, y: 0, visible: false });
gl.domElement.style.cursor = 'grab';
}
});
return null;
}
// gsap高亮动画
function _highlight(mesh: THREE.Mesh) {
const mat = mesh.material as THREE.MeshStandardMaterial;
if (mat?.emissiveIntensity !== undefined) {
gsap.to(mat, { emissiveIntensity: 0.8, duration: 0.3 });
}
gsap.to(mesh.scale, { x: 1.1, y: 1.1, z: 1.1, duration: 0.3 });
}
function _unhighlight(mesh: THREE.Mesh, origEmissive: number) {
const mat = mesh.material as THREE.MeshStandardMaterial;
if (mat?.emissiveIntensity !== undefined) {
gsap.to(mat, { emissiveIntensity: origEmissive, duration: 0.3 });
}
gsap.to(mesh.scale, { x: 1, y: 1, z: 1, duration: 0.3 });
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Scene.tsx — R3F场景基础设施:灯光、雾、网格地面、环境粒子
* OrbitControls 通过 makeDefault 暴露给 useThree().controls
*/
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import * as THREE from 'three';
/** 发光网格地面 */
function Grid() {
const geo = useMemo(() => {
const size = 100;
const divisions = 40;
const half = size / 2;
const step = size / divisions;
const positions: number[] = [];
for (let i = 0; i <= divisions; i++) {
const p = -half + i * step;
positions.push(-half, 0, p, half, 0, p);
positions.push(p, 0, -half, p, 0, half);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return g;
}, []);
return (
<lineSegments geometry={geo} position={[0, -0.5, 0]}>
<lineBasicMaterial color="#1a2040" transparent opacity={0.4} />
</lineSegments>
);
}
/** 环境漂浮粒子 */
function AmbientParticles() {
const ref = useRef<THREE.Points>(null!);
const count = 600;
const [positions, colors] = useMemo(() => {
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
pos[i * 3] = (Math.random() - 0.5) * 120;
pos[i * 3 + 1] = Math.random() * 50;
pos[i * 3 + 2] = (Math.random() - 0.5) * 120;
const c = new THREE.Color().setHSL(0.55 + Math.random() * 0.15, 0.6, 0.3 + Math.random() * 0.2);
col[i * 3] = c.r;
col[i * 3 + 1] = c.g;
col[i * 3 + 2] = c.b;
}
return [pos, col];
}, []);
useFrame(() => {
if (ref.current) {
ref.current.rotation.y += 0.0002;
}
});
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial
size={0.15}
vertexColors
transparent
opacity={0.5}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
export default function Scene() {
return (
<>
{/* 雾效 */}
<fogExp2 attach="fog" args={['#0a0e17', 0.008]} />
{/* 灯光 */}
<ambientLight intensity={0.6} color="#334466" />
<directionalLight
position={[20, 40, 30]}
intensity={0.8}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<directionalLight position={[-15, 20, -10]} intensity={0.3} color="#4488cc" />
<pointLight position={[0, 30, 0]} intensity={0.5} color="#4fc3f7" distance={80} />
{/*
OrbitControls — makeDefault 使其可通过 useThree().controls 访问
这是CameraController能操作target的关键
*/}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.05}
minDistance={15}
maxDistance={120}
maxPolarAngle={Math.PI * 0.75}
target={[0, 5, 0]}
/>
{/* 网格地面 + 环境粒子 */}
<Grid />
<AmbientParticles />
</>
);
}
@@ -0,0 +1,79 @@
/**
* Connections.tsx — 阶段间CatmullRom管道连接 + 数据流转标注
* 每段管道旁标注数据变化摘要,帮助理解每一步的输入输出
*/
import { useMemo } from 'react';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import type { StageIndex } from '../types';
import { STAGE_POSITIONS, CN_FONT } from '../types';
// 管道上方的数据流转标注
const PIPE_LABELS = [
'1048字段', // 原始数据 → 赋分
'20个原始分', // 赋分 → PCA
'20个标准化分', // PCA → 分布
'20个水平等级', // 分布 → 水平
'7维度 → 1总分', // 水平 → 维度
];
interface Props {
currentStep: StageIndex;
}
export default function Connections({ currentStep }: Props) {
const tubes = useMemo(() => {
const result: Array<{
geometry: THREE.TubeGeometry;
from: number;
to: number;
midX: number;
}> = [];
for (let i = 0; i < STAGE_POSITIONS.length - 1; i++) {
const x1 = STAGE_POSITIONS[i] + 5;
const x2 = STAGE_POSITIONS[i + 1] - 5;
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(x1, 6, 0),
new THREE.Vector3((x1 + x2) / 2, 8, 0),
new THREE.Vector3(x2, 6, 0),
]);
const geo = new THREE.TubeGeometry(curve, 20, 0.08, 6, false);
result.push({ geometry: geo, from: i, to: i + 1, midX: (x1 + x2) / 2 });
}
return result;
}, []);
return (
<group>
{tubes.map((tube, i) => {
const active = tube.to <= currentStep;
return (
<group key={i}>
{/* 管道 */}
<mesh geometry={tube.geometry}>
<meshBasicMaterial
color={active ? '#4fc3f7' : '#334466'}
transparent
opacity={active ? 0.6 : 0.25}
/>
</mesh>
{/* 数据流转标注 — 管道上方 */}
{active && PIPE_LABELS[i] && (
<Text
position={[tube.midX, 9.5, 0]}
fontSize={0.3}
color="#667788"
anchorX="center"
anchorY="middle"
font={CN_FONT}
>
{PIPE_LABELS[i]}
</Text>
)}
</group>
);
})}
</group>
);
}
@@ -0,0 +1,294 @@
/**
* Particles.tsx — 数据流粒子动画系统
*
* 对齐demo-visual的粒子效果:
* 1. 管道粒子:沿CatmullRom曲线从前一阶段流向当前阶段,带颜色渐变和wobble
* 2. 汇聚粒子:从阶段周围散射位置 ease-out 汇聚到中心
* 3. 自定义 shader:辉光点精灵(径向渐变 + additive blending
*/
import { useRef, useMemo, useEffect, useCallback } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { StageIndex } from '../types';
import { STAGE_POSITIONS } from '../types';
const MAX_PARTICLES = 200;
const STAGE_COLORS_VEC = [
new THREE.Color(0x4fc3f7), // 原始数据 - 蓝
new THREE.Color(0xffd54f), // 赋分 - 金
new THREE.Color(0x81c784), // PCA - 绿
new THREE.Color(0xba68c8), // 标准化 - 紫
new THREE.Color(0xff8a65), // 水平 - 橙
new THREE.Color(0xe53935), // 总分 - 红
];
// 阶段间的CatmullRom管道曲线
function buildCurves(): THREE.CatmullRomCurve3[] {
const curves: THREE.CatmullRomCurve3[] = [];
for (let i = 0; i < STAGE_POSITIONS.length - 1; i++) {
const x1 = STAGE_POSITIONS[i] + 5;
const x2 = STAGE_POSITIONS[i + 1] - 5;
curves.push(new THREE.CatmullRomCurve3([
new THREE.Vector3(x1, 6, 0),
new THREE.Vector3((x1 + x2) / 2, 8, 0),
new THREE.Vector3(x2, 6, 0),
]));
}
return curves;
}
interface Particle {
idx: number;
curve: THREE.CatmullRomCurve3 | null;
t: number;
speed: number;
fromColor: THREE.Color;
toColor: THREE.Color;
size: number;
wobble: number;
wobbleSpeed: number;
wobbleAmp: number;
// 汇聚模式
converge: boolean;
startPos?: THREE.Vector3;
targetPos?: THREE.Vector3;
}
interface Props {
currentStep: StageIndex;
}
export default function Particles({ currentStep }: Props) {
const pointsRef = useRef<THREE.Points>(null!);
const curvesRef = useRef(buildCurves());
const poolRef = useRef<number[]>([...Array(MAX_PARTICLES).keys()]);
const activeRef = useRef<Particle[]>([]);
const prevStepRef = useRef<StageIndex>(-1 as StageIndex);
const positions = useMemo(() => {
const arr = new Float32Array(MAX_PARTICLES * 3);
for (let i = 0; i < MAX_PARTICLES; i++) {
arr[i * 3 + 1] = -100; // 隐藏在下方
}
return arr;
}, []);
const colors = useMemo(() => {
const arr = new Float32Array(MAX_PARTICLES * 3);
for (let i = 0; i < MAX_PARTICLES; i++) {
arr[i * 3] = 0.3;
arr[i * 3 + 1] = 0.7;
arr[i * 3 + 2] = 0.95;
}
return arr;
}, []);
const sizes = useMemo(() => new Float32Array(MAX_PARTICLES).fill(0), []);
// Shader材质
const shaderMaterial = useMemo(() => new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: `
attribute float size;
attribute vec3 color;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = color;
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (200.0 / -mvPos.z);
gl_Position = projectionMatrix * mvPos;
vAlpha = size > 0.0 ? 1.0 : 0.0;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
void main() {
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
float glow = 1.0 - smoothstep(0.0, 0.5, dist);
glow = pow(glow, 1.5);
gl_FragColor = vec4(vColor * 1.5, glow * vAlpha);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}), []);
const getParticle = useCallback(() => {
if (poolRef.current.length === 0) return null;
return poolRef.current.pop()!;
}, []);
const returnParticle = useCallback((idx: number) => {
positions[idx * 3 + 1] = -100;
sizes[idx] = 0;
poolRef.current.push(idx);
}, [positions, sizes]);
// 管道粒子发射
const emitBetween = useCallback((fromStage: number, toStage: number) => {
const curveIdx = fromStage;
if (curveIdx < 0 || curveIdx >= curvesRef.current.length) return;
const curve = curvesRef.current[curveIdx];
const batchSize = 15;
for (let i = 0; i < batchSize; i++) {
const idx = getParticle();
if (idx === null) break;
const t = Math.random() * 0.3;
const point = curve.getPoint(t);
activeRef.current.push({
idx,
curve,
t,
speed: 0.002 + Math.random() * 0.004,
fromColor: STAGE_COLORS_VEC[fromStage] ?? STAGE_COLORS_VEC[0],
toColor: STAGE_COLORS_VEC[toStage] ?? STAGE_COLORS_VEC[5],
size: 1.5 + Math.random() * 2,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleAmp: 0.3 + Math.random() * 0.5,
converge: false,
});
positions[idx * 3] = point.x;
positions[idx * 3 + 1] = point.y;
positions[idx * 3 + 2] = point.z;
sizes[idx] = 0.1;
}
}, [getParticle, positions, sizes]);
// 汇聚粒子发射
const emitConverge = useCallback((stageIndex: number) => {
const center = new THREE.Vector3(STAGE_POSITIONS[stageIndex], 8, 0);
const color = STAGE_COLORS_VEC[stageIndex] ?? STAGE_COLORS_VEC[0];
const batchSize = 20;
for (let i = 0; i < batchSize; i++) {
const idx = getParticle();
if (idx === null) break;
const startPos = new THREE.Vector3(
center.x + (Math.random() - 0.5) * 12,
center.y + (Math.random() - 0.5) * 12,
center.z + (Math.random() - 0.5) * 8,
);
activeRef.current.push({
idx,
curve: null,
t: 0,
speed: 0.008 + Math.random() * 0.008,
fromColor: color,
toColor: color,
size: 1.0 + Math.random() * 1.5,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 1 + Math.random() * 2,
wobbleAmp: 0.2 + Math.random() * 0.3,
converge: true,
startPos: startPos.clone(),
targetPos: center.clone(),
});
positions[idx * 3] = startPos.x;
positions[idx * 3 + 1] = startPos.y;
positions[idx * 3 + 2] = startPos.z;
sizes[idx] = 0.1;
}
}, [getParticle, positions, sizes]);
// 阶段切换时触发粒子
useEffect(() => {
const prev = prevStepRef.current;
prevStepRef.current = currentStep;
if (prev >= 0 && currentStep > prev) {
// 从前一个阶段到当前阶段逐段发射管道粒子
for (let s = prev; s < currentStep; s++) {
setTimeout(() => emitBetween(s, s + 1), (s - prev) * 400);
}
}
// 汇聚粒子
setTimeout(() => emitConverge(currentStep), currentStep > prev ? 600 : 100);
}, [currentStep, emitBetween, emitConverge]);
// 每帧更新粒子
useFrame((_, delta) => {
shaderMaterial.uniforms.uTime.value += delta;
const toRemove: number[] = [];
activeRef.current.forEach((p, pi) => {
p.t += p.speed;
p.wobble += p.wobbleSpeed * delta;
if (p.converge && p.startPos && p.targetPos) {
// 汇聚
const lerpT = Math.min(p.t / 0.5, 1);
const eased = 1 - Math.pow(1 - lerpT, 3); // ease-out cubic
const cx = THREE.MathUtils.lerp(p.startPos.x, p.targetPos.x, eased);
const cy = THREE.MathUtils.lerp(p.startPos.y, p.targetPos.y, eased);
const cz = THREE.MathUtils.lerp(p.startPos.z, p.targetPos.z, eased);
positions[p.idx * 3] = cx + Math.sin(p.wobble) * p.wobbleAmp * (1 - eased);
positions[p.idx * 3 + 1] = cy + Math.cos(p.wobble * 0.7) * p.wobbleAmp * (1 - eased);
positions[p.idx * 3 + 2] = cz;
sizes[p.idx] = p.size * (1 - eased);
if (lerpT >= 1) toRemove.push(pi);
} else if (p.curve) {
// 管道流动
if (p.t >= 1) {
toRemove.push(pi);
} else {
const point = p.curve.getPoint(p.t);
positions[p.idx * 3] = point.x + Math.sin(p.wobble) * p.wobbleAmp;
positions[p.idx * 3 + 1] = point.y + Math.cos(p.wobble * 0.7) * p.wobbleAmp;
positions[p.idx * 3 + 2] = point.z + Math.sin(p.wobble * 1.3) * p.wobbleAmp * 0.5;
// 颜色渐变
const c = p.fromColor.clone().lerp(p.toColor, p.t);
colors[p.idx * 3] = c.r;
colors[p.idx * 3 + 1] = c.g;
colors[p.idx * 3 + 2] = c.b;
// 尺寸:中间大两头小
const sizeT = Math.sin(p.t * Math.PI);
sizes[p.idx] = p.size * sizeT;
}
}
});
// 回收
toRemove.sort((a, b) => b - a);
toRemove.forEach(pi => {
const p = activeRef.current[pi];
returnParticle(p.idx);
activeRef.current.splice(pi, 1);
});
// 更新GPU buffer
if (pointsRef.current) {
const geo = pointsRef.current.geometry;
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
(geo.attributes as any).size.needsUpdate = true;
}
});
return (
<points ref={pointsRef} material={shaderMaterial}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
<bufferAttribute attach="attributes-size" args={[sizes, 1]} />
</bufferGeometry>
</points>
);
}
@@ -0,0 +1,83 @@
/**
* useStepControl — 步骤状态管理 + 自动播放
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { StageIndex } from '../types';
export function useStepControl() {
const [currentStep, setCurrentStep] = useState<StageIndex>(0);
const [autoPlaying, setAutoPlaying] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const goToStep = useCallback((step: StageIndex) => {
setCurrentStep(step);
}, []);
const nextStep = useCallback(() => {
setCurrentStep((prev) => (prev < 5 ? (prev + 1) as StageIndex : prev));
}, []);
const prevStep = useCallback(() => {
setCurrentStep((prev) => (prev > 0 ? (prev - 1) as StageIndex : prev));
}, []);
const reset = useCallback(() => {
setAutoPlaying(false);
setCurrentStep(0);
}, []);
const toggleAutoPlay = useCallback(() => {
setAutoPlaying((prev) => !prev);
}, []);
// 自动播放逻辑
useEffect(() => {
if (!autoPlaying) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
return;
}
if (currentStep >= 5) {
setAutoPlaying(false);
return;
}
timerRef.current = setTimeout(() => {
setCurrentStep((prev) => (prev + 1) as StageIndex);
}, 3000);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [autoPlaying, currentStep]);
// 键盘快捷键
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
nextStep();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
prevStep();
} else if (e.key === 'r' || e.key === 'R') {
reset();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [nextStep, prevStep, reset]);
return {
currentStep,
autoPlaying,
goToStep,
nextStep,
prevStep,
reset,
toggleAutoPlay,
};
}
@@ -0,0 +1,46 @@
/**
* useTraceData — 获取单校的计算链路溯源数据
*/
import { useState, useEffect } from 'react';
import type { TraceData } from '../types';
import { getToken, clearAuth } from '../../../services/auth';
export function useTraceData(district: string, school: string) {
const [data, setData] = useState<TraceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!district || !school) return;
setLoading(true);
setError(null);
const url = `/api/era2/trace/${encodeURIComponent(district)}/${encodeURIComponent(school)}`;
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
fetch(url, { headers })
.then(async (res) => {
if (res.status === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json as TraceData);
})
.catch((err) => {
setError(err.message);
})
.finally(() => {
setLoading(false);
});
}, [district, school]);
return { data, loading, error };
}
@@ -0,0 +1,144 @@
/**
* Stage 5: 维度聚合 → 总分
* 7个维度柱体围绕中心红球,柱高=得分,连线到中心
*/
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Text, Line } from '@react-three/drei';
import * as THREE from 'three';
import type { DimensionInfo, OverallInfo } from '../types';
import { DIM_COLORS, STAGE_POSITIONS, CN_FONT } from '../types';
interface Props {
dimensions: Record<string, DimensionInfo>;
overall: OverallInfo;
school: string;
visible: boolean;
}
export default function DimensionStage({ dimensions, overall, visible }: Props) {
const totalRef = useRef<THREE.Mesh>(null!);
const x = STAGE_POSITIONS[5];
const dimEntries = useMemo(() => Object.entries(dimensions), [dimensions]);
// 总分球脉冲
useFrame(({ clock }) => {
if (totalRef.current && visible) {
const s = 1 + Math.sin(clock.elapsedTime * 2) * 0.05;
totalRef.current.scale.set(s, s, s);
}
});
const angleStep = (Math.PI * 2) / Math.max(dimEntries.length, 1);
const radius = 5;
return (
<group visible={visible}>
{/* 阶段标签 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#4fc3f7" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* 7个维度柱体 */}
{dimEntries.map(([name, data], i) => {
const angle = i * angleStep - Math.PI / 2;
const px = x + Math.cos(angle) * radius;
const pz = Math.sin(angle) * radius;
const height = (data.score ?? 50) / 10;
const color = DIM_COLORS[name] || '#ff8a65';
return (
<group key={name}>
{/* 柱体 */}
<mesh
position={[px, height / 2 + 0.5, pz]}
castShadow
userData={{
tooltip: `${name}\n维度分: ${data.score?.toFixed(2) ?? '-'}\n子维度: ${Object.keys(data.sub_scores).join('、')}`,
type: 'dimension-pillar',
}}
>
<cylinderGeometry args={[0.8, 0.8, height, 16]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.3}
metalness={0.3}
roughness={0.5}
transparent
opacity={0.92}
/>
</mesh>
{/* 维度名标签 */}
<Text
position={[px, height + 2, pz]}
fontSize={0.7}
color="#ffffff"
anchorX="center"
anchorY="middle"
maxWidth={4}
font={CN_FONT}
>
{name}
</Text>
{/* 得分标签 */}
<Text
position={[px, height + 1.2, pz]}
fontSize={0.5}
color={color}
anchorX="center"
anchorY="middle"
>
{data.score?.toFixed(1) ?? '-'}
</Text>
{/* 连接线到中心 */}
<Line
points={[[px, height + 0.5, pz], [x, 12, 0]]}
color={color}
transparent
opacity={0.3}
lineWidth={1}
/>
</group>
);
})}
{/* 中心总分球 */}
<mesh
ref={totalRef}
position={[x, 12, 0]}
castShadow
userData={{
tooltip: `总体得分: ${overall.score?.toFixed(1) ?? '-'}\n排名: ${overall.rank ?? '-'}/${overall.total_schools}`,
type: 'total-score',
}}
>
<sphereGeometry args={[2, 32, 32]} />
<meshStandardMaterial
color="#e53935"
emissive="#e53935"
emissiveIntensity={0.5}
metalness={0.3}
roughness={0.5}
transparent
opacity={0.92}
/>
</mesh>
{/* 总分数字 */}
<Text position={[x, 16, 0]} fontSize={2} color="#e53935" anchorX="center" anchorY="middle" fontWeight="bold">
{overall.score?.toFixed(1) ?? '-'}
</Text>
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[8, 8, 0.3, 32]} />
<meshStandardMaterial color="#e53935" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
@@ -0,0 +1,129 @@
/**
* Stage 4: 水平判定
* 4层色带(水平1-4)+ 子维度球体落入对应层
*/
import { useMemo } from 'react';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import type { LevelInfo } from '../types';
import { STAGE_POSITIONS, DIM_COLORS, CN_FONT } from '../types';
interface Props {
levels: Record<string, LevelInfo>;
visible: boolean;
}
const LEVEL_COLORS = ['#ef5350', '#ff9800', '#ffd54f', '#81c784']; // 1,2,3,4
const LEVEL_LABELS = ['水平1:尚需努力', '水平2:合格', '水平3:良好', '水平4:优秀'];
export default function LevelStage({ levels, visible }: Props) {
const x = STAGE_POSITIONS[4];
const levelEntries = useMemo(() => Object.entries(levels), [levels]);
// 按水平分组,并预计算每个球的固定位置
const grouped = useMemo(() => {
const g: Record<number, Array<{ name: string; data: LevelInfo; xOff: number; zOff: number }>> = { 1: [], 2: [], 3: [], 4: [] };
// 先分组
const temp: Record<number, Array<{ name: string; data: LevelInfo }>> = { 1: [], 2: [], 3: [], 4: [] };
for (const [name, data] of levelEntries) {
const lv = Math.max(1, Math.min(4, data.level));
temp[lv].push({ name, data });
}
// 计算固定位置(用确定性散布,不用 Math.random
for (const lv of [1, 2, 3, 4]) {
const items = temp[lv];
g[lv] = items.map((item, i) => ({
...item,
xOff: x - 3 + (i % 5) * 1.5, // 网格排列 x
zOff: (i - items.length / 2) * 1.2, // 均匀分布 z
}));
}
return g;
}, [levelEntries, x]);
return (
<group visible={visible}>
{/* 阶段标签 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#ff8a65" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* 4层色带 */}
{[1, 2, 3, 4].map((lv) => {
const y = lv * 3.5 + 1;
const color = LEVEL_COLORS[lv - 1];
const items = grouped[lv] || [];
return (
<group key={lv}>
{/* 色带 */}
<mesh position={[x, y, 0]}>
<boxGeometry args={[12, 3, 10]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.1}
transparent
opacity={0.15}
/>
</mesh>
{/* 层边框 */}
<lineSegments position={[x, y, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(12, 3, 10)]} />
<lineBasicMaterial color={color} transparent opacity={0.3} />
</lineSegments>
{/* 水平标签 */}
<Text position={[x - 6.5, y, 5.5]} fontSize={0.4} color={color} anchorX="right" anchorY="middle" font={CN_FONT}>
{LEVEL_LABELS[lv - 1]}
</Text>
{/* 子维度球 — 位置已在useMemo中预计算,不会每帧抖动 */}
{items.map((item) => {
const dimColor = DIM_COLORS[item.data.parent_dimension] || color;
return (
<group key={item.name}>
<mesh
position={[item.xOff, y, item.zOff]}
castShadow
userData={{
tooltip: `${item.name}\n标准化分: ${item.data.score.toFixed(1)}\n${item.data.description}\n→ 水平${item.data.level}`,
type: 'level-dot',
}}
>
<sphereGeometry args={[0.4, 16, 16]} />
<meshStandardMaterial
color={dimColor}
emissive={dimColor}
emissiveIntensity={0.3}
metalness={0.3}
roughness={0.5}
/>
</mesh>
<Text
position={[item.xOff, y + 0.7, item.zOff]}
fontSize={0.25}
color="#ffffff"
anchorX="center"
anchorY="middle"
maxWidth={3}
font={CN_FONT}
>
{item.name}
</Text>
</group>
);
})}
</group>
);
})}
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[7, 7, 0.3, 32]} />
<meshStandardMaterial color="#ff8a65" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
@@ -0,0 +1,226 @@
/**
* Stage 2: PCA标准化(完整的 Z→PCA→×10+50 流程)
*
* 视觉设计:
* 左侧:多个输入变量柱(代表题目赋分值)
* 中间:收缩漏斗(代表PCA降维)
* 右侧:单一得分柱(×10+50后的标准化分)
* 底部:解释方差环 + Loadings前3名
*/
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Text, Line } from '@react-three/drei';
import * as THREE from 'three';
import type { PCADetail } from '../types';
import { STAGE_POSITIONS, CN_FONT } from '../types';
interface Props {
pcaDetails: Record<string, PCADetail>;
visible: boolean;
}
/** 收缩漏斗 — 代表PCA降维过程 */
function Funnel({ position, color }: { position: [number, number, number]; color: string }) {
const ref = useRef<THREE.Mesh>(null!);
useFrame(({ clock }) => {
if (ref.current) {
// 微微呼吸效果
const s = 1 + Math.sin(clock.elapsedTime * 1.5) * 0.03;
ref.current.scale.set(s, 1, s);
}
});
return (
<mesh ref={ref} position={position} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[3, 5, 6, 1, true]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.15}
transparent
opacity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
);
}
export default function PCAStage({ pcaDetails, visible }: Props) {
const x = STAGE_POSITIONS[2];
// 找一个有详细数据的PCA示例
const examplePCA = useMemo(() => {
for (const [name, detail] of Object.entries(pcaDetails)) {
if (detail.type === 'pca' && detail.loadings && detail.loadings.length > 0) {
return { name, detail };
}
}
const first = Object.entries(pcaDetails)[0];
return first ? { name: first[0], detail: first[1] } : null;
}, [pcaDetails]);
const nVars = examplePCA?.detail.n_variables ?? 8;
const nSchools = examplePCA?.detail.n_schools ?? 266;
const evr = examplePCA?.detail.explained_variance_ratio ?? 0;
const schoolScore = examplePCA?.detail.school_standardized ?? 50;
const topLoadings = useMemo(() => {
return (examplePCA?.detail.loadings ?? []).slice(0, 5);
}, [examplePCA]);
return (
<group visible={visible}>
{/* 阶段标题 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#81c784" anchorX="center" anchorY="middle" font={CN_FONT}>
PCA标准化
</Text>
{/* ===== 左侧:输入变量柱群(代表多个题目赋分值) ===== */}
<group position={[x - 5, 5, 0]}>
{Array.from({ length: Math.min(nVars, 10) }).map((_, i) => {
const h = 2 + (i % 3) * 1.5; // 不同高度代表不同值
const z = (i - Math.min(nVars, 10) / 2) * 0.8;
return (
<mesh key={i} position={[0, h / 2, z]}>
<boxGeometry args={[0.5, h, 0.5]} />
<meshStandardMaterial
color="#81c784"
emissive="#81c784"
emissiveIntensity={0.2}
transparent
opacity={0.7}
/>
</mesh>
);
})}
<Text position={[0, -0.8, 0]} fontSize={0.35} color="#8899aa" anchorX="center" anchorY="middle" font={CN_FONT}>
{nVars}
</Text>
</group>
{/* ===== 中间:收缩漏斗(PCA降维) ===== */}
<Funnel position={[x, 8, 0]} color="#81c784" />
{/* 漏斗上方标注 */}
<Text position={[x, 12, 0]} fontSize={0.5} color="#81c784" anchorX="center" anchorY="middle" font={CN_FONT}>
PCA第一主成分
</Text>
{/* 漏斗左右箭头线:输入→漏斗→输出 */}
<Line
points={[[x - 3.5, 8, 0], [x - 1.5, 8, 0]]}
color="#81c784" lineWidth={2} transparent opacity={0.5}
/>
<Line
points={[[x + 1.5, 6, 0], [x + 3.5, 6, 0]]}
color="#81c784" lineWidth={2} transparent opacity={0.5}
/>
{/* ===== 右侧:输出得分柱(×10+50后) ===== */}
{/* 基底y=1(圆盘上表面y=0.3,留余量),柱高按比例缩放 */}
<group position={[x + 5, 0, 0]}>
{(() => {
const barH = Math.max(1, schoolScore / 8); // 50→6.25, 映射更紧凑
const baseY = 1; // 柱底离地
return (
<>
<mesh position={[0, baseY + barH / 2, 0]}
userData={{
tooltip: `该校标准化得分: ${schoolScore.toFixed(1)}\n(均值50, 标准差10)`,
type: 'pca-score',
}}
>
<boxGeometry args={[1.2, barH, 1.2]} />
<meshStandardMaterial
color="#e53935"
emissive="#e53935"
emissiveIntensity={0.3}
metalness={0.3}
roughness={0.5}
transparent
opacity={0.9}
/>
</mesh>
<Text position={[0, baseY + barH + 0.5, 0]} fontSize={0.5} color="#e53935" anchorX="center" anchorY="middle">
{schoolScore.toFixed(1)}
</Text>
<Text position={[0, baseY - 0.5, 0]} fontSize={0.35} color="#8899aa" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
</>
);
})()}
</group>
{/* ===== 底部信息区 ===== */}
{/* 解释方差环 */}
{evr > 0 && (
<group position={[x - 2, 2, 4]}>
<mesh>
<ringGeometry args={[1.2, 1.6, 32, 1, 0, Math.PI * 2 * evr]} />
<meshStandardMaterial color="#81c784" emissive="#81c784" emissiveIntensity={0.4} side={THREE.DoubleSide} />
</mesh>
<mesh>
<ringGeometry args={[1.2, 1.6, 32]} />
<meshStandardMaterial color="#1a2040" transparent opacity={0.3} side={THREE.DoubleSide} />
</mesh>
<Text position={[0, 0, 0.1]} fontSize={0.4} color="#81c784" anchorX="center" anchorY="middle">
{(evr * 100).toFixed(0)}%
</Text>
<Text position={[0, -0.6, 0.1]} fontSize={0.25} color="#8899aa" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
</group>
)}
{/* Loadings前几名 */}
{topLoadings.length > 0 && (
<group position={[x + 2, 2, 4]}>
<Text position={[0, 1.5, 0]} fontSize={0.3} color="#81c784" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{topLoadings.map((l, i) => {
const barH = Math.abs(l.loading) * 3;
const barColor = l.loading > 0 ? '#81c784' : '#ef5350';
return (
<group key={l.variable}>
<mesh position={[(i - topLoadings.length / 2) * 0.8, barH / 2, 0]}>
<boxGeometry args={[0.5, barH, 0.3]} />
<meshStandardMaterial color={barColor} emissive={barColor} emissiveIntensity={0.3} transparent opacity={0.8} />
</mesh>
<Text
position={[(i - topLoadings.length / 2) * 0.8, -0.3, 0]}
fontSize={0.18} color="#8899aa" anchorX="center" anchorY="top"
font={CN_FONT} maxWidth={1}
>
{l.variable.length > 4 ? l.variable.slice(0, 3) + '..' : l.variable}
</Text>
</group>
);
})}
</group>
)}
{/* 公式卡片 */}
<group position={[x, 18, 0]}>
<mesh>
<boxGeometry args={[10, 1.8, 0.15]} />
<meshStandardMaterial color="#81c784" emissive="#81c784" emissiveIntensity={0.08} transparent opacity={0.7} />
</mesh>
<Text position={[0, 0.3, 0.1]} fontSize={0.35} color="#81c784" anchorX="center" anchorY="middle">
Z = (x-mean)/std PCA(PC1) ×10+50
</Text>
<Text position={[0, -0.3, 0.1]} fontSize={0.28} color="#8899aa" anchorX="center" anchorY="middle" font={CN_FONT}>
{nSchools}
</Text>
</group>
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[7, 7, 0.3, 32]} />
<meshStandardMaterial color="#81c784" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
@@ -0,0 +1,109 @@
/**
* Stage 0: 原始数据
* B表和C表作为两个立体文件夹,小数据块散落其中
*/
import { useMemo } from 'react';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import type { RawDataStage as RawDataStageType } from '../types';
import { STAGE_POSITIONS, CN_FONT } from '../types';
interface Props {
data: RawDataStageType;
visible: boolean;
}
function DataBlock({ position, color }: { position: [number, number, number]; color: string }) {
return (
<mesh position={position}>
<boxGeometry args={[0.3, 0.3, 0.3]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.5} />
</mesh>
);
}
export default function RawDataStage({ data, visible }: Props) {
const x = STAGE_POSITIONS[0];
const blocks = useMemo(() => {
const items: Array<{ pos: [number, number, number]; color: string }> = [];
// B表数据块
const bCount = Math.min(Math.floor(data.b_table.field_count / 15), 10);
for (let i = 0; i < bCount; i++) {
items.push({
pos: [x + (Math.random() - 0.5) * 3, 1 + i * 0.6, -5 + (Math.random() - 0.5) * 2],
color: '#4fc3f7',
});
}
// C表数据块
const cCount = Math.min(Math.floor(data.c_table.field_count / 80), 12);
for (let i = 0; i < cCount; i++) {
items.push({
pos: [x + (Math.random() - 0.5) * 3, 1 + i * 0.6, 5 + (Math.random() - 0.5) * 2],
color: '#29b6f6',
});
}
return items;
}, [data, x]);
const tables = useMemo(() => [
{ name: 'B表', subtitle: '课程实施', fields: data.b_table.field_count, z: -5, color: '#4fc3f7' },
{ name: 'C表', subtitle: '学科课程', fields: data.c_table.field_count, z: 5, color: '#29b6f6' },
], [data]);
return (
<group visible={visible}>
{/* 阶段标签 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#4fc3f7" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{tables.map((t) => (
<group key={t.name}>
{/* 文件夹主体 */}
<mesh
position={[x, 5, t.z]}
castShadow
userData={{ tooltip: `${t.name} ${t.subtitle}\n共 ${t.fields} 个字段`, type: 'raw-table' }}
>
<boxGeometry args={[4, 8, 3]} />
<meshStandardMaterial
color={t.color}
emissive={t.color}
emissiveIntensity={0.2}
metalness={0.3}
roughness={0.5}
transparent
opacity={0.92}
/>
</mesh>
{/* 边框 */}
<lineSegments position={[x, 5, t.z]}>
<edgesGeometry args={[new THREE.BoxGeometry(4, 8, 3)]} />
<lineBasicMaterial color={t.color} transparent opacity={0.6} />
</lineSegments>
{/* 标签 */}
<Text position={[x, 10, t.z]} fontSize={0.7} color={t.color} anchorX="center" anchorY="middle" font={CN_FONT}>
{t.name}
</Text>
<Text position={[x, 9.2, t.z]} fontSize={0.5} color="#aabbcc" anchorX="center" anchorY="middle" font={CN_FONT}>
{t.subtitle} · {t.fields}
</Text>
</group>
))}
{/* 散落的数据块 */}
{blocks.map((b, i) => (
<DataBlock key={i} position={b.pos} color={b.color} />
))}
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[7, 7, 0.3, 32]} />
<meshStandardMaterial color="#4fc3f7" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
@@ -0,0 +1,167 @@
/**
* Stage 1: 赋分规则
*
* 视觉设计:
* 中间:齿轮形赋分引擎(ExtrudeGeometry,带旋转动画)
* 左侧:B表(学校级)子维度卡片组 — 蓝色调
* 右侧:C表(学科级)子维度卡片组 — 绿色调
* 这样一眼能看出两种数据来源的区别
*/
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import type { ScoringInfo } from '../types';
import { STAGE_POSITIONS, CN_FONT } from '../types';
interface Props {
scoring: Record<string, ScoringInfo>;
visible: boolean;
}
/** 真实齿轮形状 */
function GearMesh({ position }: { position: [number, number, number] }) {
const ref = useRef<THREE.Mesh>(null!);
const geometry = useMemo(() => {
const shape = new THREE.Shape();
const teeth = 12;
const outerR = 3.5;
const innerR = 2.5;
for (let i = 0; i < teeth; i++) {
const a1 = (i / teeth) * Math.PI * 2;
const a2 = ((i + 0.3) / teeth) * Math.PI * 2;
const a3 = ((i + 0.5) / teeth) * Math.PI * 2;
const a4 = ((i + 0.8) / teeth) * Math.PI * 2;
if (i === 0) shape.moveTo(Math.cos(a1) * innerR, Math.sin(a1) * innerR);
shape.lineTo(Math.cos(a2) * outerR, Math.sin(a2) * outerR);
shape.lineTo(Math.cos(a3) * outerR, Math.sin(a3) * outerR);
shape.lineTo(Math.cos(a4) * innerR, Math.sin(a4) * innerR);
}
shape.closePath();
return new THREE.ExtrudeGeometry(shape, {
depth: 1.2, bevelEnabled: true, bevelThickness: 0.12, bevelSize: 0.08,
});
}, []);
useFrame(({ clock }) => {
if (ref.current) ref.current.rotation.z = clock.elapsedTime * 0.3;
});
return (
<mesh ref={ref} geometry={geometry} position={position} rotation={[-Math.PI / 2, 0, 0]}
userData={{ tooltip: '赋分引擎\n20个赋分函数\n量表题·多选题·填空题·二值题', type: 'scoring-engine' }}
>
<meshStandardMaterial color="#ffd54f" emissive="#ffd54f" emissiveIntensity={0.25}
metalness={0.3} roughness={0.5} transparent opacity={0.92} />
</mesh>
);
}
/** 规则卡片 */
function RuleCard({ position, name, method, score, color }:
{ position: [number, number, number]; name: string; method: string; score: number | null; color: string }) {
return (
<group position={position}>
<mesh castShadow
userData={{ tooltip: `${name}\n${method}\n得分: ${score?.toFixed(1) ?? '-'}`, type: 'scoring-rule' }}
>
<boxGeometry args={[4.5, 1.6, 0.12]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.1}
metalness={0.1} roughness={0.8} transparent opacity={0.85} />
</mesh>
<Text position={[0, 0.3, 0.08]} fontSize={0.35} color={color}
anchorX="center" anchorY="middle" maxWidth={4} font={CN_FONT}>
{name}
</Text>
<Text position={[1.5, -0.35, 0.08]} fontSize={0.3} color="#ffffff"
anchorX="center" anchorY="middle">
{score?.toFixed(1) ?? '-'}
</Text>
</group>
);
}
export default function ScoringStage({ scoring, visible }: Props) {
const x = STAGE_POSITIONS[1];
// 按数据来源分组
const { bTable, cTable } = useMemo(() => {
const b: Array<[string, ScoringInfo]> = [];
const c: Array<[string, ScoringInfo]> = [];
for (const [name, info] of Object.entries(scoring)) {
if (info.data_source?.includes('B表') || info.method?.includes('学校级')) {
b.push([name, info]);
} else {
c.push([name, info]);
}
}
// 如果分组失败(全在一边),平分
if (b.length === 0 && c.length > 0) {
const half = Math.ceil(c.length / 2);
return { bTable: c.slice(0, half), cTable: c.slice(half) };
}
return { bTable: b, cTable: c };
}, [scoring]);
return (
<group visible={visible}>
{/* 阶段标题 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#ffd54f" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* 中间齿轮 — 下移到底盘上方,距底盘约5个单位 */}
<GearMesh position={[x, 5, 0]} />
<Text position={[x, 7, 1]} fontSize={0.5} color="#ffd54f" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* 左侧 B表(学校级)卡片 */}
<Text position={[x - 5, 19, 0]} fontSize={0.5} color="#4fc3f7" anchorX="center" anchorY="middle" font={CN_FONT}>
B表 ·
</Text>
{bTable.slice(0, 5).map(([name, info], i) => (
<RuleCard
key={name}
position={[x - 5, 17 - i * 2, 0]}
name={name}
method={info.method?.slice(0, 40) ?? ''}
score={info.final_score}
color="#4fc3f7"
/>
))}
{/* 右侧 C表(学科级)卡片 */}
<Text position={[x + 5, 19, 0]} fontSize={0.5} color="#81c784" anchorX="center" anchorY="middle" font={CN_FONT}>
C表 ·
</Text>
{cTable.slice(0, 5).map(([name, info], i) => (
<RuleCard
key={name}
position={[x + 5, 17 - i * 2, 0]}
name={name}
method={info.method?.slice(0, 40) ?? ''}
score={info.final_score}
color="#81c784"
/>
))}
{/* 赋分类型图例 */}
<group position={[x, 3, 4]}>
{['量表 1-4分', '分档 0/1/2', '二值 有/无', '连续值'].map((label, i) => (
<Text key={label} position={[(i - 1.5) * 3, 0, 0]} fontSize={0.3} color="#8899aa"
anchorX="center" anchorY="middle" font={CN_FONT}>
{label}
</Text>
))}
</group>
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[8, 8, 0.3, 32]} />
<meshStandardMaterial color="#ffd54f" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
@@ -0,0 +1,179 @@
/**
* Stage 3: 得分分布对比
*
* 视觉设计(替代原来21条小曲线):
* 一条大的正态分布管(TubeGeometry,和demo一样)
* 该校标记球(红色,带分数标注)
* 区均值线(虚线)
* 底部:按维度分组的得分柱状图(7组,每组2-4个子维度)
*/
import { useMemo } from 'react';
import { Text, Line } from '@react-three/drei';
import * as THREE from 'three';
import type { StandardizedInfo } from '../types';
import { STAGE_POSITIONS, DIM_COLORS, CN_FONT } from '../types';
interface Props {
standardized: Record<string, StandardizedInfo>;
visible: boolean;
}
export default function StandardStage({ standardized, visible }: Props) {
const x = STAGE_POSITIONS[3];
const entries = useMemo(() => Object.entries(standardized), [standardized]);
// 按维度分组
const dimGrouped = useMemo(() => {
const groups: Record<string, Array<{ name: string; data: StandardizedInfo }>> = {};
for (const [name, data] of entries) {
const dim = data.parent_dimension;
if (!groups[dim]) groups[dim] = [];
groups[dim].push({ name, data });
}
return Object.entries(groups);
}, [entries]);
// 计算总体均值和该校总体
const stats = useMemo(() => {
const scores = entries.map(([, d]) => d.score);
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const distAvgs = entries.map(([, d]) => d.district_avg);
const distAvg = distAvgs.reduce((a, b) => a + b, 0) / distAvgs.length;
return { schoolAvg: avg, districtAvg: distAvg };
}, [entries]);
// 大正态分布曲线点
const curvePoints = useMemo(() => {
const pts: [number, number, number][] = [];
for (let i = -3; i <= 3; i += 0.1) {
const yv = Math.exp(-i * i / 2) / Math.sqrt(2 * Math.PI);
pts.push([x + i * 2.5, 10 + yv * 18, 0]);
}
return pts;
}, [x]);
// 该校在分布上的X位置
const schoolOffset = useMemo(() => {
const zScore = (stats.schoolAvg - 50) / 10;
return Math.max(-3, Math.min(3, zScore)) * 2.5;
}, [stats]);
// 区均值在分布上的X位置
const distOffset = useMemo(() => {
const zScore = (stats.districtAvg - 50) / 10;
return Math.max(-3, Math.min(3, zScore)) * 2.5;
}, [stats]);
return (
<group visible={visible}>
{/* 阶段标题 */}
<Text position={[x, 22, 0]} fontSize={1.6} color="#ba68c8" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* ===== 大正态分布曲线 ===== */}
<Line points={curvePoints} color="#ba68c8" lineWidth={3} transparent opacity={0.8} />
{/* 均值=50 虚线 */}
<Line
points={[[x, 9.5, 0], [x, 18, 0]]}
color="#ba68c8" lineWidth={1} transparent opacity={0.3}
dashed dashSize={0.3} gapSize={0.2}
/>
<Text position={[x, 18.5, 0]} fontSize={0.3} color="#ba68c8" anchorX="center" anchorY="middle">
μ=50
</Text>
{/* 该校标记球 */}
<mesh position={[x + schoolOffset, 10, 0]}
userData={{
tooltip: `该校平均标准化分: ${stats.schoolAvg.toFixed(1)}\n偏离均值: ${(stats.schoolAvg - 50).toFixed(1)}`,
type: 'school-marker',
}}
>
<sphereGeometry args={[0.6, 16, 16]} />
<meshStandardMaterial color="#e53935" emissive="#e53935" emissiveIntensity={0.6} />
</mesh>
<Text position={[x + schoolOffset, 9, 0]} fontSize={0.4} color="#e53935" anchorX="center" anchorY="middle">
{stats.schoolAvg.toFixed(1)}
</Text>
<Text position={[x + schoolOffset, 8.3, 0]} fontSize={0.25} color="#ff8a80" anchorX="center" anchorY="middle" font={CN_FONT}>
</Text>
{/* 区均值标记 */}
<Line
points={[[x + distOffset, 9.5, 0], [x + distOffset, 15, 0]]}
color="#ffd54f" lineWidth={1.5} transparent opacity={0.6}
dashed dashSize={0.4} gapSize={0.2}
/>
<Text position={[x + distOffset, 15.5, 0]} fontSize={0.25} color="#ffd54f" anchorX="center" anchorY="middle" font={CN_FONT}>
{stats.districtAvg.toFixed(1)}
</Text>
{/* ===== 底部:按维度分组的得分对比柱(圆盘中心,正态曲线后方) ===== */}
{dimGrouped.map(([dim, items], di) => {
const dimColor = DIM_COLORS[dim] || '#ba68c8';
const groupX = x - 6 + di * 2; // 左右展开对齐7个维度
const baseY = 1.5; // 圆盘上方
return (
<group key={dim}>
{/* 维度名 — 在柱子下方 */}
<Text
position={[groupX, baseY - 0.3, 0]}
fontSize={0.22} color={dimColor}
anchorX="center" anchorY="middle"
font={CN_FONT} maxWidth={2}
>
{dim.slice(0, 4)}
</Text>
{/* 子维度得分柱 — 沿z负方向排列(曲线后方),不遮挡正面文字 */}
{items.map((item, si) => {
const barH = Math.max(0.3, (item.data.score - 30) / 6);
const z = -(1 + si * 0.8); // z从-1开始往后排
const diffColor = item.data.diff_district > 0 ? '#81c784' : '#ef5350';
return (
<group key={item.name}>
<mesh
position={[groupX, baseY + barH / 2, z]}
userData={{
tooltip: `${item.name}\n得分: ${item.data.score.toFixed(1)}\n区均: ${item.data.district_avg.toFixed(1)}\n差值: ${item.data.diff_district > 0 ? '+' : ''}${item.data.diff_district.toFixed(1)}`,
type: 'sub-dim-bar',
}}
>
<boxGeometry args={[0.5, barH, 0.5]} />
<meshStandardMaterial
color={diffColor}
emissive={diffColor}
emissiveIntensity={0.2}
transparent opacity={0.8}
/>
</mesh>
</group>
);
})}
</group>
);
})}
{/* 公式说明 */}
<group position={[x, 19.5, 0]}>
<mesh>
<boxGeometry args={[8, 1.2, 0.15]} />
<meshStandardMaterial color="#ba68c8" emissive="#ba68c8" emissiveIntensity={0.08} transparent opacity={0.7} />
</mesh>
<Text position={[0, 0, 0.1]} fontSize={0.35} color="#ba68c8" anchorX="center" anchorY="middle" font={CN_FONT}>
{entries.length > 0 ? entries[0][1].city_avg ? '266' : '' : ''}
</Text>
</group>
{/* 底部平台 */}
<mesh position={[x, 0.15, 0]}>
<cylinderGeometry args={[7, 7, 0.3, 32]} />
<meshStandardMaterial color="#ba68c8" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
+182
View File
@@ -0,0 +1,182 @@
/**
* 数据溯源可视化的类型定义
* 对应后端 TraceEngine.compute_trace() 的输出结构
*/
// ====== 阶段0:原始数据 ======
export interface RawField {
name: string;
value: string | number | null;
type: 'number' | 'binary' | 'ordinal' | 'text' | 'null';
subject?: string;
}
export interface RawDataStage {
b_table: {
field_count: number;
sample_fields: RawField[];
};
c_table: {
field_count: number;
subject_count: number;
subjects: Array<{ subject: string; field_count: number }>;
sample_fields: RawField[];
};
total_fields: number;
}
// ====== 阶段1:赋分 ======
export interface ScoringInput {
name: string;
raw: number;
standard?: number;
score: number;
rule: string;
subject?: string;
}
export interface SubFactor {
name: string;
type: 'PCA' | 'Z-score' | string;
input_count?: number;
raw?: number;
threshold?: number;
met?: boolean;
score?: number;
description?: string;
}
export interface ScoringInfo {
method: string;
data_source?: string;
inputs?: ScoringInput[];
sub_factors?: SubFactor[];
sample_inputs?: Array<{ name: string; value: string | number | null; subject?: string }>;
subject_count?: number;
final_score: number | null;
parent_dimension: string;
error?: string;
}
// ====== 阶段2PCA细节 ======
export interface LoadingDetail {
variable: string;
loading: number;
}
export interface PCADetail {
label?: string;
type: 'pca' | 'single_variable_z' | 'subject_level' | 'school_level' | 'multi_factor' | string;
n_schools?: number;
n_variables?: number;
explained_variance_ratio?: number;
loadings?: LoadingDetail[];
school_pca_score?: number | null;
school_standardized?: number | null;
pca_mean?: number;
pca_std?: number;
pipeline?: string;
factor_count?: number;
subject_count?: number;
note?: string;
error?: string;
}
// ====== 阶段3:标准化 ======
export interface StandardizedInfo {
score: number;
district_avg: number;
city_avg: number;
city_std: number;
diff_district: number;
diff_city: number;
parent_dimension: string;
}
// ====== 阶段4:水平判定 ======
export interface LevelInfo {
score: number;
thresholds: {
level2: number;
level3: number;
level4: number;
};
level: number;
description: string;
parent_dimension: string;
}
// ====== 阶段5:维度聚合 ======
export interface DimensionInfo {
sub_scores: Record<string, number>;
score: number | null;
method: string;
district_avg: number | null;
}
export interface OverallInfo {
score: number | null;
method: string;
district_avg: number | null;
rank: number | null;
total_schools: number;
}
// ====== 完整Trace数据 ======
export interface TraceData {
school: string;
school_info: Record<string, string>;
district_school_count: number;
city_school_count: number;
stages: {
raw_data: RawDataStage;
scoring: Record<string, ScoringInfo>;
pca_detail: Record<string, PCADetail>;
standardized: Record<string, StandardizedInfo>;
levels: Record<string, LevelInfo>;
dimensions: Record<string, DimensionInfo>;
overall: OverallInfo;
};
all_schools_overall: Record<string, number>;
}
// ====== 阶段枚举 ======
export const STAGE_NAMES = [
'原始数据',
'赋分规则',
'PCA降维',
'全市标准化',
'水平判定',
'维度聚合',
] as const;
export type StageIndex = 0 | 1 | 2 | 3 | 4 | 5;
// ====== 颜色常量 ======
export const STAGE_COLORS = {
raw: '#4fc3f7',
scoring: '#ffd54f',
pca: '#81c784',
standard: '#ba68c8',
level: '#ff8a65',
dimension: '#ff8a65',
total: '#e53935',
particle: '#4fc3f7',
bg: '#0a0e17',
} as const;
export const DIM_COLORS: Record<string, string> = {
'课程领导力': '#4fc3f7',
'教学变革力': '#81c784',
'学生发展指导力': '#ffd54f',
'教师发展支持力': '#ba68c8',
'教育质量评估力': '#ff8a65',
'教育条件保障力': '#26c6da',
'数字化赋能力': '#ef5350',
};
// 阶段X坐标位置
export const STAGE_POSITIONS = [-35, -21, -7, 7, 21, 35] as const;
// 中文字体路径(drei <Text> 底层 troika 需要完整 TTF/OTF/WOFF2
export const CN_FONT = '/fonts/noto-sans-sc.ttf';
@@ -0,0 +1,180 @@
/**
* InfoPanel — 左侧信息面板
* 根据当前阶段显示不同的说明信息
*/
import type { StageIndex, TraceData } from '../types';
interface Props {
currentStep: StageIndex;
data: TraceData;
}
const STEP_COLORS = ['#4fc3f7', '#ffd54f', '#81c784', '#ba68c8', '#ff8a65', '#e53935'];
function SectionTitle({ text, color }: { text: string; color: string }) {
return (
<div style={{ fontSize: 16, fontWeight: 600, color, marginBottom: 6 }}>
{text}
</div>
);
}
function InfoRow({ label, value, color }: { label: string; value: string | number; color?: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', margin: '3px 0', fontSize: 12 }}>
<span style={{ color: '#8899aa' }}>{label}</span>
<span style={{ color: color || '#ffffff', fontWeight: 500 }}>{value}</span>
</div>
);
}
export default function InfoPanel({ currentStep, data }: Props) {
const color = STEP_COLORS[currentStep];
const renderContent = () => {
switch (currentStep) {
case 0: {
const raw = data.stages.raw_data;
return (
<>
<SectionTitle text="原始数据" color={color} />
<InfoRow label="B表字段数" value={raw.b_table.field_count} />
<InfoRow label="C表字段数" value={raw.c_table.field_count} />
<InfoRow label="涵盖学科" value={`${raw.c_table.subject_count}`} />
<InfoRow label="总字段数" value={raw.total_fields} />
<div style={{ marginTop: 10, fontSize: 11, color: '#667788', lineHeight: 1.6 }}>
B表<br />
C表
</div>
</>
);
}
case 1: {
const scoring = data.stages.scoring;
const subDims = Object.keys(scoring);
return (
<>
<SectionTitle text="赋分规则" color={color} />
<InfoRow label="子维度总数" value={subDims.length} />
<InfoRow label="赋分方式" value="Likert/分档/二值" />
<div style={{ marginTop: 8 }}>
{subDims.slice(0, 8).map((name) => (
<div key={name} style={{ fontSize: 11, color: '#8899aa', margin: '2px 0', display: 'flex', justifyContent: 'space-between' }}>
<span>{name}</span>
<span style={{ color: '#ffffff' }}>{scoring[name].final_score?.toFixed(1) ?? '-'}</span>
</div>
))}
{subDims.length > 8 && (
<div style={{ fontSize: 11, color: '#556677', marginTop: 4 }}>...{subDims.length}</div>
)}
</div>
</>
);
}
case 2: {
const pca = data.stages.pca_detail;
const pcaEntries = Object.entries(pca);
const hasPCA = pcaEntries.find(([, d]) => d.type === 'pca');
return (
<>
<SectionTitle text="PCA降维" color={color} />
<InfoRow label="全市学校数" value={data.city_school_count} />
{hasPCA && (
<>
<InfoRow label="示例子维度" value={hasPCA[0]} />
<InfoRow label="输入变量数" value={hasPCA[1].n_variables ?? '-'} />
<InfoRow label="解释方差" value={`${((hasPCA[1].explained_variance_ratio ?? 0) * 100).toFixed(0)}%`} color="#81c784" />
<InfoRow label="该校PCA得分" value={hasPCA[1].school_standardized?.toFixed(1) ?? '-'} color="#e53935" />
</>
)}
<div style={{ marginTop: 10, fontSize: 11, color: '#667788', lineHeight: 1.6 }}>
Z标准化 PCA提取第一主成分 线5010
</div>
</>
);
}
case 3: {
const std = data.stages.standardized;
const entries = Object.entries(std).slice(0, 6);
return (
<>
<SectionTitle text="全市标准化" color={color} />
<div style={{ fontSize: 11, color: '#667788', marginBottom: 8, lineHeight: 1.6 }}>
PCA变换后5010
</div>
{entries.map(([name, d]) => (
<div key={name} style={{ fontSize: 11, margin: '2px 0', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#8899aa' }}>{name}</span>
<span>
<span style={{ color: d.diff_district > 0 ? '#81c784' : '#ef5350' }}>
{d.diff_district > 0 ? '+' : ''}{d.diff_district.toFixed(1)}
</span>
<span style={{ color: '#556677', marginLeft: 4 }}>({d.score.toFixed(1)})</span>
</span>
</div>
))}
</>
);
}
case 4: {
const lvls = data.stages.levels;
const counts = { 1: 0, 2: 0, 3: 0, 4: 0 };
for (const d of Object.values(lvls)) {
const lv = Math.max(1, Math.min(4, d.level)) as 1 | 2 | 3 | 4;
counts[lv]++;
}
return (
<>
<SectionTitle text="水平判定" color={color} />
<InfoRow label="水平4(优秀)" value={`${counts[4]}`} color="#81c784" />
<InfoRow label="水平3(良好)" value={`${counts[3]}`} color="#ffd54f" />
<InfoRow label="水平2(合格)" value={`${counts[2]}`} color="#ff9800" />
<InfoRow label="水平1(尚需努力)" value={`${counts[1]}`} color="#ef5350" />
<div style={{ marginTop: 10, fontSize: 11, color: '#667788', lineHeight: 1.6 }}>
4
</div>
</>
);
}
case 5: {
const dims = data.stages.dimensions;
const overall = data.stages.overall;
return (
<>
<SectionTitle text="维度聚合" color={color} />
{Object.entries(dims).map(([name, d]) => (
<InfoRow
key={name}
label={name}
value={d.score?.toFixed(1) ?? '-'}
color={d.score != null && d.district_avg != null && d.score > d.district_avg ? '#81c784' : '#ef5350'}
/>
))}
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)', margin: '8px 0' }} />
<InfoRow label="总体得分" value={overall.score?.toFixed(1) ?? '-'} color="#e53935" />
<InfoRow label="区内排名" value={`${overall.rank ?? '-'}名 / ${overall.total_schools}`} />
</>
);
}
}
};
return (
<div style={{
position: 'absolute',
top: 80,
left: 16,
width: 260,
background: 'rgba(10,14,23,0.88)',
borderRadius: 12,
padding: 16,
backdropFilter: 'blur(10px)',
border: '1px solid rgba(79,195,247,0.2)',
zIndex: 100,
maxHeight: '70vh',
overflowY: 'auto',
}}>
{renderContent()}
</div>
);
}
@@ -0,0 +1,107 @@
/**
* StepBar — 底部步骤控制条
*/
import React from 'react';
import type { StageIndex } from '../types';
import { STAGE_NAMES } from '../types';
interface Props {
currentStep: StageIndex;
onStepChange: (step: StageIndex) => void;
autoPlaying: boolean;
onToggleAutoPlay: () => void;
onReset: () => void;
onNext: () => void;
onPrev: () => void;
}
const STEP_COLORS = ['#4fc3f7', '#ffd54f', '#81c784', '#ba68c8', '#ff8a65', '#e53935'];
export default function StepBar({
currentStep, onStepChange, autoPlaying, onToggleAutoPlay, onReset, onNext, onPrev,
}: Props) {
return (
<div style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 6,
background: 'rgba(10,14,23,0.85)',
borderRadius: 16,
padding: '10px 18px',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(79,195,247,0.2)',
zIndex: 100,
}}>
{/* 控制按钮 */}
<button onClick={onReset} style={btnStyle} title="重置(R)"></button>
<button onClick={onPrev} style={btnStyle} title="上一步(←)"></button>
<button onClick={onToggleAutoPlay} style={{
...btnStyle,
background: autoPlaying ? 'rgba(79,195,247,0.3)' : undefined,
}} title="自动播放">
{autoPlaying ? '⏸' : '▶'}
</button>
<button onClick={onNext} style={btnStyle} title="下一步(→)"></button>
{/* 分隔线 */}
<div style={{ width: 1, height: 28, background: 'rgba(79,195,247,0.2)', margin: '0 6px' }} />
{/* 步骤点 */}
{STAGE_NAMES.map((name, i) => (
<div
key={name}
onClick={() => onStepChange(i as StageIndex)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 8,
background: currentStep === i ? 'rgba(79,195,247,0.15)' : 'transparent',
transition: 'all 0.3s',
}}
>
{/* 圆点 */}
<div style={{
width: 14,
height: 14,
borderRadius: '50%',
background: i <= currentStep ? STEP_COLORS[i] : 'rgba(255,255,255,0.1)',
border: currentStep === i ? `2px solid ${STEP_COLORS[i]}` : '2px solid transparent',
boxShadow: currentStep === i ? `0 0 8px ${STEP_COLORS[i]}80` : 'none',
transition: 'all 0.3s',
}} />
{/* 标签 */}
<span style={{
fontSize: 10,
color: i <= currentStep ? STEP_COLORS[i] : '#556677',
marginTop: 4,
whiteSpace: 'nowrap',
transition: 'color 0.3s',
}}>
{name}
</span>
</div>
))}
</div>
);
}
const btnStyle: React.CSSProperties = {
width: 36,
height: 36,
borderRadius: 8,
border: '1px solid rgba(79,195,247,0.2)',
background: 'transparent',
color: '#4fc3f7',
fontSize: 18,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
@@ -0,0 +1,35 @@
/**
* Tooltip — 悬浮提示框(屏幕坐标定位)
*/
import type { TooltipData } from '../Interaction';
interface Props {
data: TooltipData;
}
export default function Tooltip({ data }: Props) {
if (!data.visible || !data.text) return null;
return (
<div style={{
position: 'fixed',
left: data.x,
top: data.y,
maxWidth: 280,
padding: '8px 12px',
background: 'rgba(10,14,23,0.92)',
border: '1px solid rgba(79,195,247,0.35)',
borderRadius: 8,
color: '#e0e0e0',
fontSize: 12,
lineHeight: 1.6,
pointerEvents: 'none',
zIndex: 200,
backdropFilter: 'blur(8px)',
whiteSpace: 'pre-line',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
}}>
{data.text}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Dropdown, Select } from 'antd';
import {
GlobalOutlined,
HistoryOutlined,
BarChartOutlined,
UserOutlined,
LogoutOutlined,
TranslationOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../App';
const { Header, Sider, Content } = Layout;
const { Title } = Typography;
export default function AppLayout() {
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const { username, logout } = useAuth();
const { t, i18n } = useTranslation();
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const handleLangChange = (lang: string) => {
i18n.changeLanguage(lang);
};
const menuItems = [
{ key: '/', icon: <GlobalOutlined />, label: t('layout.menu_districts') },
{ key: '/history', icon: <HistoryOutlined />, label: t('layout.menu_history') },
];
const getSelectedKey = () => {
const path = location.pathname;
if (path === '/history') return '/history';
return '/';
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}
theme="light" style={{ boxShadow: '2px 0 8px rgba(0,0,0,0.06)' }}>
<div style={{
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid #f0f0f0',
}}>
<BarChartOutlined style={{ fontSize: 24, color: '#1677ff' }} />
{!collapsed && (
<Title level={5} style={{ margin: '0 0 0 8px', whiteSpace: 'nowrap' }}>
{i18n.language === 'en' ? 'Reports' : '监测报告'}
</Title>
)}
</div>
<Menu mode="inline" selectedKeys={[getSelectedKey()]} items={menuItems}
onClick={({ key }) => navigate(key)} style={{ borderRight: 0 }} />
</Sider>
<Layout>
<Header style={{
background: '#fff', padding: '0 24px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)', zIndex: 1,
}}>
<Title level={4} style={{ margin: 0 }}>
{t('layout.title')}
</Title>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Select
size="small"
value={i18n.language.startsWith('en') ? 'en' : 'zh'}
onChange={handleLangChange}
style={{ width: 110 }}
suffixIcon={<TranslationOutlined />}
options={[
{ value: 'zh', label: '🇨🇳 中文' },
{ value: 'en', label: '🇬🇧 English' },
]}
/>
<Dropdown menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: t('layout.logout'),
onClick: handleLogout,
},
],
}} placement="bottomRight">
<Button type="text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UserOutlined />
<span>{username || 'admin'}</span>
</Button>
</Dropdown>
</div>
</Header>
<Content style={{ margin: 16, minHeight: 280 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+33
View File
@@ -0,0 +1,33 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import zh from './locales/zh.json';
import en from './locales/en.json';
export const SUPPORTED_LANGS = ['zh', 'en'] as const;
export type AdminLang = typeof SUPPORTED_LANGS[number];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'zh',
supportedLngs: SUPPORTED_LANGS,
defaultNS: 'translation',
interpolation: {
escapeValue: false,
},
detection: {
// 优先 localStorage,其次浏览器,最后 fallback
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'admin_lang',
},
resources: {
zh: { translation: zh },
en: { translation: en },
},
});
export default i18n;
+128
View File
@@ -0,0 +1,128 @@
{
"common": {
"loading": "Loading...",
"verifying_login": "Verifying login...",
"back": "Back",
"back_to_overview": "Back to overview",
"preview": "Preview",
"download": "Download",
"generate": "Generate",
"regenerate": "Regenerate",
"schools_unit": "schools",
"of": "of",
"school": "School",
"district": "District",
"type": "Type",
"score": "Score",
"rank": "Rank",
"city_rank": "Municipal Rank",
"district_rank": "District Rank",
"cluster": "Cluster",
"report": "Report",
"actions": "Actions",
"generated": "Generated",
"not_generated": "Not generated",
"language": "Language",
"lang_zh": "中文",
"lang_en": "English",
"lang_both": "Bilingual",
"open_new_window": "New window",
"print": "Print",
"data_trace": "Data trace",
"city_overview": "Citywide overview",
"skip_llm_label": "Skip LLM narrative (charts only):",
"select_all": "Select all",
"clear_selection": "Clear"
},
"layout": {
"title": "Shanghai Senior Secondary Curriculum Implementation Monitoring · Report Admin",
"menu_districts": "Districts",
"menu_history": "History",
"logout": "Logout"
},
"auth": {
"login_title": "Sign in",
"login_subtitle": "Shanghai Senior Secondary Curriculum Implementation Monitoring · Report Admin",
"username": "Username",
"password": "Password",
"login_button": "Sign in",
"username_placeholder": "Enter your username",
"password_placeholder": "Enter your password",
"login_failed": "Login failed: invalid username or password",
"login_success": "Signed in"
},
"districts": {
"title": "Citywide District Overview",
"subtitle": "16 districts · {{n}} senior secondary schools — curriculum implementation monitoring",
"school_count": "Schools",
"avg_score": "District avg.",
"report_count": "Reports generated",
"report_count_zh_en": "ZH {{zh}} · EN {{en}}",
"view_schools": "View schools",
"fetch_failed": "Failed to fetch district data"
},
"schools": {
"title": "{{district}} · Senior Secondary Curriculum Implementation Monitoring",
"fetch_failed": "Failed to fetch school list",
"stats_school_count": "Schools",
"stats_district_avg": "District average",
"stats_reports": "Reports generated",
"batch_generate": "Batch generate",
"batch_running": "Generating...",
"generation_card_text": "Generating report for {{school}}",
"generation_card_lang_both": "Bilingual · current: {{currentLangLabel}}",
"generation_segments": "{{progress}}/{{total}} segments",
"batch_card_text": "Batch generating: {{school}}",
"batch_progress_text": "{{completed}}/{{total}}",
"preview_zh": "Preview · ZH",
"preview_en": "Preview · EN",
"single_dropdown_zh": "Regenerate Chinese",
"single_dropdown_zh_new": "Generate Chinese",
"single_dropdown_en": "Regenerate English",
"single_dropdown_en_new": "Generate English",
"single_dropdown_both": "Generate bilingual",
"batch_modal_title": "Batch generate · {{district}}",
"batch_modal_ok": "Start ({{n}} schools)",
"batch_modal_lang": "Language:",
"single_started_failed": "Failed to start generation task",
"single_finished": "{{school}} {{langLabel}} report generated. Score: {{score}}",
"single_failed": "Generation failed: {{error}}",
"batch_started_failed": "Failed to start batch generation",
"batch_finished": "Batch generation done: {{ok}}/{{total}}"
},
"preview": {
"report_title": "Curriculum Implementation Monitoring Report",
"loading": "Loading report...",
"load_failed": "Failed to load the report. Has the requested language version been generated?"
},
"history": {
"title": "Report History",
"search_placeholder": "Search school or district...",
"header_school": "School",
"header_district": "District",
"header_type": "Type",
"header_score": "Score",
"header_size": "Size",
"header_generated_at": "Generated at",
"header_lang": "Language",
"header_actions": "Actions",
"fetch_failed": "Failed to fetch report history"
},
"trace": {
"title": "Data Trace",
"back": "Back to report"
},
"chat": {
"title": "AI Report Assistant",
"clear": "Clear conversation",
"close": "Close",
"input_placeholder": "Type your question...",
"request_failed_prefix": "⚠️ Request failed: ",
"welcome_l1": "Hello! I have full access to the curriculum-implementation monitoring data for {{school}}.",
"welcome_l2": "Ask any question directly, or pick a quick prompt below to begin.",
"quick_overall": "How does the school perform overall?",
"quick_gap": "What are the most significant gaps?",
"quick_urgent": "Which improvements are most urgent?",
"quick_compare": "How does it compare with peer-type schools?"
}
}
+128
View File
@@ -0,0 +1,128 @@
{
"common": {
"loading": "加载中...",
"verifying_login": "验证登录状态...",
"back": "返回",
"back_to_overview": "返回全市",
"preview": "预览",
"download": "下载",
"generate": "生成",
"regenerate": "重新生成",
"schools_unit": "所",
"of": "/",
"school": "学校",
"district": "区",
"type": "类型",
"score": "总分",
"rank": "排名",
"city_rank": "全市排名",
"district_rank": "区内排名",
"cluster": "聚类",
"report": "报告",
"actions": "操作",
"generated": "已生成",
"not_generated": "未生成",
"language": "语言",
"lang_zh": "中文",
"lang_en": "English",
"lang_both": "中英双语",
"open_new_window": "新窗口",
"print": "打印",
"data_trace": "数据溯源",
"city_overview": "全市概览",
"skip_llm_label": "跳过LLM文字(仅图表):",
"select_all": "全选",
"clear_selection": "清空"
},
"layout": {
"title": "上海高中课程实施监测 · 报告管理系统",
"menu_districts": "区域概览",
"menu_history": "历史记录",
"logout": "退出登录"
},
"auth": {
"login_title": "登录",
"login_subtitle": "上海高中课程实施监测 · 报告管理系统",
"username": "用户名",
"password": "密码",
"login_button": "登 录",
"username_placeholder": "请输入用户名",
"password_placeholder": "请输入密码",
"login_failed": "登录失败:用户名或密码错误",
"login_success": "登录成功"
},
"districts": {
"title": "全市区域概览",
"subtitle": "上海市16个区·{{n}}所高中课程实施监测",
"school_count": "学校数",
"avg_score": "区均分",
"report_count": "已生成报告",
"report_count_zh_en": "中文 {{zh}} · 英文 {{en}}",
"view_schools": "查看学校",
"fetch_failed": "获取区域数据失败"
},
"schools": {
"title": "{{district}} · 高中课程实施监测",
"fetch_failed": "获取学校列表失败",
"stats_school_count": "学校数",
"stats_district_avg": "区内均分",
"stats_reports": "已生成报告",
"batch_generate": "批量生成",
"batch_running": "生成中...",
"generation_card_text": "正在生成 {{school}} 的报告",
"generation_card_lang_both": "中英双语 · 当前{{currentLangLabel}}",
"generation_segments": "{{progress}}/{{total}} 段",
"batch_card_text": "批量生成中: {{school}}",
"batch_progress_text": "{{completed}}/{{total}}",
"preview_zh": "预览·中",
"preview_en": "预览·EN",
"single_dropdown_zh": "重新生成中文",
"single_dropdown_zh_new": "生成中文",
"single_dropdown_en": "重新生成英文",
"single_dropdown_en_new": "生成英文",
"single_dropdown_both": "生成中英双语",
"batch_modal_title": "批量生成 · {{district}}",
"batch_modal_ok": "开始生成 ({{n}}所)",
"batch_modal_lang": "语言:",
"single_started_failed": "启动生成任务失败",
"single_finished": "{{school}} {{langLabel}}报告生成完成!得分: {{score}}分",
"single_failed": "生成失败: {{error}}",
"batch_started_failed": "启动批量生成失败",
"batch_finished": "批量生成完成: {{ok}}/{{total}}"
},
"preview": {
"report_title": "课程实施监测报告",
"loading": "加载报告中...",
"load_failed": "报告加载失败,可能尚未生成对应语言的版本"
},
"history": {
"title": "报告历史",
"search_placeholder": "搜索学校或区...",
"header_school": "学校",
"header_district": "区",
"header_type": "类型",
"header_score": "得分",
"header_size": "大小",
"header_generated_at": "生成时间",
"header_lang": "语言",
"header_actions": "操作",
"fetch_failed": "获取历史记录失败"
},
"trace": {
"title": "数据溯源",
"back": "返回报告"
},
"chat": {
"title": "AI 报告助手",
"clear": "清空对话",
"close": "关闭",
"input_placeholder": "输入问题...",
"request_failed_prefix": "⚠️ 请求失败: ",
"welcome_l1": "您好!我已了解 {{school}} 课程实施监测报告的全部数据。",
"welcome_l2": "您可以直接提问,或点击下方快捷问题开始。",
"quick_overall": "总体表现如何?",
"quick_gap": "最大的短板是什么?",
"quick_urgent": "最紧迫应该改进什么?",
"quick_compare": "与同类学校对比如何?"
}
}
+24
View File
@@ -0,0 +1,24 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #f5f5f5;
}
/* 报告 iframe 全屏样式 */
.report-iframe {
width: 100%;
border: none;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import './i18n'; // 初始化 i18next(必须在任何使用 t() 的组件之前)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+240
View File
@@ -0,0 +1,240 @@
import { useState, useRef, useEffect } from 'react';
import {
Card, Input, Button, Select, Space, Typography, Spin, message,
Tag, Divider,
} from 'antd';
import {
SendOutlined, RobotOutlined, UserOutlined, ClearOutlined,
BulbOutlined,
} from '@ant-design/icons';
import { getDistricts, getSchoolsInDistrict, sendChatMessage } from '../services/era2Api';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
const QUICK_PROMPTS = [
'分析这所学校的优势和劣势',
'给出课程领导力的改进建议',
'对比该校与同类学校的差异',
'总结七大维度的表现特征',
'分析教师发展支持力偏低的原因',
];
export default function Assistant() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
// 上下文选择
const [districts, setDistricts] = useState<string[]>([]);
const [selectedDistrict, setSelectedDistrict] = useState<string | undefined>();
const [schoolOptions, setSchoolOptions] = useState<string[]>([]);
const [selectedSchool, setSelectedSchool] = useState<string | undefined>();
const chatEndRef = useRef<HTMLDivElement>(null);
// 加载区列表
useEffect(() => {
getDistricts().then(res => {
setDistricts(res.data.districts.map(d => d.district));
}).catch(() => {});
}, []);
// 区变化时加载学校
useEffect(() => {
if (!selectedDistrict) {
setSchoolOptions([]);
setSelectedSchool(undefined);
return;
}
getSchoolsInDistrict(selectedDistrict).then(res => {
setSchoolOptions(res.data.schools.map(s => s.name));
}).catch(() => {});
}, [selectedDistrict]);
// 自动滚动
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async (text?: string) => {
const msg = text || input.trim();
if (!msg) return;
const userMsg: ChatMessage = { role: 'user', content: msg };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
// 构建历史
const history = messages.map(m => ({ role: m.role, content: m.content }));
try {
const response = await sendChatMessage(msg, selectedSchool, selectedDistrict, history);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No reader');
const decoder = new TextDecoder();
let assistantContent = '';
// 添加空的assistant消息
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.content) {
assistantContent += parsed.content;
setMessages(prev => {
const newMsgs = [...prev];
newMsgs[newMsgs.length - 1] = { role: 'assistant', content: assistantContent };
return newMsgs;
});
}
if (parsed.error) {
message.error(`LLM错误: ${parsed.error}`);
}
} catch {
// skip parse errors
}
}
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
message.error(`请求失败: ${errorMessage}`);
setMessages(prev => [...prev, {
role: 'assistant',
content: '抱歉,请求处理失败。请检查网络连接和后端服务状态。',
}]);
} finally {
setLoading(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 130px)' }}>
{/* 上下文选择 */}
<Card size="small" style={{ marginBottom: 12 }}>
<Space wrap>
<Text strong><BulbOutlined /> :</Text>
<Select placeholder="选择区" value={selectedDistrict} onChange={v => { setSelectedDistrict(v); setSelectedSchool(undefined); }}
allowClear style={{ width: 130 }} showSearch filterOption={(input, option) =>
(option?.children as unknown as string)?.includes(input) || false}>
{districts.map(d => <Select.Option key={d} value={d}>{d}</Select.Option>)}
</Select>
<Select placeholder="选择学校" value={selectedSchool} onChange={setSelectedSchool}
allowClear disabled={!selectedDistrict} style={{ width: 180 }}
showSearch filterOption={(input, option) =>
(option?.children as unknown as string)?.includes(input) || false}>
{schoolOptions.map(s => <Select.Option key={s} value={s}>{s}</Select.Option>)}
</Select>
{selectedSchool && selectedDistrict && (
<Tag color="blue">{selectedDistrict} · {selectedSchool}</Tag>
)}
{messages.length > 0 && (
<Button size="small" icon={<ClearOutlined />} onClick={() => setMessages([])}>
</Button>
)}
</Space>
</Card>
{/* 对话区域 */}
<Card style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, overflow: 'auto', padding: '16px' } }}>
{messages.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<RobotOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 16 }} />
<Paragraph type="secondary" style={{ fontSize: 16 }}>
</Paragraph>
<Paragraph type="secondary">
</Paragraph>
<Divider></Divider>
<Space wrap style={{ justifyContent: 'center' }}>
{QUICK_PROMPTS.map(p => (
<Button key={p} size="small" onClick={() => handleSend(p)}
style={{ borderRadius: 16, marginBottom: 4 }}>
{p}
</Button>
))}
</Space>
</div>
) : (
<div>
{messages.map((msg, idx) => (
<div key={idx} style={{
display: 'flex', gap: 12, marginBottom: 16,
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
}}>
<div style={{
width: 36, height: 36, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: msg.role === 'user' ? '#1677ff' : '#f0f5ff',
color: msg.role === 'user' ? '#fff' : '#1677ff',
}}>
{msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
</div>
<div style={{
maxWidth: '75%', padding: '10px 16px', borderRadius: 12,
background: msg.role === 'user' ? '#1677ff' : '#f5f5f5',
color: msg.role === 'user' ? '#fff' : '#333',
whiteSpace: 'pre-wrap', lineHeight: 1.6,
}}>
{msg.content || (loading && idx === messages.length - 1 ? (
<Spin size="small" />
) : '')}
</div>
</div>
))}
<div ref={chatEndRef} />
</div>
)}
</Card>
{/* 输入区 */}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<TextArea
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入问题... (Shift+Enter换行)"
autoSize={{ minRows: 1, maxRows: 4 }}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
disabled={loading}
style={{ flex: 1, borderRadius: 8 }}
/>
<Button type="primary" icon={<SendOutlined />} onClick={() => handleSend()}
loading={loading} style={{ height: 'auto', borderRadius: 8 }}>
</Button>
</div>
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card, Button, Space, Table, Tag, Progress, Switch, message,
Typography, Alert, Row, Col, Statistic, Divider, Checkbox,
} from 'antd';
import {
ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined,
LoadingOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
getSchools, batchGenerate, getBatchStatus,
type SchoolSummary, type BatchStatus,
} from '../services/api';
const { Text } = Typography;
export default function BatchGenerate() {
const [schools, setSchools] = useState<SchoolSummary[]>([]);
const [selectedSchools, setSelectedSchools] = useState<string[]>([]);
const [, setLoading] = useState(true);
const [useCache, setUseCache] = useState(true);
const [skipLlm, setSkipLlm] = useState(false);
// 批量生成状态
const [batchId, setBatchId] = useState<string | null>(null);
const [batchStatus, setBatchStatus] = useState<BatchStatus | null>(null);
const [polling, setPolling] = useState(false);
const fetchSchools = useCallback(async () => {
setLoading(true);
try {
const res = await getSchools();
setSchools(res.data.schools);
// 默认全选
setSelectedSchools(res.data.schools.map(s => s.name));
} catch {
message.error('获取学校列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSchools();
}, [fetchSchools]);
// 轮询批量状态
useEffect(() => {
if (!batchId || !polling) return;
const timer = setInterval(async () => {
try {
const res = await getBatchStatus(batchId);
setBatchStatus(res.data);
if (res.data.status === 'completed' || res.data.status === 'failed') {
setPolling(false);
clearInterval(timer);
if (res.data.status === 'completed') {
const success = res.data.results.filter(r => r.status === 'success').length;
message.success(`批量生成完成!成功 ${success}/${res.data.total} 所学校`);
fetchSchools();
}
}
} catch {
// ignore
}
}, 2000);
return () => clearInterval(timer);
}, [batchId, polling, fetchSchools]);
const handleBatchGenerate = async () => {
try {
const schoolsToGen = selectedSchools.length === schools.length
? undefined // 全部
: selectedSchools;
const res = await batchGenerate(schoolsToGen, useCache, skipLlm);
setBatchId(res.data.batch_id);
setBatchStatus({
status: 'running',
schools: res.data.schools,
total: res.data.total,
completed: 0,
results: [],
});
setPolling(true);
message.info(`开始批量生成 ${res.data.total} 所学校的报告...`);
} catch (err) {
message.error('启动批量生成失败');
}
};
const isRunning = batchStatus?.status === 'running';
const resultColumns: ColumnsType<BatchStatus['results'][0]> = [
{
title: '学校',
dataIndex: 'school',
key: 'school',
render: (name: string) => <Text strong>{name}</Text>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => status === 'success'
? <Tag icon={<CheckCircleOutlined />} color="success"></Tag>
: <Tag icon={<CloseCircleOutlined />} color="error"></Tag>,
},
{
title: '总分',
dataIndex: 'score',
key: 'score',
width: 80,
render: (score?: number) => score != null ? score.toFixed(2) : '-',
},
{
title: '排名',
dataIndex: 'rank',
key: 'rank',
width: 80,
render: (rank?: number) => rank != null ? `${rank}/9` : '-',
},
{
title: '聚类',
dataIndex: 'cluster',
key: 'cluster',
width: 100,
render: (cluster?: string) => cluster
? <Tag color={cluster === '较好' ? 'success' : 'warning'}>{cluster}</Tag>
: '-',
},
{
title: '耗时',
dataIndex: 'time',
key: 'time',
width: 80,
render: (t?: number) => t != null ? `${t}s` : '-',
},
];
return (
<div>
{/* 控制面板 */}
<Card title="批量生成配置" bordered={false} style={{ marginBottom: 16 }}>
<Row gutter={24} align="middle">
<Col>
<Space>
<Text>使</Text>
<Switch checked={useCache} onChange={setUseCache} disabled={isRunning} />
</Space>
</Col>
<Col>
<Space>
<Text>LLM</Text>
<Switch checked={skipLlm} onChange={setSkipLlm} disabled={isRunning} />
</Space>
</Col>
<Col flex="auto" style={{ textAlign: 'right' }}>
<Button
type="primary"
size="large"
icon={isRunning ? <LoadingOutlined /> : <ThunderboltOutlined />}
loading={isRunning}
onClick={handleBatchGenerate}
disabled={isRunning || selectedSchools.length === 0}
>
{isRunning ? '生成中...' : `批量生成 (${selectedSchools.length} 所学校)`}
</Button>
</Col>
</Row>
{/* 学校选择 */}
<Divider style={{ margin: '16px 0' }} />
<Checkbox.Group
value={selectedSchools}
onChange={(v) => setSelectedSchools(v as string[])}
disabled={isRunning}
>
<Row gutter={[16, 8]}>
{schools.map(s => (
<Col span={8} key={s.name}>
<Checkbox value={s.name}>
{s.name}
<Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>
({s.type})
</Text>
</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
<div style={{ marginTop: 8 }}>
<Button
size="small"
type="link"
onClick={() => setSelectedSchools(schools.map(s => s.name))}
disabled={isRunning}
>
</Button>
<Button
size="small"
type="link"
onClick={() => setSelectedSchools([])}
disabled={isRunning}
>
</Button>
</div>
</Card>
{/* 生成进度 */}
{batchStatus && (
<Card title="生成进度" bordered={false} style={{ marginBottom: 16 }}>
<Row gutter={24} style={{ marginBottom: 16 }}>
<Col span={6}>
<Statistic
title="总学校数"
value={batchStatus.total}
suffix="所"
/>
</Col>
<Col span={6}>
<Statistic
title="已完成"
value={batchStatus.completed}
suffix={`/ ${batchStatus.total}`}
valueStyle={{ color: '#1677ff' }}
/>
</Col>
<Col span={6}>
<Statistic
title="状态"
value={
batchStatus.status === 'running' ? '生成中' :
batchStatus.status === 'completed' ? '已完成' : '失败'
}
valueStyle={{
color: batchStatus.status === 'completed' ? '#52c41a' :
batchStatus.status === 'running' ? '#1677ff' : '#ff4d4f',
}}
/>
</Col>
<Col span={6}>
{batchStatus.elapsed && (
<Statistic
title="总耗时"
value={batchStatus.elapsed}
suffix="秒"
prefix={<ClockCircleOutlined />}
/>
)}
</Col>
</Row>
<Progress
percent={Math.round((batchStatus.completed / batchStatus.total) * 100)}
status={batchStatus.status === 'completed' ? 'success' :
batchStatus.status === 'running' ? 'active' : 'exception'}
strokeWidth={12}
/>
{batchStatus.current_school && batchStatus.status === 'running' && (
<Alert
type="info"
showIcon
icon={<LoadingOutlined />}
message={`正在生成: ${batchStatus.current_school}`}
style={{ marginTop: 12 }}
/>
)}
</Card>
)}
{/* 生成结果 */}
{batchStatus?.results && batchStatus.results.length > 0 && (
<Card title="生成结果" bordered={false}>
<Table
columns={resultColumns}
dataSource={batchStatus.results}
rowKey="school"
pagination={false}
size="small"
/>
</Card>
)}
</div>
);
}
+285
View File
@@ -0,0 +1,285 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card, Table, Tag, Button, Space, Statistic, Row, Col, message, Progress, Typography,
} from 'antd';
import {
EyeOutlined, DownloadOutlined, SyncOutlined,
TrophyOutlined, TeamOutlined, FileTextOutlined, CheckCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
getSchools, generateReport, getTaskStatus,
getReportDownloadUrl,
type SchoolSummary,
} from '../services/api';
const { Text } = Typography;
export default function Dashboard() {
const navigate = useNavigate();
const [schools, setSchools] = useState<SchoolSummary[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [genProgress, setGenProgress] = useState(0);
const [genTotal, setGenTotal] = useState(29);
const fetchSchools = useCallback(async () => {
setLoading(true);
try {
const res = await getSchools();
setSchools(res.data.schools);
} catch (err) {
message.error('获取学校列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSchools();
}, [fetchSchools]);
const handleGenerate = async (school: string, useCache: boolean) => {
try {
setGenerating(school);
setGenProgress(0);
const res = await generateReport(school, useCache);
const taskId = res.data.task_id;
// 轮询状态
const poll = setInterval(async () => {
try {
const status = await getTaskStatus(taskId);
setGenProgress(status.data.progress);
setGenTotal(status.data.total);
if (status.data.status === 'completed') {
clearInterval(poll);
setGenerating(null);
message.success(`${school} 报告生成完成!得分: ${status.data.score}`);
fetchSchools();
} else if (status.data.status === 'failed') {
clearInterval(poll);
setGenerating(null);
message.error(`${school} 报告生成失败: ${status.data.error}`);
}
} catch {
clearInterval(poll);
setGenerating(null);
}
}, 1000);
} catch (err) {
setGenerating(null);
message.error('启动生成任务失败');
}
};
const columns: ColumnsType<SchoolSummary> = [
{
title: '排名',
dataIndex: 'rank',
key: 'rank',
width: 70,
align: 'center',
render: (rank: number) => (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: rank <= 3 ? '#1677ff' : '#f0f0f0',
color: rank <= 3 ? '#fff' : '#666',
fontWeight: 600, fontSize: 13,
}}>
{rank}
</span>
),
},
{
title: '学校名称',
dataIndex: 'name',
key: 'name',
render: (name: string) => <Text strong>{name}</Text>,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => {
const colorMap: Record<string, string> = {
'市实验性示范性高中': 'blue',
'区实验性示范性高中': 'cyan',
'特色高中': 'purple',
'公办普通高中': 'green',
'民办高中': 'orange',
};
return <Tag color={colorMap[type] || 'default'}>{type}</Tag>;
},
},
{
title: '总分',
dataIndex: 'score',
key: 'score',
width: 100,
align: 'center',
sorter: (a, b) => a.score - b.score,
render: (score: number) => (
<Text strong style={{ fontSize: 16, color: score >= 50 ? '#52c41a' : '#faad14' }}>
{score}
</Text>
),
},
{
title: '聚类',
dataIndex: 'cluster',
key: 'cluster',
width: 100,
align: 'center',
render: (cluster: string) => (
<Tag color={cluster === '较好' ? 'success' : 'warning'}>{cluster}</Tag>
),
},
{
title: '报告状态',
dataIndex: 'has_report',
key: 'has_report',
width: 200,
align: 'center',
render: (has: boolean, record) => has ? (
<Space direction="vertical" size={0} style={{ lineHeight: 1.4 }}>
<Space size={4}>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
{(record.report_size / 1024).toFixed(0)}KB
</Text>
</Space>
{record.report_generated_at && (
<Text type="secondary" style={{ fontSize: 11 }}>
{record.report_generated_at}
</Text>
)}
</Space>
) : (
<Tag color="default"></Tag>
),
},
{
title: '操作',
key: 'actions',
width: 280,
render: (_, record) => (
<Space size="small">
{record.has_report && (
<>
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/report/${encodeURIComponent(record.name)}`)}
>
</Button>
<Button
size="small"
icon={<DownloadOutlined />}
href={getReportDownloadUrl(record.name)}
>
</Button>
</>
)}
<Button
size="small"
icon={<SyncOutlined spin={generating === record.name} />}
loading={generating === record.name}
onClick={() => handleGenerate(record.name, true)}
>
{record.has_report ? '重新生成' : '生成'}
</Button>
</Space>
),
},
];
// 统计数据
const totalSchools = schools.length;
const avgScore = totalSchools > 0
? (schools.reduce((s, r) => s + r.score, 0) / totalSchools).toFixed(1)
: '0';
const reportCount = schools.filter(s => s.has_report).length;
const goodCount = schools.filter(s => s.cluster === '较好').length;
return (
<div>
{/* 顶部统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="学校总数"
value={totalSchools}
prefix={<TeamOutlined />}
suffix="所"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="区内均分"
value={avgScore}
prefix={<TrophyOutlined />}
precision={1}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="课程实施较好类"
value={goodCount}
suffix={`/ ${totalSchools}`}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已生成报告"
value={reportCount}
prefix={<FileTextOutlined />}
suffix={`/ ${totalSchools}`}
valueStyle={{ color: reportCount === totalSchools ? '#52c41a' : '#faad14' }}
/>
</Card>
</Col>
</Row>
{/* 生成进度条 */}
{generating && (
<Card style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Text> <Text strong>{generating}</Text> ...</Text>
<Progress
percent={Math.round((genProgress / genTotal) * 100)}
status="active"
format={() => `${genProgress}/${genTotal}`}
/>
</Space>
</Card>
)}
{/* 学校列表 */}
<Card title="长宁区高中课程实施监测" bordered={false}>
<Table
columns={columns}
dataSource={schools}
rowKey="name"
loading={loading}
pagination={false}
size="middle"
/>
</Card>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Row, Col, Statistic, Typography, Spin, message } from 'antd';
import {
BankOutlined, TeamOutlined, TrophyOutlined, FileTextOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { getDistricts, type DistrictSummary } from '../services/era2Api';
const { Title, Text } = Typography;
// 区中文名 → 英文名(用于 EN 模式下显示)
const DISTRICT_EN: Record<string, string> = {
'黄浦区': 'Huangpu', '徐汇区': 'Xuhui', '长宁区': 'Changning',
'静安区': "Jing'an", '普陀区': 'Putuo', '虹口区': 'Hongkou',
'杨浦区': 'Yangpu', '浦东新区': 'Pudong', '闵行区': 'Minhang',
'宝山区': 'Baoshan', '嘉定区': 'Jiading', '金山区': 'Jinshan',
'松江区': 'Songjiang', '青浦区': 'Qingpu', '奉贤区': 'Fengxian',
'崇明区': 'Chongming',
};
// 区的颜色映射
const DISTRICT_COLORS: Record<string, string> = {
'黄浦区': '#f5222d', '徐汇区': '#fa541c', '长宁区': '#fa8c16',
'静安区': '#faad14', '普陀区': '#a0d911', '虹口区': '#52c41a',
'杨浦区': '#13c2c2', '浦东新区': '#1677ff', '闵行区': '#2f54eb',
'宝山区': '#722ed1', '嘉定区': '#eb2f96', '金山区': '#f759ab',
'松江区': '#597ef7', '青浦区': '#36cfc9', '奉贤区': '#95de64',
'崇明区': '#ffc53d',
};
export default function DistrictList() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const isEn = i18n.language?.startsWith('en');
const [districts, setDistricts] = useState<DistrictSummary[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const res = await getDistricts();
setDistricts(res.data.districts);
} catch {
message.error(t('districts.fetch_failed'));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const totalSchools = districts.reduce((s, d) => s + d.school_count, 0);
const totalReports = districts.reduce((s, d) => s + d.report_count, 0);
const overallAvg = districts.length > 0
? (districts.reduce((s, d) => s + d.avg_score * d.school_count, 0) / totalSchools).toFixed(1)
: '50.0';
const renderDistrictName = (d: string) =>
isEn ? (DISTRICT_EN[d] ? `${DISTRICT_EN[d]}` : d) : d;
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<Spin size="large" tip={t('common.loading')} />
</div>
);
}
return (
<div>
{/* 全市统计 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title={isEn ? 'Districts' : '覆盖区县'}
value={districts.length}
prefix={<BankOutlined />}
suffix={isEn ? '' : '个'}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={isEn ? 'Total schools' : '学校总数'}
value={totalSchools}
prefix={<TeamOutlined />}
suffix={isEn ? '' : '所'}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={isEn ? 'Citywide average' : '全市均分'}
value={overallAvg}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#1677ff' }}
precision={1}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={t('districts.report_count')}
value={totalReports}
prefix={<FileTextOutlined />}
suffix={`/ ${totalSchools}`}
valueStyle={{ color: totalReports > 0 ? '#52c41a' : '#999' }}
/>
</Card>
</Col>
</Row>
{/* 标题 */}
<Title level={4} style={{ marginBottom: 16 }}>
{t('districts.title')}
</Title>
{/* 区卡片网格 */}
<Row gutter={[16, 16]}>
{districts.map(d => {
const color = DISTRICT_COLORS[d.district] || '#1677ff';
const reportPct = d.school_count > 0 ? Math.round((d.report_count / d.school_count) * 100) : 0;
return (
<Col xs={12} sm={8} md={6} key={d.district}>
<Card
hoverable
onClick={() => navigate(`/district/${encodeURIComponent(d.district)}`)}
style={{ borderTop: `3px solid ${color}`, height: '100%' }}
styles={{ body: { padding: '20px 16px' } }}
>
<div style={{ textAlign: 'center' }}>
<Title level={4} style={{ margin: 0, color }}>
{renderDistrictName(d.district)}
</Title>
<div style={{ margin: '16px 0 8px', display: 'flex', justifyContent: 'space-around' }}>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#333' }}>{d.school_count}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('districts.school_count')}</Text>
</div>
<div>
<div style={{ fontSize: 24, fontWeight: 700, color: d.avg_score >= 50 ? '#52c41a' : '#faad14' }}>
{d.avg_score.toFixed(1)}
</div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('districts.avg_score')}</Text>
</div>
</div>
<div style={{
marginTop: 8,
padding: '4px 12px',
borderRadius: 12,
background: reportPct === 100 ? '#f6ffed' : reportPct > 0 ? '#fffbe6' : '#f5f5f5',
display: 'inline-block',
}}>
<Text style={{
fontSize: 12,
color: reportPct === 100 ? '#52c41a' : reportPct > 0 ? '#faad14' : '#999',
}}>
<FileTextOutlined /> {d.report_count}/{d.school_count}{' '}
{isEn ? 'reports' : '报告'}
</Text>
</div>
</div>
</Card>
</Col>
);
})}
</Row>
</div>
);
}
+496
View File
@@ -0,0 +1,496 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Table, Tag, Button, Space, Statistic, Row, Col, message,
Progress, Typography, Breadcrumb, Switch, Checkbox, Modal, Divider,
Dropdown, Radio,
} from 'antd';
import {
ArrowLeftOutlined, EyeOutlined, DownloadOutlined, SyncOutlined,
TrophyOutlined, TeamOutlined, FileTextOutlined, ThunderboltOutlined,
LoadingOutlined, GlobalOutlined, DownOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { MenuProps } from 'antd';
import { useTranslation } from 'react-i18next';
import {
getSchoolsInDistrict, generateReport, getTaskStatus,
batchGenerate, getBatchStatus,
getReportDownloadUrl,
type SchoolInfo, type BatchStatus, type ReportLang,
} from '../services/era2Api';
const { Text } = Typography;
export default function DistrictSchools() {
const { district } = useParams<{ district: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const isEn = i18n.language?.startsWith('en');
const districtName = decodeURIComponent(district || '');
const [schools, setSchools] = useState<SchoolInfo[]>([]);
const [loading, setLoading] = useState(true);
// 单校生成
const [generating, setGenerating] = useState<string | null>(null);
const [genProgress, setGenProgress] = useState(0);
const [genTotal, setGenTotal] = useState(29);
const [genLang, setGenLang] = useState<ReportLang>('zh');
const [genCurrentLang, setGenCurrentLang] = useState<string>('zh');
// 批量生成
const [batchModalOpen, setBatchModalOpen] = useState(false);
const [selectedSchools, setSelectedSchools] = useState<string[]>([]);
const [skipLlm, setSkipLlm] = useState(false);
const [batchLang, setBatchLang] = useState<ReportLang>('zh');
const [batchRunning, setBatchRunning] = useState(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus | null>(null);
const fetchSchools = useCallback(async () => {
if (!districtName) return;
setLoading(true);
try {
const res = await getSchoolsInDistrict(districtName);
setSchools(res.data.schools);
} catch {
message.error(t('schools.fetch_failed'));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [districtName]);
useEffect(() => { fetchSchools(); }, [fetchSchools]);
// 单校生成
const handleGenerate = async (
school: string,
useCache: boolean,
lang: ReportLang = 'zh',
) => {
try {
setGenerating(school);
setGenLang(lang);
setGenProgress(0);
setGenCurrentLang(lang === 'both' ? 'zh' : lang);
const res = await generateReport(school, districtName, { useCache, lang });
const taskId = res.data.task_id;
const poll = setInterval(async () => {
try {
const status = await getTaskStatus(taskId);
setGenProgress(status.data.progress);
setGenTotal(status.data.total);
if (status.data.current_lang) setGenCurrentLang(status.data.current_lang);
if (status.data.status === 'completed') {
clearInterval(poll);
setGenerating(null);
const langLabel =
lang === 'both' ? t('common.lang_both')
: lang === 'en' ? t('common.lang_en')
: t('common.lang_zh');
message.success(t('schools.single_finished', {
school, langLabel, score: status.data.score,
}));
fetchSchools();
} else if (status.data.status === 'failed') {
clearInterval(poll);
setGenerating(null);
message.error(t('schools.single_failed', { error: status.data.error || '' }));
}
} catch {
clearInterval(poll);
setGenerating(null);
}
}, 1000);
} catch {
setGenerating(null);
message.error(t('schools.single_started_failed'));
}
};
// 批量生成
const handleBatchGenerate = async () => {
try {
const toGen = selectedSchools.length === schools.length ? undefined : selectedSchools;
const res = await batchGenerate(districtName, toGen, { skipLlm, lang: batchLang });
setBatchRunning(true);
setBatchStatus({
status: 'running', district: districtName,
schools: selectedSchools, total: res.data.total,
completed: 0, results: [],
lang: batchLang,
});
setBatchModalOpen(false);
// 轮询
const poll = setInterval(async () => {
try {
const s = await getBatchStatus(res.data.batch_id);
setBatchStatus(s.data);
if (s.data.status === 'completed' || s.data.status === 'failed') {
clearInterval(poll);
setBatchRunning(false);
if (s.data.status === 'completed') {
const ok = s.data.results.filter(r => r.status === 'success').length;
message.success(t('schools.batch_finished', { ok, total: s.data.total }));
fetchSchools();
}
}
} catch {
clearInterval(poll);
setBatchRunning(false);
}
}, 2000);
} catch {
message.error(t('schools.batch_started_failed'));
}
};
// 打开批量Modal
const openBatchModal = () => {
setSelectedSchools(schools.map(s => s.name));
setBatchModalOpen(true);
};
// 学校类型 i18n 显示
const SCHOOL_TYPE_EN: Record<string, string> = {
'市实验性示范性高中': 'Municipal Demonstrative',
'区实验性示范性高中': 'District Demonstrative',
'特色高中': 'Featured',
'公办普通高中': 'Public General',
'民办高中': 'Private',
};
const CLUSTER_EN: Record<string, string> = {
'较好': 'High-Performing',
'中等': 'Mid-Tier',
'待提升': 'Needs Improvement',
};
const columns: ColumnsType<SchoolInfo> = [
{
title: t('common.district_rank'), dataIndex: 'district_rank', key: 'rank', width: 80, align: 'center',
render: (rank: number) => (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: rank <= 3 ? '#1677ff' : '#f0f0f0',
color: rank <= 3 ? '#fff' : '#666', fontWeight: 600, fontSize: 13,
}}>
{rank}
</span>
),
},
{
title: t('common.school'), dataIndex: 'name', key: 'name',
render: (name: string) => <Text strong>{name}</Text>,
},
{
title: t('common.type'), dataIndex: 'type', key: 'type', width: 180,
render: (type: string) => {
const colors: Record<string, string> = {
'市实验性示范性高中': 'blue', '区实验性示范性高中': 'cyan',
'特色高中': 'purple', '公办普通高中': 'green', '民办高中': 'orange',
};
const label = isEn ? (SCHOOL_TYPE_EN[type] || type) : type;
return <Tag color={colors[type] || 'default'}>{label}</Tag>;
},
},
{
title: t('common.score'), dataIndex: 'score', key: 'score', width: 90, align: 'center',
sorter: (a, b) => a.score - b.score,
render: (score: number) => (
<Text strong style={{ fontSize: 15, color: score >= 50 ? '#52c41a' : '#faad14' }}>
{score.toFixed(1)}
</Text>
),
},
{
title: t('common.city_rank'), dataIndex: 'city_rank', key: 'city_rank', width: 100, align: 'center',
render: (rank: number, record) => (
<Text type="secondary">{rank}/{record.total_in_city}</Text>
),
},
{
title: t('common.cluster'), dataIndex: 'cluster', key: 'cluster', width: 110, align: 'center',
render: (c: string) => {
const colors: Record<string, string> = { '较好': 'success', '中等': 'processing', '待提升': 'warning' };
const label = isEn ? (CLUSTER_EN[c] || c) : c;
return <Tag color={colors[c] || 'default'}>{label}</Tag>;
},
},
{
title: t('common.report'), key: 'report', width: 170, align: 'center',
render: (_, record) => {
const hasZh = record.has_report_zh ?? record.has_report;
const hasEn = record.has_report_en ?? false;
const at = record.report_generated_at_zh || record.report_generated_at_en || record.report_generated_at;
return (
<Space direction="vertical" size={2} style={{ lineHeight: 1.3 }}>
<Space size={4}>
<Tag color={hasZh ? 'success' : 'default'} style={{ margin: 0, minWidth: 38, textAlign: 'center' }}>
ZH{hasZh ? ' ✓' : ' ·'}
</Tag>
<Tag color={hasEn ? 'blue' : 'default'} style={{ margin: 0, minWidth: 38, textAlign: 'center' }}>
EN{hasEn ? ' ✓' : ' ·'}
</Tag>
</Space>
{at && <Text type="secondary" style={{ fontSize: 11 }}>{at}</Text>}
</Space>
);
},
},
{
title: t('common.actions'), key: 'actions', width: 360,
render: (_, record) => {
const hasZh = record.has_report_zh ?? record.has_report;
const hasEn = record.has_report_en ?? false;
// 三种语言选项 — 都用「图标 + 文字」让用户一眼看清
const reGenMenu: MenuProps['items'] = [
{
key: 'zh',
icon: <span style={{ fontSize: 14 }}>🇨🇳</span>,
label: hasZh ? t('schools.single_dropdown_zh') : t('schools.single_dropdown_zh_new'),
},
{
key: 'en',
icon: <span style={{ fontSize: 14 }}>🇬🇧</span>,
label: hasEn ? t('schools.single_dropdown_en') : t('schools.single_dropdown_en_new'),
},
{
key: 'both',
icon: <GlobalOutlined />,
label: t('schools.single_dropdown_both'),
},
];
return (
<Space size="small" wrap>
{hasZh && (
<>
<Button size="small" type="primary" icon={<EyeOutlined />}
onClick={() => navigate(`/district/${encodeURIComponent(districtName)}/report/${encodeURIComponent(record.name)}?lang=zh`)}>
{t('schools.preview_zh')}
</Button>
<Button size="small" icon={<DownloadOutlined />}
href={getReportDownloadUrl(districtName, record.name, 'zh')}>
ZH
</Button>
</>
)}
{hasEn && (
<>
<Button size="small" type="primary" ghost icon={<EyeOutlined />}
onClick={() => navigate(`/district/${encodeURIComponent(districtName)}/report/${encodeURIComponent(record.name)}?lang=en`)}>
{t('schools.preview_en')}
</Button>
<Button size="small" icon={<DownloadOutlined />}
href={getReportDownloadUrl(districtName, record.name, 'en')}>
EN
</Button>
</>
)}
{/* 主按钮点击即弹出语言菜单,用户必须选语言再开始生成 */}
<Dropdown
menu={{
items: reGenMenu,
onClick: (info) => handleGenerate(record.name, true, info.key as ReportLang),
}}
trigger={['click']}
disabled={generating === record.name}
>
<Button
size="small"
type="primary"
ghost
icon={
generating === record.name
? <SyncOutlined spin />
: <SyncOutlined />
}
loading={generating === record.name}
>
{hasZh ? t('common.regenerate') : t('common.generate')}
{generating !== record.name && (
<DownOutlined style={{ fontSize: 10, marginLeft: 4 }} />
)}
</Button>
</Dropdown>
</Space>
);
},
},
];
const totalSchools = schools.length;
const avgScore = totalSchools > 0
? (schools.reduce((s, r) => s + r.score, 0) / totalSchools).toFixed(1) : '0';
const reportCountZh = schools.filter(s => (s.has_report_zh ?? s.has_report)).length;
const reportCountEn = schools.filter(s => s.has_report_en).length;
return (
<div>
{/* 面包屑 */}
<Breadcrumb style={{ marginBottom: 16 }} items={[
{ title: <a onClick={() => navigate('/')}>{t('common.city_overview')}</a> },
{ title: districtName },
]} />
{/* 统计 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title={t('schools.stats_school_count')}
value={totalSchools}
prefix={<TeamOutlined />}
suffix={isEn ? '' : '所'}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={t('schools.stats_district_avg')}
value={avgScore}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#1677ff' }}
precision={1}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={t('schools.stats_reports')}
value={reportCountZh}
prefix={<FileTextOutlined />}
suffix={`/ ${totalSchools}`}
valueStyle={{ color: reportCountZh === totalSchools ? '#52c41a' : '#faad14' }}
/>
<div style={{ marginTop: 4, fontSize: 12, color: '#999' }}>
{t('districts.report_count_zh_en', { zh: reportCountZh, en: reportCountEn })}
</div>
</Card>
</Col>
<Col span={6}>
<Card style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Button type="primary" icon={batchRunning ? <LoadingOutlined /> : <ThunderboltOutlined />}
onClick={openBatchModal} loading={batchRunning} disabled={batchRunning}
style={{ width: '100%', height: 50 }}>
{batchRunning ? t('schools.batch_running') : t('schools.batch_generate')}
</Button>
</Card>
</Col>
</Row>
{/* 生成进度 */}
{generating && (
<Card style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
{t('schools.generation_card_text', { school: generating })}
{' '}
<Tag color={genLang === 'en' ? 'blue' : (genLang === 'both' ? 'purple' : 'green')}>
{genLang === 'both'
? t('schools.generation_card_lang_both', {
currentLangLabel: genCurrentLang === 'en' ? t('common.lang_en') : t('common.lang_zh'),
})
: genLang === 'en' ? t('common.lang_en') : t('common.lang_zh')}
</Tag>
...
</Text>
<Progress percent={Math.round((genProgress / Math.max(genTotal, 1)) * 100)} status="active"
format={() => t('schools.generation_segments', { progress: genProgress, total: genTotal })} />
</Space>
</Card>
)}
{/* 批量进度 */}
{batchStatus && batchStatus.status === 'running' && (
<Card style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
{t('schools.batch_card_text', { school: batchStatus.current_school || '' })}
{batchStatus.lang && (
<Tag style={{ marginLeft: 8 }} color={batchStatus.lang === 'en' ? 'blue' : (batchStatus.lang === 'both' ? 'purple' : 'green')}>
{batchStatus.lang === 'both'
? t('schools.generation_card_lang_both', {
currentLangLabel: batchStatus.current_lang === 'en' ? t('common.lang_en') : t('common.lang_zh'),
})
: batchStatus.lang === 'en' ? t('common.lang_en') : t('common.lang_zh')}
</Tag>
)}
</Text>
<Progress percent={Math.round((batchStatus.completed / batchStatus.total) * 100)}
status="active" format={() => t('schools.batch_progress_text', { completed: batchStatus.completed, total: batchStatus.total })} />
</Space>
</Card>
)}
{/* 学校列表 */}
<Card
title={t('schools.title', { district: districtName })}
extra={
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
{t('common.back_to_overview')}
</Button>
}
bordered={false}
>
<Table columns={columns} dataSource={schools} rowKey="name"
loading={loading} pagination={false} size="middle" />
</Card>
{/* 批量生成Modal */}
<Modal
title={t('schools.batch_modal_title', { district: districtName })}
open={batchModalOpen}
onOk={handleBatchGenerate}
onCancel={() => setBatchModalOpen(false)}
okText={t('schools.batch_modal_ok', { n: selectedSchools.length })}
width={620}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<GlobalOutlined />
<Text>{t('schools.batch_modal_lang')}</Text>
<Radio.Group value={batchLang} onChange={(e) => setBatchLang(e.target.value)}>
<Radio.Button value="zh">{t('common.lang_zh')}</Radio.Button>
<Radio.Button value="en">{t('common.lang_en')}</Radio.Button>
<Radio.Button value="both">{t('common.lang_both')}</Radio.Button>
</Radio.Group>
</Space>
<Space>
<Text>{t('common.skip_llm_label')}</Text>
<Switch checked={skipLlm} onChange={setSkipLlm} />
</Space>
<Divider style={{ margin: '12px 0' }} />
<div>
<Space style={{ marginBottom: 8 }}>
<Button size="small" type="link"
onClick={() => setSelectedSchools(schools.map(s => s.name))}>{t('common.select_all')}</Button>
<Button size="small" type="link"
onClick={() => setSelectedSchools([])}>{t('common.clear_selection')}</Button>
</Space>
<Checkbox.Group value={selectedSchools}
onChange={v => setSelectedSchools(v as string[])}>
<Row gutter={[8, 4]}>
{schools.map(s => (
<Col span={12} key={s.name}>
<Checkbox value={s.name}>
{s.name}{' '}
<Text type="secondary" style={{ fontSize: 11 }}>
({isEn ? (SCHOOL_TYPE_EN[s.type] || s.type) : s.type})
</Text>
</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
</div>
</Space>
</Modal>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useState, useRef, useMemo, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Card, Button, Space, Spin, Typography, message, Breadcrumb, Radio } from 'antd';
import {
ArrowLeftOutlined, DownloadOutlined, PrinterOutlined, ExpandOutlined,
NodeIndexOutlined, GlobalOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { getReportPreviewUrl, getReportDownloadUrl } from '../services/era2Api';
import ChatFab from '../components/ChatFab';
const { Title } = Typography;
type Lang = 'zh' | 'en';
export default function Era2ReportPreview() {
const { district, school } = useParams<{ district: string; school: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const [searchParams, setSearchParams] = useSearchParams();
const initialLang: Lang = (searchParams.get('lang') === 'en' ? 'en' : 'zh');
const [lang, setLang] = useState<Lang>(initialLang);
const districtName = decodeURIComponent(district || '');
const schoolName = decodeURIComponent(school || '');
const previewUrl = useMemo(
() => getReportPreviewUrl(districtName, schoolName, lang),
[districtName, schoolName, lang],
);
// 切换语言时重置 loading 并同步 URL 参数
useEffect(() => {
setLoading(true);
const next = new URLSearchParams(searchParams);
if (lang === 'en') next.set('lang', 'en'); else next.delete('lang');
setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lang]);
const reportTitleSuffix = lang === 'en'
? 'Curriculum Implementation Monitoring Report'
: '课程实施监测报告';
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={[
{ title: <a onClick={() => navigate('/')}>{t('common.city_overview')}</a> },
{ title: <a onClick={() => navigate(`/district/${encodeURIComponent(districtName)}`)}>{districtName}</a> },
{ title: `${schoolName} ${lang === 'en' ? 'Report' : '报告'}` },
]} />
<Card
title={
<Space>
<Button icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/district/${encodeURIComponent(districtName)}`)} type="text" />
<Title level={5} style={{ margin: 0 }}>
{schoolName} {reportTitleSuffix}
</Title>
</Space>
}
extra={
<Space wrap>
<Space.Compact>
<Button
icon={<GlobalOutlined />}
disabled
style={{ pointerEvents: 'none', background: '#fafafa' }}
/>
<Radio.Group
value={lang}
onChange={(e) => setLang(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="zh">{t('common.lang_zh')}</Radio.Button>
<Radio.Button value="en">EN</Radio.Button>
</Radio.Group>
</Space.Compact>
<Button
icon={<NodeIndexOutlined />}
onClick={() => navigate(`/district/${encodeURIComponent(districtName)}/trace/${encodeURIComponent(schoolName)}`)}
style={{ color: '#1677ff' }}
>
{t('common.data_trace')}
</Button>
<Button icon={<ExpandOutlined />} onClick={() => window.open(previewUrl, '_blank')}>
{t('common.open_new_window')}
</Button>
<Button icon={<PrinterOutlined />} onClick={() => iframeRef.current?.contentWindow?.print()}>
{t('common.print')}
</Button>
<Button type="primary" icon={<DownloadOutlined />}
href={getReportDownloadUrl(districtName, schoolName, lang)}>
{t('common.download')}
</Button>
</Space>
}
styles={{ body: { padding: 0, height: 'calc(100vh - 200px)' } }}
bordered={false}
>
{loading && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#fff', zIndex: 1,
}}>
<Spin size="large" tip={t('preview.loading')} />
</div>
)}
<iframe
ref={iframeRef}
key={previewUrl}
src={previewUrl}
className="report-iframe"
style={{ width: '100%', height: '100%', border: 'none' }}
onLoad={() => setLoading(false)}
onError={() => {
setLoading(false);
message.error(t('preview.load_failed'));
}}
/>
</Card>
{/* AI助理浮动按钮 — 自动绑定当前报告的区+学校+语言 */}
<ChatFab school={schoolName} district={districtName} lang={lang} />
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card, Table, Tag, Button, Space, Input, Select, Typography, message, Statistic, Row, Col,
} from 'antd';
import {
EyeOutlined, DownloadOutlined, SearchOutlined,
FileTextOutlined, ClockCircleOutlined, BankOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useTranslation } from 'react-i18next';
import { getReportHistory, getReportDownloadUrl, type ReportHistoryItem } from '../services/era2Api';
const { Text } = Typography;
export default function History() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const isEn = i18n.language?.startsWith('en');
const [reports, setReports] = useState<ReportHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterDistrict, setFilterDistrict] = useState<string | undefined>(undefined);
const [filterLang, setFilterLang] = useState<'zh' | 'en' | undefined>(undefined);
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
const res = await getReportHistory();
setReports(res.data.reports);
} catch {
message.error(t('history.fetch_failed'));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const SCHOOL_TYPE_EN: Record<string, string> = {
'市实验性示范性高中': 'Municipal Demonstrative',
'区实验性示范性高中': 'District Demonstrative',
'特色高中': 'Featured',
'公办普通高中': 'Public General',
'民办高中': 'Private',
};
// 筛选
const districts = [...new Set(reports.map(r => r.district))].sort();
const filtered = reports.filter(r => {
if (search && !r.school.includes(search) && !r.district.includes(search)) return false;
if (filterDistrict && r.district !== filterDistrict) return false;
if (filterLang && r.lang !== filterLang) return false;
return true;
});
const columns: ColumnsType<ReportHistoryItem> = [
{
title: t('history.header_school'), dataIndex: 'school', key: 'school',
render: (name: string) => <Text strong>{name}</Text>,
sorter: (a, b) => a.school.localeCompare(b.school),
},
{
title: t('history.header_district'), dataIndex: 'district', key: 'district', width: 100,
render: (d: string) => <Tag color="blue">{d}</Tag>,
filters: districts.map(d => ({ text: d, value: d })),
onFilter: (value, record) => record.district === value,
},
{
title: t('history.header_type'), dataIndex: 'type', key: 'type', width: 180,
render: (type: string) => {
const colors: Record<string, string> = {
'市实验性示范性高中': 'blue', '区实验性示范性高中': 'cyan',
'特色高中': 'purple', '公办普通高中': 'green', '民办高中': 'orange',
};
const label = isEn ? (SCHOOL_TYPE_EN[type] || type) : type;
return <Tag color={colors[type] || 'default'}>{label}</Tag>;
},
},
{
title: t('history.header_lang'), dataIndex: 'lang', key: 'lang', width: 80, align: 'center',
render: (l?: string) => l === 'en'
? <Tag color="blue">EN</Tag>
: <Tag color="success">ZH</Tag>,
},
{
title: t('history.header_score'), dataIndex: 'score', key: 'score', width: 80, align: 'center',
sorter: (a, b) => (a.score ?? 0) - (b.score ?? 0),
render: (score: number | null) => score != null ? (
<Text strong style={{ color: score >= 50 ? '#52c41a' : '#faad14' }}>{score.toFixed(1)}</Text>
) : '-',
},
{
title: t('history.header_size'), dataIndex: 'file_size', key: 'size', width: 100, align: 'center',
render: (size: number) => `${(size / 1024).toFixed(0)} KB`,
},
{
title: t('history.header_generated_at'), dataIndex: 'generated_at', key: 'time', width: 180,
sorter: (a, b) => a.generated_at.localeCompare(b.generated_at),
defaultSortOrder: 'descend',
render: (text: string) => (
<Space size={4}>
<ClockCircleOutlined style={{ color: '#999' }} />
<Text type="secondary">{text}</Text>
</Space>
),
},
{
title: t('history.header_actions'), key: 'actions', width: 200,
render: (_, record) => (
<Space size="small">
<Button size="small" type="primary" icon={<EyeOutlined />}
onClick={() => navigate(
`/district/${encodeURIComponent(record.district)}/report/${encodeURIComponent(record.school)}?lang=${record.lang || 'zh'}`
)}>
{t('common.preview')}
</Button>
<Button size="small" icon={<DownloadOutlined />}
href={getReportDownloadUrl(record.district, record.school, (record.lang === 'en' ? 'en' : 'zh'))}>
{t('common.download')}
</Button>
</Space>
),
},
];
return (
<div>
{/* 统计 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title={isEn ? 'Total reports' : '报告总数'}
value={reports.length}
prefix={<FileTextOutlined />}
suffix={isEn ? '' : '份'}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title={isEn ? 'Districts covered' : '覆盖区'}
value={districts.length}
prefix={<BankOutlined />}
suffix={isEn ? '' : '个'}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title={isEn ? 'Total storage' : '总存储'}
prefix={<FileTextOutlined />}
value={`${(reports.reduce((s, r) => s + r.file_size, 0) / (1024 * 1024)).toFixed(1)} MB`}
/>
</Card>
</Col>
</Row>
<Card title={t('history.title')} bordered={false}
extra={
<Space>
<Input placeholder={t('history.search_placeholder')} prefix={<SearchOutlined />}
value={search} onChange={e => setSearch(e.target.value)}
style={{ width: 220 }} allowClear />
<Select placeholder={t('common.district')} value={filterDistrict}
onChange={setFilterDistrict} allowClear style={{ width: 130 }}>
{districts.map(d => <Select.Option key={d} value={d}>{d}</Select.Option>)}
</Select>
<Select placeholder={t('common.language')} value={filterLang}
onChange={setFilterLang} allowClear style={{ width: 110 }}
options={[
{ value: 'zh', label: t('common.lang_zh') },
{ value: 'en', label: t('common.lang_en') },
]}
/>
</Space>
}>
<Table columns={columns} dataSource={filtered} rowKey={r => `${r.district}_${r.school}_${r.lang || 'zh'}`}
loading={loading} pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: total => isEn ? `${total} entries` : `${total}`,
}}
size="middle" />
</Card>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 登录页面
* 简洁的居中卡片式登录表单
*/
import { useState } from 'react';
import { Form, Input, Button, Card, Typography, message, Space } from 'antd';
import { LockOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { login } from '../services/auth';
const { Title, Text } = Typography;
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { t } = useTranslation();
const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success(t('auth.login_success'));
navigate('/', { replace: true });
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|| t('auth.login_failed');
message.error(errorMsg);
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #1e40af 100%)',
padding: 24,
}}>
<Card
style={{
width: 400,
maxWidth: '100%',
borderRadius: 16,
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
styles={{ body: { padding: '40px 36px 32px' } }}
>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
{/* Logo 区域 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'linear-gradient(135deg, #1e3a5f, #2563eb)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
<Title level={3} style={{ margin: 0, color: '#1e293b' }}>
{t('auth.login_title')}
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
{t('auth.login_subtitle')}
</Text>
</div>
{/* 登录表单 */}
<Form
name="login"
onFinish={handleLogin}
size="large"
autoComplete="off"
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.username_placeholder') }]}
>
<Input
prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
placeholder={t('auth.username')}
autoFocus
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.password_placeholder') }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
placeholder={t('auth.password')}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 44,
borderRadius: 8,
fontWeight: 600,
fontSize: 15,
background: 'linear-gradient(135deg, #1e3a5f, #2563eb)',
border: 'none',
}}
>
{t('auth.login_button')}
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Button, Space, Spin, Typography, message, Breadcrumb } from 'antd';
import {
ArrowLeftOutlined, DownloadOutlined, PrinterOutlined, ExpandOutlined,
} from '@ant-design/icons';
import { getReportPreviewUrl, getReportDownloadUrl } from '../services/api';
const { Title } = Typography;
export default function ReportPreview() {
const { school } = useParams<{ school: string }>();
const navigate = useNavigate();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const schoolName = decodeURIComponent(school || '');
const previewUrl = getReportPreviewUrl(schoolName);
useEffect(() => {
setLoading(true);
}, [schoolName]);
const handlePrint = () => {
const iframe = iframeRef.current;
if (iframe?.contentWindow) {
iframe.contentWindow.print();
}
};
const handleNewTab = () => {
window.open(previewUrl, '_blank');
};
return (
<div>
<Breadcrumb
style={{ marginBottom: 16 }}
items={[
{ title: <a onClick={() => navigate('/')}></a> },
{ title: `${schoolName} 报告预览` },
]}
/>
<Card
title={
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
type="text"
/>
<Title level={5} style={{ margin: 0 }}>{schoolName} </Title>
</Space>
}
extra={
<Space>
<Button icon={<ExpandOutlined />} onClick={handleNewTab}>
</Button>
<Button icon={<PrinterOutlined />} onClick={handlePrint}>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
href={getReportDownloadUrl(schoolName)}
>
</Button>
</Space>
}
bodyStyle={{ padding: 0, height: 'calc(100vh - 200px)' }}
bordered={false}
>
{loading && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#fff', zIndex: 1,
}}>
<Spin size="large" tip="加载报告中..." />
</div>
)}
<iframe
ref={iframeRef}
src={previewUrl}
className="report-iframe"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
onLoad={() => setLoading(false)}
onError={() => {
setLoading(false);
message.error('报告加载失败');
}}
/>
</Card>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import axios from 'axios';
import { getToken, clearAuth } from './auth';
// 同源部署:前端和 API 由同一个 FastAPI 服务提供,使用相对路径
const api = axios.create({
baseURL: '/api',
timeout: 30000,
});
// 请求拦截器:自动附加 JWT token
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:401 时跳转登录页
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearAuth();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export interface SchoolSummary {
name: string;
type: string;
code: string;
nature: string;
score: number;
rank: number;
cluster: string;
has_report: boolean;
report_size: number;
report_generated_at: string;
}
export interface GenerateResponse {
task_id: string;
school: string;
status: string;
}
export interface BatchResponse {
batch_id: string;
schools: string[];
total: number;
}
export interface TaskStatus {
status: string;
school: string;
progress: number;
total: number;
current_segment: string;
elapsed?: number;
score?: number;
rank?: number;
error?: string;
}
export interface BatchStatus {
status: string;
schools: string[];
total: number;
completed: number;
current_school?: string;
elapsed?: number;
results: Array<{
school: string;
status: string;
score?: number;
rank?: number;
cluster?: string;
time?: number;
error?: string;
}>;
}
// 学校列表
export const getSchools = () =>
api.get<{ schools: SchoolSummary[]; total: number }>('/schools');
// 学校详情
export const getSchoolDetail = (name: string) =>
api.get(`/schools/${encodeURIComponent(name)}`);
// 学校维度数据
export const getSchoolDimensions = (name: string) =>
api.get(`/schools/${encodeURIComponent(name)}/dimensions`);
// 生成报告
export const generateReport = (school: string, useCache = true, skipLlm = false) =>
api.post<GenerateResponse>('/reports/generate', {
school,
use_cache: useCache,
skip_llm: skipLlm,
});
// 批量生成
export const batchGenerate = (schools?: string[], useCache = true, skipLlm = false) =>
api.post<BatchResponse>('/reports/batch', {
schools,
use_cache: useCache,
skip_llm: skipLlm,
});
// 查询生成状态
export const getTaskStatus = (taskId: string) =>
api.get<TaskStatus>(`/reports/generate/${taskId}/status`);
// 查询批量状态
export const getBatchStatus = (batchId: string) =>
api.get<BatchStatus>(`/reports/batch/${batchId}/status`);
// 报告预览 URL(自动附带 token)
export const getReportPreviewUrl = (school: string) => {
const base = `/api/reports/${encodeURIComponent(school)}/preview`;
const token = getToken();
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
};
// 报告下载 URL(自动附带 token)
export const getReportDownloadUrl = (school: string) => {
const base = `/api/reports/${encodeURIComponent(school)}/download`;
const token = getToken();
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
};
// SSE 进度流
export const getTaskStreamUrl = (taskId: string) =>
`/api/reports/generate/${taskId}/stream`;
export const getBatchStreamUrl = (batchId: string) =>
`/api/reports/batch/${batchId}/stream`;
// 批量汇总
export const getReportSummary = () =>
api.get('/reports/summary');
// 测评框架
export const getFramework = () =>
api.get('/config/framework');
export default api;
+85
View File
@@ -0,0 +1,85 @@
/**
* 认证服务:登录、登出、Token 管理
*/
import axios from 'axios';
const TOKEN_KEY = 'access_token';
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
}
/** 登录 */
export async function login(username: string, password: string): Promise<LoginResponse> {
const res = await axios.post<LoginResponse>('/api/auth/login', { username, password });
const { access_token } = res.data;
localStorage.setItem(TOKEN_KEY, access_token);
return res.data;
}
/** 登出 */
export async function logout(): Promise<void> {
try {
await axios.post('/api/auth/logout');
} catch {
// 即使服务端失败也清理本地
}
localStorage.removeItem(TOKEN_KEY);
}
/** 获取本地存储的 token */
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
/** 验证 token 是否仍然有效 */
export async function verifyToken(): Promise<boolean> {
const token = getToken();
if (!token) return false;
try {
const res = await axios.get('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
});
return res.data?.valid === true;
} catch {
return false;
}
}
/** 清除认证信息 */
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
}
/**
* 为 axios 实例添加认证拦截器
* - 请求拦截:自动附加 Authorization header
* - 响应拦截:401 时跳转登录页
*/
export function setupAuthInterceptors(instance: typeof axios) {
// 请求拦截器
instance.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearAuth();
// 如果当前不在登录页,跳转到登录页
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
+263
View File
@@ -0,0 +1,263 @@
import axios from 'axios';
import { getToken, clearAuth } from './auth';
const api = axios.create({
baseURL: '/api/era2',
timeout: 30000,
});
// 请求拦截器:自动附加 JWT token
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:401 时跳转登录页
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearAuth();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// ==================== 类型定义 ====================
/** 报告语言:"zh"=中文,"en"=英文,"both"=同时生成中英两份 */
export type ReportLang = 'zh' | 'en' | 'both';
export interface DistrictSummary {
district: string;
school_count: number;
avg_score: number;
report_count: number;
report_count_zh?: number;
report_count_en?: number;
}
export interface SchoolInfo {
name: string;
district: string;
type: string;
nature: string;
area: string;
score: number;
district_rank: number;
city_rank: number;
total_in_district: number;
total_in_city: number;
cluster: string;
// 兼容旧字段
has_report: boolean;
report_size: number;
report_generated_at: string;
// 中英分开
has_report_zh?: boolean;
has_report_en?: boolean;
report_size_zh?: number;
report_size_en?: number;
report_generated_at_zh?: string;
report_generated_at_en?: string;
}
export interface TaskStatus {
status: string;
school: string;
district: string;
progress: number;
total: number;
current_segment: string;
elapsed?: number;
score?: number;
rank?: number;
error?: string;
lang?: string; // "zh" | "en" | "both"
langs?: string[]; // ["zh"] / ["en"] / ["zh","en"]
current_lang?: string;
outputs?: Record<string, string>;
}
export interface BatchStatus {
status: string;
district: string;
schools: string[];
total: number;
completed: number;
current_school?: string;
current_lang?: string;
lang?: string;
langs?: string[];
elapsed?: number;
results: Array<{
school: string;
status: string;
score?: number;
rank?: number;
time?: number;
error?: string;
langs?: string[];
outputs?: Record<string, string>;
}>;
}
export interface ReportHistoryItem {
school: string;
district: string;
type: string;
score: number | null;
file_size: number;
generated_at: string;
file_name: string;
lang?: 'zh' | 'en';
}
// ==================== API 调用 ====================
// 区列表
export const getDistricts = () =>
api.get<{ districts: DistrictSummary[]; total: number }>('/districts');
// 某区的学校列表
export const getSchoolsInDistrict = (district: string) =>
api.get<{ district: string; schools: SchoolInfo[]; total: number }>(
`/districts/${encodeURIComponent(district)}/schools`
);
// 生成报告
export const generateReport = (
school: string,
district: string,
options: {
useCache?: boolean;
skipLlm?: boolean;
enableAgent?: boolean;
lang?: ReportLang;
} = {}
) =>
api.post<{
task_id: string;
school: string;
district: string;
lang?: string;
langs?: string[];
}>('/reports/generate', {
school,
district,
use_cache: options.useCache ?? true,
skip_llm: options.skipLlm ?? false,
enable_agent: options.enableAgent ?? false, // AI助理已移至前端ChatFab,静态HTML不再内嵌
lang: options.lang ?? 'zh',
});
// 批量生成
export const batchGenerate = (
district: string,
schools?: string[],
options: {
useCache?: boolean;
skipLlm?: boolean;
enableAgent?: boolean;
lang?: ReportLang;
} = {}
) =>
api.post<{
batch_id: string;
district: string;
total: number;
lang?: string;
langs?: string[];
}>('/reports/batch', {
district,
schools,
use_cache: options.useCache ?? true,
skip_llm: options.skipLlm ?? false,
enable_agent: options.enableAgent ?? false, // AI助理已移至前端ChatFab,静态HTML不再内嵌
lang: options.lang ?? 'zh',
});
// 任务状态
export const getTaskStatus = (taskId: string) =>
api.get<TaskStatus>(`/reports/generate/${taskId}/status`);
// 批量状态
export const getBatchStatus = (batchId: string) =>
api.get<BatchStatus>(`/reports/batch/${batchId}/status`);
/**
* 报告预览URL(自动附带 token,供 iframe / window.open 使用)
* lang: 'zh' (默认) 或 'en'
*/
export const getReportPreviewUrl = (
district: string,
school: string,
lang: 'zh' | 'en' = 'zh',
) => {
const base = `/api/era2/reports/${encodeURIComponent(district)}/${encodeURIComponent(school)}/preview`;
const token = getToken();
const params = new URLSearchParams();
params.set('lang', lang);
if (token) params.set('token', token);
return `${base}?${params.toString()}`;
};
/**
* 报告下载URL(自动附带 token,供 <a href> 使用)
* lang: 'zh' (默认) 或 'en'
*/
export const getReportDownloadUrl = (
district: string,
school: string,
lang: 'zh' | 'en' = 'zh',
) => {
const base = `/api/era2/reports/${encodeURIComponent(district)}/${encodeURIComponent(school)}/download`;
const token = getToken();
const params = new URLSearchParams();
params.set('lang', lang);
if (token) params.set('token', token);
return `${base}?${params.toString()}`;
};
// 报告历史
export const getReportHistory = () =>
api.get<{ reports: ReportHistoryItem[] }>('/reports/history');
// LLM聊天 — SSE流式
export const getChatStreamUrl = () => '/api/era2/chat';
/**
* 发送聊天消息 — 原生 fetch + SSE 流式读取
* @param signal 可选 AbortSignal,用于取消请求(组件卸载 / 用户中断)
*/
export const sendChatMessage = (
message: string,
school?: string,
district?: string,
history: Array<{ role: string; content: string }> = [],
signal?: AbortSignal,
lang: 'zh' | 'en' = 'zh',
) => {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(getChatStreamUrl(), {
method: 'POST',
headers,
body: JSON.stringify({ message, school, district, history, lang }),
signal,
});
};
export default api;
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/output': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})