Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+6472
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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 |
@@ -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;
|
||||
@@ -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 |
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ====== 阶段2:PCA细节 ======
|
||||
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提取第一主成分 → 线性变换到均值50、标准差10
|
||||
</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变换后,均值约50、标准差约10。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
@@ -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": "与同类学校对比如何?"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user