init
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# README
|
||||
|
||||
## 安装
|
||||
|
||||
```
|
||||
pnpm
|
||||
```
|
||||
|
||||
## 运行命令
|
||||
|
||||
### 开发环境
|
||||
|
||||
```
|
||||
pnpm dev
|
||||
```
|
||||
53
eslint.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
varsIgnorePattern: "^[A-Z_]",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"no-console": "warn",
|
||||
"simple-import-sort/imports": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"no-nested-ternary": 0, // 允许嵌套三元表达式
|
||||
"no-script-url": 0, // 允许javascript:;
|
||||
"prefer-destructuring": 0, // 关闭强制使用解构
|
||||
"no-plusplus": 0, // 允许使用++和--的操作
|
||||
"array-callback-return": 0, // 允许数组map不返回值
|
||||
"consistent-return": 0,
|
||||
"no-param-reassign": 0, // 允许修改函数形参
|
||||
"no-unused-expressions": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"react/prop-types": 0,
|
||||
"no-prototype-builtins": 0,
|
||||
"react/no-deprecated": 0, // 关闭react弃用检测
|
||||
"react/no-string-refs": 0,
|
||||
"no-useless-escape": 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:docker": "vite --config vite.config.docker.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"build:prod": "vite build --mode production",
|
||||
"preview:local": "vite preview --host 0.0.0.0 --port 4173",
|
||||
"deploy": "vercel",
|
||||
"deploy:prod": "vercel --prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.4",
|
||||
"d3": "^7.9.0",
|
||||
"echarts": "^6.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-echarts": "^0.1.1",
|
||||
"react-router-dom": "^7.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-px-to-viewport": "^1.1.1",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
2481
pnpm-lock.yaml
generated
Normal file
14
postcss.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"postcss-px-to-viewport": {
|
||||
viewportWidth: 1440, // 设计稿宽度
|
||||
viewportHeight: 1133, // 设计稿高度
|
||||
unitPrecision: 5, // 转换后的精度,即小数点位数
|
||||
viewportUnit: "vw", // 希望使用的视口单位
|
||||
selectorBlackList: ["ignore"], // 不需要转换的类名
|
||||
minPixelValue: 1, // 小于或等于1px则不进行转换
|
||||
mediaQuery: true, // 是否在媒体查询中也进行转换
|
||||
exclude: /node_modules/i, // 排除node_modules目录
|
||||
},
|
||||
},
|
||||
};
|
||||
BIN
public/live.mp4
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
71
src/App.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import CalendarPage from "./pages/CalendarPage";
|
||||
import LivePage from "./pages/LivePage";
|
||||
import HomeworkPage from "./pages/HomeworkPage";
|
||||
import ProjectLibraryPage from "./pages/ProjectLibraryPage";
|
||||
import PersonalProfile from "./pages/PersonalProfile";
|
||||
import CareerTreePage from "./pages/CareerTreePage";
|
||||
import CompanyJobsPage from "./pages/CompanyJobsPage";
|
||||
import CompanyJobsListPage from "./pages/CompanyJobsListPage";
|
||||
import JobStrategyPage from "./pages/JobStrategyPage";
|
||||
import ResumeInterviewPage from "./pages/ResumeInterviewPage";
|
||||
import ResumeDetailPage from "./pages/ResumeDetailPage";
|
||||
import InterviewQAPage from "./pages/InterviewQAPage";
|
||||
import InterviewSimulationPage from "./pages/InterviewSimulationPage";
|
||||
import ExpertSupportPage from "./pages/ExpertSupportPage";
|
||||
import InterviewLiveRoomPage from "./pages/InterviewLiveRoomPage";
|
||||
import Portfolio from "./pages/Portfolio";
|
||||
|
||||
// 样式文件导入
|
||||
import "./normalize.css";
|
||||
import "@arco-design/web-react/dist/css/arco.css";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/live" element={<LivePage />} />
|
||||
<Route path="/homework" element={<HomeworkPage />} />
|
||||
<Route path="/project-library" element={<ProjectLibraryPage />} />
|
||||
<Route path="/profile" element={<PersonalProfile />} />
|
||||
<Route path="/career-tree" element={<CareerTreePage />} />
|
||||
<Route path="/company-jobs" element={<CompanyJobsPage />} />
|
||||
<Route path="/company-jobs-list" element={<CompanyJobsListPage />} />
|
||||
<Route path="/job-strategy" element={<JobStrategyPage />} />
|
||||
<Route path="/resume-interview" element={<ResumeInterviewPage />} />
|
||||
<Route path="/portfolio" element={<Portfolio />} />
|
||||
<Route
|
||||
path="/resume/:industry/:position"
|
||||
element={<ResumeDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/interview-qa/:industry/:position"
|
||||
element={<InterviewQAPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/interview-simulation"
|
||||
element={<InterviewSimulationPage />}
|
||||
/>
|
||||
<Route path="/expert-support" element={<ExpertSupportPage />} />
|
||||
<Route
|
||||
path="/interview-live/:interviewId"
|
||||
element={<InterviewLiveRoomPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/images/Common/close.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
src/assets/images/Common/modal_bg.png
Normal file
|
After Width: | Height: | Size: 688 KiB |
BIN
src/assets/images/Common/pdf_icon.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/images/CompanyJobsPage/btn_icon.png
Normal file
|
After Width: | Height: | Size: 820 B |
BIN
src/assets/images/CompanyJobsPage/btn_icon_2.png
Normal file
|
After Width: | Height: | Size: 709 B |
BIN
src/assets/images/CompanyJobsPage/close_icon.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
src/assets/images/CompanyJobsPage/company_jobs_page_icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/CompanyJobsPage/expand_icon.png
Normal file
|
After Width: | Height: | Size: 1013 B |
BIN
src/assets/images/CompanyJobsPage/file_icon.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/images/CompanyJobsPage/fulltime_icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/images/CompanyJobsPage/internship_icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/images/CompanyJobsPage/process1.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/CompanyJobsPage/process2.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
src/assets/images/CompanyJobsPage/process3.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/CompanyJobsPage/process4.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/images/CompanyJobsPage/process5.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/CompanyJobsPage/process6.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/assets/images/CompanyJobsPage/process7.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/images/CompanyJobsPage/process_dot.png
Normal file
|
After Width: | Height: | Size: 999 B |
BIN
src/assets/images/Dashboard/QuickAccess/icon1.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/assets/images/Dashboard/QuickAccess/icon2.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/images/Dashboard/QuickAccess/icon3.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/assets/images/Dashboard/StartClass/start_class_bg.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
src/assets/images/HomeworkPage/homework_page_icon1.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
src/assets/images/HomeworkPage/homework_page_icon2.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
src/assets/images/PersonalProfile/course_icon.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/images/PersonalProfile/line_icon.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src/assets/images/PersonalProfile/location_icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/PersonalProfile/major_icon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src/assets/images/PersonalProfile/male_icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/assets/images/PersonalProfile/personal_profile_bg.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
src/assets/images/PersonalProfile/school_icon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src/assets/images/PersonalProfile/study_study_bg.png
Normal file
|
After Width: | Height: | Size: 553 KiB |
BIN
src/assets/images/ProjectLibraryPage/class_icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/assets/images/ProjectLibraryPage/eyes_icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/images/Rank/1.png
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
src/assets/images/Rank/2.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/Rank/3.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
src/assets/images/Rank/first_icon.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/assets/images/Rank/icon4.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/assets/images/Rank/icon5.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/images/Rank/icon6.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/Rank/second_icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/images/Rank/third_icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/images/ResumeInterviewPage/bg_1.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
src/assets/images/ResumeInterviewPage/bg_2.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
src/assets/images/ResumeInterviewPage/change_icon.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src/assets/images/ResumeInterviewPage/icon_1.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/images/ResumeInterviewPage/icon_2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/ResumeInterviewPage/modal_bg.png
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
src/assets/images/ResumeInterviewPage/question_icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/StageProgress/star.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/images/StageProgress/star_active.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/images/StageProgress/step1.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/StageProgress/step1_active.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/StageProgress/step2.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/images/StageProgress/step2_active.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/StageProgress/step3.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/images/StageProgress/step3_active.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/StageProgress/step4.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/StageProgress/step4_active.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/TaskList/frame.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
219
src/components/EcoTree/EcoTree.css
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* EcoTree 组件样式 - React版本
|
||||
* 从Vue版本转换而来,移除了Vue特有的样式语法
|
||||
*/
|
||||
|
||||
/* Tree View Container Styles */
|
||||
.tree-view-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-view-scroll-wrapper {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
/* Enable both horizontal and vertical scrolling */
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
/* 防止 flex 项目溢出其容器 */
|
||||
display: flex;
|
||||
/* 使用flex布局使子元素能够正确填充 */
|
||||
flex-direction: column;
|
||||
/* 垂直排列 */
|
||||
}
|
||||
|
||||
.tree-view-tree-chart {
|
||||
min-width: 100%;
|
||||
height: auto !important;
|
||||
/* 强制使用自动高度 */
|
||||
min-height: 100%;
|
||||
/* 确保至少占满容器 */
|
||||
flex: 1 0 auto;
|
||||
/* 允许伸展但不允许收缩 */
|
||||
}
|
||||
|
||||
.tree-view-tree-chart svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
/* 确保SVG不会超出容器 */
|
||||
height: auto !important;
|
||||
/* 强制高度自适应 */
|
||||
}
|
||||
|
||||
/* Node and Link Styles */
|
||||
.tree-view-link {
|
||||
fill: none;
|
||||
stroke: #ccc;
|
||||
stroke-width: 1.2px;
|
||||
transition: stroke 0.3s, stroke-width 0.3s;
|
||||
}
|
||||
|
||||
.tree-view-node-marker {
|
||||
transition: filter 0.2s ease, r 0.2s ease;
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.tree-view-left-marker {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-view-label-bg {
|
||||
transition: fill 0.2s ease, stroke 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-view-node:hover .tree-view-label-bg {
|
||||
filter: brightness(1.1);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.tree-view-node-label {
|
||||
font-family: "Alibaba-PuHuiTi-Regular", sans-serif;
|
||||
pointer-events: none;
|
||||
fill: #333;
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-view-toggle-bg {
|
||||
transition: filter 0.2s ease, r 0.2s ease;
|
||||
cursor: pointer;
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.tree-view-toggle-bg:hover {
|
||||
filter: brightness(1.2);
|
||||
r: 13;
|
||||
}
|
||||
|
||||
.tree-view-toggle-icon-fo svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tree-view-color-block {
|
||||
transition: filter 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
rx: 3;
|
||||
ry: 3;
|
||||
}
|
||||
|
||||
.tree-view-node-icon {
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-view-node:hover .tree-view-node-icon img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.depth-0 .tree-view-node-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.depth-1 .tree-view-node-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Loading State Styles */
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid #f3f3f3;
|
||||
border-top: 5px solid #0275f2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 18px;
|
||||
color: #0275f2;
|
||||
font-family: "Alibaba-PuHuiTi-SemiBold", sans-serif;
|
||||
text-align: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Error State Styles */
|
||||
.chart-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
font-family: "Alibaba-PuHuiTi-SemiBold", sans-serif;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 8px 20px;
|
||||
background-color: #0275f2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background-color: #0262d3;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
693
src/components/EcoTree/EcoTree.jsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import './EcoTree.css';
|
||||
|
||||
/**
|
||||
* EcoTree React组件 - 从Vue版本转换而来
|
||||
* 使用D3.js绘制可交互的树形图
|
||||
*/
|
||||
const EcoTree = ({
|
||||
// Tree data in hierarchical format
|
||||
treeData,
|
||||
// Custom icons for nodes at different levels
|
||||
nodeIcons = {},
|
||||
// Initial expanded nodes
|
||||
initialExpandedNodes = [],
|
||||
// Whether to expand all nodes initially
|
||||
expandAll = false,
|
||||
// Whether to allow clicking on nodes to expand/collapse
|
||||
expandOnClickNode = false,
|
||||
// Custom node colors based on depth
|
||||
nodeColors = {},
|
||||
// Custom node sizes based on depth
|
||||
nodeSizes = {},
|
||||
// Custom node text colors based on depth
|
||||
nodeTextColors = {},
|
||||
// Custom node text sizes based on depth
|
||||
nodeTextSizes = {},
|
||||
// Allow customizing background colors
|
||||
backgroundColor = '#f5f6f9',
|
||||
// Whether the component is in loading state
|
||||
isLoading = false,
|
||||
// Loading state text
|
||||
loadingText = '正在加载数据...',
|
||||
// Error state
|
||||
loadingError = false,
|
||||
// Error text
|
||||
errorText = '加载失败,请稍后重试',
|
||||
// 控制节点之间的水平间距
|
||||
horizontalNodeSpace = 320,
|
||||
// 控制根节点到第一级节点的间距
|
||||
rootLevelNodeSpace = 320,
|
||||
// 是否显示第一级节点的颜色块
|
||||
showLevel1ColorBlock = true,
|
||||
// 控制叶子节点到其父节点的间距
|
||||
leafNodeSpace = null,
|
||||
// 控制不同层级节点之间的间距配置
|
||||
levelNodeSpaces = {},
|
||||
// 事件回调
|
||||
onNodeClick,
|
||||
onNodeToggle,
|
||||
onRetry,
|
||||
onUpdateExpandedNodes
|
||||
}) => {
|
||||
const treeContainer = useRef(null);
|
||||
const canvasContextRef = useRef(null);
|
||||
const currentHierarchyRootRef = useRef(null);
|
||||
const nodeStatesRef = useRef(new Map());
|
||||
const resizeObserverRef = useRef(null);
|
||||
|
||||
// Helper Functions
|
||||
const getNodeColor = useCallback((depth) => {
|
||||
if (nodeColors && nodeColors[depth] !== undefined) {
|
||||
return nodeColors[depth];
|
||||
}
|
||||
|
||||
// Default colors
|
||||
switch (depth) {
|
||||
case 0: return '#cfedff'; // Root - Light Blue
|
||||
case 1: return '#43a047'; // Level 1 - Green
|
||||
case 2: return '#fb8c00'; // Level 2 - Orange
|
||||
case 3: return '#8e24aa'; // Level 3 - Purple
|
||||
default: return '#a0a0a0'; // Level 4+ - Gray
|
||||
}
|
||||
}, [nodeColors]);
|
||||
|
||||
const getNodeSize = useCallback((depth) => {
|
||||
if (nodeSizes && nodeSizes[depth] !== undefined) {
|
||||
return nodeSizes[depth];
|
||||
}
|
||||
|
||||
// Default sizes
|
||||
switch (depth) {
|
||||
case 0: return 8;
|
||||
case 1: return 7;
|
||||
case 2: return 6;
|
||||
default: return 5;
|
||||
}
|
||||
}, [nodeSizes]);
|
||||
|
||||
const getNodeTextColor = useCallback((depth) => {
|
||||
if (nodeTextColors && nodeTextColors[depth] !== undefined) {
|
||||
return nodeTextColors[depth];
|
||||
}
|
||||
return depth > 0 ? '#ffffff' : '#4c5767';
|
||||
}, [nodeTextColors]);
|
||||
|
||||
const getNodeTextSize = useCallback((depth) => {
|
||||
if (nodeTextSizes && nodeTextSizes[depth] !== undefined) {
|
||||
return nodeTextSizes[depth];
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
return '20px';
|
||||
}
|
||||
return ['19px', '18px', '16px'][depth - 1] || '16px';
|
||||
}, [nodeTextSizes]);
|
||||
|
||||
const safeNumber = useCallback((value, fallback = 0) => {
|
||||
return (value === undefined || isNaN(value)) ? fallback : value;
|
||||
}, []);
|
||||
|
||||
// Text Processing Functions
|
||||
const getTextWidth = useCallback((text, fontStyle) => {
|
||||
if (!canvasContextRef.current) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvasContextRef.current = canvas.getContext('2d');
|
||||
}
|
||||
canvasContextRef.current.font = fontStyle;
|
||||
return canvasContextRef.current.measureText(text || '').width;
|
||||
}, []);
|
||||
|
||||
const truncateText = useCallback((text, maxWidth, fontStyle) => {
|
||||
if (!text) return '';
|
||||
|
||||
if (!canvasContextRef.current) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvasContextRef.current = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
canvasContextRef.current.font = fontStyle;
|
||||
const ellipsis = '...';
|
||||
const ellipsisWidth = canvasContextRef.current.measureText(ellipsis).width;
|
||||
|
||||
if (canvasContextRef.current.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const targetWidth = maxWidth - ellipsisWidth;
|
||||
let truncatedText = text;
|
||||
|
||||
while (truncatedText.length > 0) {
|
||||
truncatedText = truncatedText.slice(0, -1);
|
||||
if (canvasContextRef.current.measureText(truncatedText).width <= targetWidth) {
|
||||
return truncatedText + ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
return ellipsis;
|
||||
}, []);
|
||||
|
||||
// Main render function
|
||||
const renderTree = useCallback(() => {
|
||||
if (!treeContainer.current || !currentHierarchyRootRef.current) return;
|
||||
|
||||
try {
|
||||
// Clear existing visualization
|
||||
d3.select(treeContainer.current).html('');
|
||||
|
||||
// Constants for layout
|
||||
const rectHeight = 36;
|
||||
const textPaddingLeft = 44;
|
||||
const textPaddingRight = 28;
|
||||
|
||||
const containerWidth = treeContainer.current.clientWidth;
|
||||
|
||||
// Icon dimensions
|
||||
const iconWidth = 30;
|
||||
const iconHeight = 30;
|
||||
|
||||
// Toggle button dimensions
|
||||
const toggleButtonRadius = 12;
|
||||
const toggleButtonGap = 10;
|
||||
|
||||
// Calculate node widths and store in Map
|
||||
const nodeVisualWidths = new Map();
|
||||
|
||||
// Apply expanded/collapsed state
|
||||
currentHierarchyRootRef.current.descendants().forEach(node => {
|
||||
const nodeId = node.id || node.data.code;
|
||||
const state = nodeId ? nodeStatesRef.current.get(nodeId) : undefined;
|
||||
const shouldBeExpanded = state !== undefined
|
||||
? state.expanded
|
||||
: (node.depth <= 1 || expandAll);
|
||||
|
||||
if (node.depth > 0) {
|
||||
if (!shouldBeExpanded) {
|
||||
if (node.children) {
|
||||
node._children = node.children;
|
||||
node.children = null;
|
||||
}
|
||||
} else {
|
||||
if (node._children) {
|
||||
node.children = node._children;
|
||||
node._children = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate tree layout
|
||||
const treeLayout = d3.tree().nodeSize([60, horizontalNodeSpace]);
|
||||
treeLayout(currentHierarchyRootRef.current);
|
||||
|
||||
// Adjust node positions
|
||||
currentHierarchyRootRef.current.descendants().forEach(node => {
|
||||
if (node.parent) {
|
||||
node.originalY = node.y;
|
||||
node.y += 20;
|
||||
|
||||
// Handle level spacing
|
||||
let levelSpacingApplied = false;
|
||||
if (levelNodeSpaces && Object.keys(levelNodeSpaces).length > 0) {
|
||||
const levelKey = `${node.parent.depth}-${node.depth}`;
|
||||
if (levelNodeSpaces[levelKey] !== undefined) {
|
||||
if (node.parent.depth === 0) {
|
||||
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
||||
node.y = rootOuterSize + levelNodeSpaces[levelKey];
|
||||
levelSpacingApplied = true;
|
||||
} else {
|
||||
const parentY = node.parent.y;
|
||||
const parentId = node.parent.id || node.parent.data.code;
|
||||
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
||||
const spacingStartY = parentY + parentWidth;
|
||||
node.y = spacingStartY + levelNodeSpaces[levelKey];
|
||||
levelSpacingApplied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!levelSpacingApplied) {
|
||||
if (node.parent.depth === 0 && node.depth === 1) {
|
||||
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
||||
node.y = Math.max(rootLevelNodeSpace, rootOuterSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle leaf nodes
|
||||
const isLeafNode = !node.children && !node._children;
|
||||
if (leafNodeSpace !== null && isLeafNode) {
|
||||
if (node.parent) {
|
||||
const parentY = node.parent.y;
|
||||
const parentId = node.parent.id || node.parent.data.code;
|
||||
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
||||
const minSpacing = 30;
|
||||
const spacing = Math.max(leafNodeSpace, minSpacing);
|
||||
node.y = parentY + parentWidth + spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter visible nodes
|
||||
const visibleNodes = currentHierarchyRootRef.current.descendants().filter(node => {
|
||||
let current = node;
|
||||
while (current.parent) {
|
||||
if (current.parent.children?.indexOf(current) === -1) {
|
||||
return false;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate node widths
|
||||
visibleNodes.forEach(node => {
|
||||
const fontSize = getNodeTextSize(node.depth);
|
||||
const fontWeight = node.depth < 2 ? 'bold' : 'normal';
|
||||
const fontFamily = fontWeight === 'bold'
|
||||
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
||||
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
||||
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
|
||||
const actualTextWidth = getTextWidth(node.data.name || '', fontStyle);
|
||||
const hasColorBlock = node.depth === 1;
|
||||
const colorBlockWidth = hasColorBlock ? 10 : 0;
|
||||
const colorBlockPadding = hasColorBlock ? 5 : 0;
|
||||
|
||||
const calculatedWidth = textPaddingLeft + actualTextWidth + textPaddingRight + colorBlockWidth + colorBlockPadding;
|
||||
|
||||
node._calculatedRectWidth = calculatedWidth;
|
||||
node._textAvailableWidth = calculatedWidth - textPaddingLeft - (node.depth === 1 ? 10 + 5 : 0) - textPaddingRight + 10;
|
||||
|
||||
const nodeId = node.id || node.data.code;
|
||||
if (nodeId) {
|
||||
nodeVisualWidths.set(nodeId, calculatedWidth);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate boundaries
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
|
||||
visibleNodes.forEach(node => {
|
||||
minX = Math.min(minX, node.x);
|
||||
maxX = Math.max(maxX, node.x);
|
||||
minY = Math.min(minY, node.y);
|
||||
maxY = Math.max(maxY, node.y);
|
||||
maxY = Math.max(maxY, node.y + (nodeVisualWidths.get(node.id || node.data.code) || node._calculatedRectWidth || 0) + 30);
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const padding = { top: 100, right: 20, bottom: 100, left: 120 };
|
||||
|
||||
// Calculate SVG dimensions
|
||||
const realWidth = maxY - minY + padding.left + padding.right;
|
||||
const realHeight = maxX - minX + padding.top + padding.bottom;
|
||||
|
||||
const containerHeight = treeContainer.current.clientHeight;
|
||||
const containerAvailableWidth = containerWidth - 20;
|
||||
let scaleFactor = 1;
|
||||
let needsScaling = false;
|
||||
|
||||
if (realWidth > containerAvailableWidth) {
|
||||
scaleFactor = containerAvailableWidth / realWidth;
|
||||
needsScaling = true;
|
||||
}
|
||||
|
||||
const finalHeight = realHeight * (needsScaling ? scaleFactor : 1);
|
||||
const useFullHeight = finalHeight < containerHeight;
|
||||
|
||||
// Create SVG
|
||||
const svg = d3.select(treeContainer.current)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', useFullHeight ? '100%' : finalHeight)
|
||||
.attr('viewBox', `0 0 ${realWidth} ${realHeight}`)
|
||||
.attr('preserveAspectRatio', 'xMinYMid meet')
|
||||
.style('display', 'block')
|
||||
.style('max-height', '100%');
|
||||
|
||||
// Add gradients
|
||||
const defs = svg.append('defs');
|
||||
|
||||
const incrementGradient = defs.append('linearGradient')
|
||||
.attr('id', 'increment-gradient')
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%');
|
||||
incrementGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
||||
incrementGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FF8989');
|
||||
|
||||
const stockGradient = defs.append('linearGradient')
|
||||
.attr('id', 'stock-gradient')
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%');
|
||||
stockGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
||||
stockGradient.append('stop').attr('offset', '100%').attr('stop-color', '#7ABAFF');
|
||||
|
||||
const declineGradient = defs.append('linearGradient')
|
||||
.attr('id', 'decline-gradient')
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%');
|
||||
declineGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
||||
declineGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFBD7F');
|
||||
|
||||
// Main group
|
||||
const mainGroup = svg.append('g')
|
||||
.attr('transform', `translate(${padding.left - minY}, ${padding.top - minX})`);
|
||||
|
||||
// Draw links
|
||||
mainGroup.selectAll('.tree-view-link')
|
||||
.data(visibleNodes.filter(d => d.parent))
|
||||
.enter().append('path')
|
||||
.attr('class', 'tree-view-link')
|
||||
.attr('d', d => {
|
||||
const parent = d.parent;
|
||||
const sourceX = parent.x;
|
||||
const targetX = d.x;
|
||||
const targetY = d.y;
|
||||
let sourceY;
|
||||
|
||||
if (parent.depth === 0) {
|
||||
sourceY = parent.y + 95;
|
||||
} else {
|
||||
const parentId = parent.id || parent.data.code;
|
||||
const parentWidth = safeNumber(nodeVisualWidths.get(parentId) || parent._calculatedRectWidth || 0, 0);
|
||||
sourceY = parent.y + parentWidth + toggleButtonGap + toggleButtonRadius;
|
||||
}
|
||||
|
||||
return `M ${sourceY},${sourceX}
|
||||
C ${(sourceY + targetY) / 2},${sourceX}
|
||||
${(sourceY + targetY) / 2},${targetX}
|
||||
${targetY},${targetX}`;
|
||||
})
|
||||
.attr('stroke', '#ccc')
|
||||
.attr('stroke-width', '1.2px')
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Draw nodes
|
||||
const node = mainGroup.selectAll('.tree-view-node')
|
||||
.data(visibleNodes, d => d.id || d.data.code || `${d.depth}-${d.data.name}`)
|
||||
.enter().append('g')
|
||||
.attr('class', d => `tree-view-node depth-${d.depth}`)
|
||||
.attr('transform', d => `translate(${d.y}, ${d.x})`);
|
||||
|
||||
// Root nodes
|
||||
const rootNodeGroup = node.filter(d => d.depth === 0);
|
||||
|
||||
// Root outer circle
|
||||
rootNodeGroup.append('circle')
|
||||
.attr('class', 'tree-view-root-outer')
|
||||
.attr('r', d => {
|
||||
const baseSize = 95;
|
||||
const configSize = safeNumber(getNodeSize(0), 8);
|
||||
return Math.max(baseSize * (configSize / 8), 75);
|
||||
})
|
||||
.attr('fill', backgroundColor)
|
||||
.attr('cx', 0)
|
||||
.attr('cy', 0);
|
||||
|
||||
// Root inner circle
|
||||
rootNodeGroup.append('circle')
|
||||
.attr('class', 'tree-view-root-inner')
|
||||
.attr('r', d => {
|
||||
const baseSize = 75;
|
||||
const configSize = safeNumber(getNodeSize(0), 8);
|
||||
return Math.max(baseSize * (configSize / 8), 60);
|
||||
})
|
||||
.attr('fill', d => getNodeColor(0))
|
||||
.attr('cx', 0)
|
||||
.attr('cy', 0);
|
||||
|
||||
// Root node text
|
||||
rootNodeGroup.append('foreignObject')
|
||||
.attr('x', -60)
|
||||
.attr('y', -25)
|
||||
.attr('width', 120)
|
||||
.attr('height', 50)
|
||||
.attr('pointer-events', 'none')
|
||||
.html(d => {
|
||||
const name = d.data.name || '';
|
||||
const textColor = getNodeTextColor(d.depth);
|
||||
const fontSize = getNodeTextSize(d.depth);
|
||||
return `<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center;
|
||||
font-size: ${fontSize}; font-weight: bold; color: ${textColor}; font-family: 'Alibaba-PuHuiTi-SemiBold', sans-serif;
|
||||
overflow-wrap: break-word; line-height: 1.2;">
|
||||
${name}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Non-root nodes
|
||||
const nonRootNodeGroups = node.filter(d => d.depth > 0);
|
||||
|
||||
// Left marker circle
|
||||
nonRootNodeGroups.append('circle')
|
||||
.attr('class', 'tree-view-node-marker tree-view-left-marker')
|
||||
.attr('r', d => getNodeSize(d.depth))
|
||||
.attr('cx', rectHeight / 2)
|
||||
.attr('cy', 0)
|
||||
.attr('fill', d => getNodeColor(d.depth))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5);
|
||||
|
||||
// Node background
|
||||
nonRootNodeGroups.append('path')
|
||||
.attr('class', 'tree-view-label-bg')
|
||||
.attr('d', d => {
|
||||
const w = nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0;
|
||||
const h = rectHeight;
|
||||
const r = h / 2;
|
||||
const rr = 10;
|
||||
|
||||
return `M ${w - rr},${-h / 2}
|
||||
L ${r},${-h / 2}
|
||||
A ${r},${r} 0 0 0 ${r},${h / 2}
|
||||
L ${w - rr},${h / 2}
|
||||
A ${rr},${rr} 0 0 0 ${w},${h / 2 - rr}
|
||||
L ${w},${-h / 2 + rr}
|
||||
A ${rr},${rr} 0 0 0 ${w - rr},${-h / 2}
|
||||
Z`;
|
||||
})
|
||||
.attr('fill', d => {
|
||||
if (d.depth === 1 && d.data.tag) {
|
||||
const gradientMap = {
|
||||
'增量行业': 'url(#increment-gradient)',
|
||||
'存量行业': 'url(#stock-gradient)',
|
||||
'衰落行业': 'url(#decline-gradient)'
|
||||
};
|
||||
return gradientMap[d.data.tag] || backgroundColor;
|
||||
}
|
||||
return backgroundColor;
|
||||
})
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick({
|
||||
node: d.data,
|
||||
level: d.depth + 1,
|
||||
event
|
||||
});
|
||||
}
|
||||
|
||||
if (expandOnClickNode && (d.children || d._children)) {
|
||||
event.stopPropagation();
|
||||
handleToggleNode(d);
|
||||
}
|
||||
});
|
||||
|
||||
// Node icon
|
||||
nonRootNodeGroups.append('foreignObject')
|
||||
.attr('class', 'tree-view-node-icon')
|
||||
.attr('x', 0)
|
||||
.attr('y', -(iconHeight / 2))
|
||||
.attr('width', iconWidth)
|
||||
.attr('height', iconHeight)
|
||||
.style('pointer-events', 'none')
|
||||
.html(d => {
|
||||
let icon = '';
|
||||
if (nodeIcons && nodeIcons[d.depth]) {
|
||||
icon = nodeIcons[d.depth];
|
||||
}
|
||||
return icon ? `<img src="${icon}" width="${iconWidth}" height="${iconHeight}" style="display: block;" />` : '';
|
||||
});
|
||||
|
||||
// Node label text
|
||||
nonRootNodeGroups.append('text')
|
||||
.attr('class', 'tree-view-node-label')
|
||||
.attr('dy', '.35em')
|
||||
.attr('y', 0)
|
||||
.attr('x', d => textPaddingLeft + 3)
|
||||
.attr('text-anchor', 'start')
|
||||
.text(d => {
|
||||
const fontSize = getNodeTextSize(d.depth);
|
||||
const fontWeight = d.depth < 2 ? 'bold' : 'normal';
|
||||
const fontFamily = fontWeight === 'bold'
|
||||
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
||||
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
||||
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
return truncateText(d.data.name, d._textAvailableWidth, fontStyle);
|
||||
})
|
||||
.style('font-size', d => getNodeTextSize(d.depth))
|
||||
.style('font-weight', d => d.depth < 2 ? 'bold' : 'normal')
|
||||
.attr('fill', d => getNodeTextColor(d.depth))
|
||||
.style('pointer-events', 'none');
|
||||
|
||||
// Node tooltips
|
||||
nonRootNodeGroups.append('title')
|
||||
.text(d => d.data.name);
|
||||
|
||||
// Toggle buttons
|
||||
const toggleNodes = nonRootNodeGroups.filter(d => d.children || d._children);
|
||||
|
||||
toggleNodes.append('circle')
|
||||
.attr('class', 'tree-view-toggle-bg')
|
||||
.attr('r', toggleButtonRadius)
|
||||
.attr('cx', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius, toggleButtonGap + toggleButtonRadius))
|
||||
.attr('cy', 0)
|
||||
.attr('fill', d => getNodeColor(d.depth))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
handleToggleNode(d);
|
||||
});
|
||||
|
||||
// Toggle icons
|
||||
toggleNodes.append('foreignObject')
|
||||
.attr('class', 'tree-view-toggle-icon-fo')
|
||||
.attr('x', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius - 8, toggleButtonGap + toggleButtonRadius - 8))
|
||||
.attr('y', -8)
|
||||
.attr('width', 16)
|
||||
.attr('height', 16)
|
||||
.style('pointer-events', 'none')
|
||||
.html(d => {
|
||||
const nodeId = d.id || d.data.code;
|
||||
if (!nodeId) return '';
|
||||
|
||||
const currentState = nodeStatesRef.current.get(nodeId);
|
||||
const isExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
||||
|
||||
return isExpanded ?
|
||||
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13H5v-2h14v2z"/></svg>` :
|
||||
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Error handled silently
|
||||
}
|
||||
}, [
|
||||
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
||||
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
||||
leafNodeSpace, levelNodeSpaces, expandOnClickNode, onNodeClick,
|
||||
getNodeColor, getNodeSize, getNodeTextColor, getNodeTextSize,
|
||||
safeNumber, getTextWidth, truncateText
|
||||
]);
|
||||
|
||||
// Handle toggle node
|
||||
const handleToggleNode = useCallback((d) => {
|
||||
const nodeId = d.id || d.data.code;
|
||||
if (!nodeId) return;
|
||||
|
||||
const currentState = nodeStatesRef.current.get(nodeId);
|
||||
const currentlyExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
||||
|
||||
const newState = { expanded: !currentlyExpanded };
|
||||
nodeStatesRef.current.set(nodeId, newState);
|
||||
|
||||
if (onUpdateExpandedNodes) {
|
||||
const expandedNodesList = [];
|
||||
nodeStatesRef.current.forEach((state, id) => {
|
||||
if (state.expanded) {
|
||||
expandedNodesList.push(id);
|
||||
}
|
||||
});
|
||||
onUpdateExpandedNodes(expandedNodesList);
|
||||
}
|
||||
|
||||
if (onNodeToggle) {
|
||||
onNodeToggle({
|
||||
node: d.data,
|
||||
expanded: !currentlyExpanded
|
||||
});
|
||||
}
|
||||
|
||||
renderTree();
|
||||
}, [onUpdateExpandedNodes, onNodeToggle, renderTree]);
|
||||
|
||||
// Initialize component
|
||||
useEffect(() => {
|
||||
if (initialExpandedNodes && initialExpandedNodes.length > 0) {
|
||||
initialExpandedNodes.forEach(nodeId => {
|
||||
nodeStatesRef.current.set(nodeId, { expanded: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (treeData) {
|
||||
currentHierarchyRootRef.current = d3.hierarchy(treeData, d => d.children);
|
||||
renderTree();
|
||||
}
|
||||
}, [treeData, initialExpandedNodes, renderTree]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (treeContainer.current) {
|
||||
renderTree();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined' && treeContainer.current) {
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
renderTree();
|
||||
});
|
||||
resizeObserverRef.current.observe(treeContainer.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [renderTree]);
|
||||
|
||||
// Watch for prop changes that require re-render
|
||||
useEffect(() => {
|
||||
if (currentHierarchyRootRef.current && treeContainer.current) {
|
||||
renderTree();
|
||||
}
|
||||
}, [
|
||||
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
||||
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
||||
leafNodeSpace, levelNodeSpaces, renderTree
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="tree-view-container">
|
||||
<div className="tree-view-scroll-wrapper">
|
||||
<div ref={treeContainer} className="tree-view-tree-chart"></div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="chart-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<div className="loading-text">{loadingText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{loadingError && (
|
||||
<div className="chart-error">
|
||||
<div className="error-icon">!</div>
|
||||
<div className="error-text">{errorText}</div>
|
||||
<button className="retry-button" onClick={onRetry}>重新加载</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EcoTree;
|
||||
165
src/components/Layout/MessageNotification.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { mockData } from "@/data/mockData";
|
||||
import Portal from "../common/Portal";
|
||||
|
||||
const MessageNotification = ({ isOpen, onClose, onMarkAllRead }) => {
|
||||
const { notifications } = mockData;
|
||||
const popupRef = useRef(null);
|
||||
|
||||
// 点击外部关闭浮窗
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (popupRef.current && !popupRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 获取消息类型图标
|
||||
const getMessageIcon = (type) => {
|
||||
const icons = {
|
||||
system: "⚙️",
|
||||
course: "📚",
|
||||
assignment: "📝",
|
||||
announcement: "📢",
|
||||
};
|
||||
return icons[type] || "📬";
|
||||
};
|
||||
|
||||
// 获取优先级颜色
|
||||
const getPriorityColor = (priority) => {
|
||||
const colors = {
|
||||
high: "#ef4444",
|
||||
medium: "#f59e0b",
|
||||
low: "#10b981",
|
||||
};
|
||||
return colors[priority] || "#6b7280";
|
||||
};
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString) => {
|
||||
const date = new Date(timeString);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now - date) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
return "刚刚";
|
||||
} else if (diffInHours < 24) {
|
||||
return `${diffInHours}小时前`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays}天前`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理消息点击
|
||||
const handleMessageClick = (message) => {
|
||||
// 这里可以添加具体的消息处理逻辑
|
||||
};
|
||||
|
||||
// 处理全部标记已读
|
||||
const handleMarkAllReadClick = () => {
|
||||
onMarkAllRead();
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal className="message-notification-portal">
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="message-notification-popup"
|
||||
style={{
|
||||
animation: "notificationFadeIn 200ms ease-out forwards",
|
||||
position: "fixed",
|
||||
top: "120px",
|
||||
left: "280px",
|
||||
zIndex: "var(--z-modal, 10000)",
|
||||
}}
|
||||
>
|
||||
{/* 浮窗头部 */}
|
||||
<div className="message-notification-header">
|
||||
<h4 className="message-notification-title">系统消息</h4>
|
||||
<div className="message-notification-actions">
|
||||
{notifications.unreadCount > 0 && (
|
||||
<button
|
||||
className="mark-all-read-btn"
|
||||
onClick={handleMarkAllReadClick}
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
)}
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="message-notification-content">
|
||||
{notifications.messages.length === 0 ? (
|
||||
<div className="empty-messages">
|
||||
<div className="empty-icon">📭</div>
|
||||
<p>暂无消息通知</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list">
|
||||
{notifications.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`message-item ${
|
||||
!message.isRead ? "unread" : "read"
|
||||
}`}
|
||||
onClick={() => handleMessageClick(message)}
|
||||
>
|
||||
<div className="message-icon-wrapper">
|
||||
<span className="message-type-icon">
|
||||
{getMessageIcon(message.type)}
|
||||
</span>
|
||||
{!message.isRead && (
|
||||
<div
|
||||
className="message-priority-dot"
|
||||
style={{
|
||||
backgroundColor: getPriorityColor(message.priority),
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<h5 className="message-title">{message.title}</h5>
|
||||
<span className="message-time">
|
||||
{formatTime(message.time)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="message-text">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 浮窗底部 */}
|
||||
<div className="message-notification-footer">
|
||||
<span className="message-count">
|
||||
共 {notifications.messages.length} 条消息
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageNotification;
|
||||
139
src/components/Layout/Sidebar.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { mockData } from "@/data/mockData";
|
||||
|
||||
const navigation = {
|
||||
sections: [
|
||||
{
|
||||
title: "个人区块",
|
||||
items: [
|
||||
{ name: "🏠 主页", path: "/dashboard", active: true },
|
||||
{ name: "👤 个人档案", path: "/profile" },
|
||||
{ name: "📅 日历", path: "/calendar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "课程区块",
|
||||
items: [
|
||||
{ name: "📺 课程直播间", path: "/live" },
|
||||
{ name: "🌳 就业管家知识树", path: "/career-tree" },
|
||||
{ name: "📝 课后作业", path: "/homework" },
|
||||
{ name: "🎯 1V1定制求职策略", path: "/job-strategy" },
|
||||
{ name: "🎭 线下面试模拟", path: "/interview-simulation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "资源区块",
|
||||
items: [
|
||||
{ name: "🏥 专家支持中心", path: "/expert-support" },
|
||||
{
|
||||
name: "🏢 企业内推岗位",
|
||||
path: ["/company-jobs", "/company-jobs-list"],
|
||||
},
|
||||
{ name: "📄 我的简历与面试题", path: "/resume-interview" },
|
||||
{ name: "📚 我的项目库", path: "/project-library" },
|
||||
{ name: "📚 我的作品集", path: "/portfolio" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = mockData;
|
||||
|
||||
// 侧边栏折叠状态,从localStorage恢复状态
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved === "true";
|
||||
});
|
||||
|
||||
// 保存状态到localStorage并发送事件
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sidebar-collapsed", isCollapsed.toString());
|
||||
// 发送自定义事件通知Layout组件状态变化
|
||||
const event = new CustomEvent("sidebarToggle", {
|
||||
detail: { isCollapsed },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, [isCollapsed]);
|
||||
|
||||
const handleNavClick = (path) => {
|
||||
if (Array.isArray(path)) {
|
||||
navigate(path[0]);
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换侧边栏展开/折叠状态
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`sidebar ${isCollapsed ? "collapsed" : ""}`}>
|
||||
{/* 顶部Logo和标题 */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">多</div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar-title">多多畅职教务系统</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用户信息 - 纯静态展示 */}
|
||||
<div className="user-profile">
|
||||
<div className="user-avatar"></div>
|
||||
{!isCollapsed && (
|
||||
<div className="user-info">
|
||||
<h4>{user.name}</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<div className="nav-menu">
|
||||
{navigation.sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className="nav-section">
|
||||
{isCollapsed ? (
|
||||
<div className="nav-section-title-collapsed">
|
||||
{section.title.charAt(0)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="nav-section-title">{section.title}</div>
|
||||
)}
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<button
|
||||
key={itemIndex}
|
||||
className={`nav-item ${
|
||||
location.pathname === item.path ||
|
||||
item.path.includes(location.pathname)
|
||||
? "active"
|
||||
: ""
|
||||
} ${section.title !== "个人区块" ? "nav-subitem" : ""}`}
|
||||
onClick={() => handleNavClick(item.path)}
|
||||
title={isCollapsed ? item.name : ""}
|
||||
>
|
||||
{isCollapsed ? item.name.split(" ")[0] : item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬浮的折叠/展开按钮 */}
|
||||
<button
|
||||
className={`sidebar-float-toggle ${isCollapsed ? "collapsed" : ""}`}
|
||||
onClick={toggleSidebar}
|
||||
title={isCollapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
>
|
||||
<span className="toggle-icon">{isCollapsed ? "▶" : "◀"}</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
582
src/components/Layout/index.css
Normal file
@@ -0,0 +1,582 @@
|
||||
/* 全局z-index层级系统 - 确保弹窗层级的最佳实践 */
|
||||
:root {
|
||||
/* z-index层级标准 - 数值间隔1000确保充足的层级空间 */
|
||||
--z-modal: 10000; /* 模态框 - 最高层级,包括系统消息弹窗 */
|
||||
--z-popup: 9000; /* 弹出框 - 次高层级,如用户菜单、下拉框 */
|
||||
--z-tooltip: 8000; /* 提示框 - 中等层级,如悬浮提示 */
|
||||
--z-dropdown: 7000; /* 下拉菜单 - 较低层级 */
|
||||
--z-header: 1000; /* 页头导航 - 基础层级 */
|
||||
--z-content: 1; /* 页面内容 - 默认层级 */
|
||||
}
|
||||
|
||||
/* Layout组件专用变量定义 - 不与其他文件共享 */
|
||||
.app-layout,
|
||||
.sidebar,
|
||||
.app-layout *,
|
||||
.sidebar * {
|
||||
--primary-color: #3b82f6;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--card-bg: #ffffff;
|
||||
--sidebar-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 布局相关样式 */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar {
|
||||
width: 280px; /* 增加宽度避免内容换行 */
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed; /* 固定定位同时作为子元素的定位上下文 */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 侧边栏滚动条样式 - 极简清透设计 */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px; /* 纤细的滚动条 */
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent; /* 透明轨道 */
|
||||
margin: 10px 0; /* 上下留边 */
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1); /* 半透明滑块 */
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2); /* 悬停时加深 */
|
||||
}
|
||||
|
||||
/* Firefox滚动条样式 */
|
||||
.sidebar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.1) transparent;
|
||||
}
|
||||
|
||||
/* 折叠状态的侧边栏 */
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border-radius: 50% !important;
|
||||
background: #e5e7eb;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>');
|
||||
background-size: 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.user-info h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 导航菜单样式 */
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #eff6ff;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 折叠状态下的区块标题 */
|
||||
.nav-section-title-collapsed {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 悬浮的折叠/展开按钮 */
|
||||
.sidebar-float-toggle {
|
||||
position: fixed;
|
||||
top: 5%; /* 垂直居中 */
|
||||
left: 260px; /* 默认位置:侧边栏宽度 */
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
border: none;
|
||||
border-radius: 0 10px 10px 0; /* 右侧圆角 */
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 2px 0 10px rgba(59, 130, 246, 0.3);
|
||||
z-index: 1001; /* 确保在侧边栏上方 */
|
||||
opacity: 0.9;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 折叠状态的按钮位置 */
|
||||
.sidebar-float-toggle.collapsed {
|
||||
left: 64px; /* 折叠时的侧边栏宽度 */
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.sidebar-float-toggle:hover {
|
||||
opacity: 1;
|
||||
width: 28px;
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
box-shadow: 3px 0 15px rgba(59, 130, 246, 0.4);
|
||||
transform: translateY(-50%) translateX(2px);
|
||||
}
|
||||
|
||||
/* 图标动画 */
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sidebar-float-toggle:hover .toggle-icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 旧的toggle-btn样式已被sidebar-float-toggle替代 */
|
||||
|
||||
/* 折叠状态下的特殊样式 */
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-profile {
|
||||
justify-content: center;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
justify-content: center;
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px; /* 配合侧边栏宽度调整 */
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 当侧边栏折叠时的主内容区域 */
|
||||
.main-content.sidebar-collapsed {
|
||||
margin-left: 64px;
|
||||
}
|
||||
|
||||
/* 用户信息区域优化 */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 消息图标容器 */
|
||||
.message-icon-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-icon-container:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 消息图标 */
|
||||
.message-icon {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.message-icon-container:hover .message-icon {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 未读消息徽章 */
|
||||
.message-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: messagePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 未读徽章动画 */
|
||||
@keyframes messagePulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 消息通知浮窗遮罩 - 已由Portal替代,保留样式以防回退 */
|
||||
.message-notification-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-modal);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 消息通知浮窗 - 使用全局z-index系统 */
|
||||
.message-notification-popup {
|
||||
position: fixed; /* Portal渲染到body,使用fixed定位 */
|
||||
top: 120px;
|
||||
left: 280px;
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
z-index: var(--z-modal); /* 使用全局变量确保最高层级 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 浮窗淡入动画 */
|
||||
@keyframes notificationFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 浮窗头部 */
|
||||
.message-notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 16px 12px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.message-notification-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.message-notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mark-all-read-btn {
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.mark-all-read-btn:hover {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 150ms ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 浮窗内容区域 */
|
||||
.message-notification-content {
|
||||
flex: 1;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.message-notification-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.message-notification-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-notification-content::-webkit-scrollbar-thumb {
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.message-notification-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 消息项 */
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.message-item.unread {
|
||||
background-color: #fefefe;
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.message-item.read {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 消息图标包装器 */
|
||||
.message-icon-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-type-icon {
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-priority-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 消息内容 */
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 空消息状态 */
|
||||
.empty-messages {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-messages p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 浮窗底部 */
|
||||
.message-notification-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
46
src/components/Layout/index.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Sidebar from "./Sidebar";
|
||||
import "./index.css";
|
||||
// import ResumeInfoModal from "@/pages/CompanyJobsPage/components/ResumeInfoModal";
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
setIsCollapsed(saved === "true");
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
// 监听自定义事件
|
||||
const handleSidebarToggle = (event) => {
|
||||
setIsCollapsed(event.detail.isCollapsed);
|
||||
};
|
||||
|
||||
window.addEventListener("sidebarToggle", handleSidebarToggle);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener("sidebarToggle", handleSidebarToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`main-content ${isCollapsed ? "sidebar-collapsed" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{/* <ResumeInfoModal visible={true} /> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
42
src/components/Modal/index.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5); /* 遮罩层颜色 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
40
src/components/Modal/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import "./index.css";
|
||||
|
||||
const Modal = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
children,
|
||||
className = "",
|
||||
maskClosable = true,
|
||||
}) => {
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const handleMaskClick = (e) => {
|
||||
if (maskClosable && e.target.classList.contains("modal-mask")) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className={`modal-mask ${className}`} onClick={handleMaskClick}>
|
||||
<div className="modal-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
195
src/components/Rank/index.css
Normal file
@@ -0,0 +1,195 @@
|
||||
.module-class-rank {
|
||||
width: 360px;
|
||||
height: 413px;
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
margin-right: 20px;
|
||||
border: 1px solid #fff;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
.module-class-rank-title {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.module-class-rank-podium {
|
||||
width: 288px;
|
||||
height: 120px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
> li {
|
||||
width: 88px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
|
||||
.module-class-rank-podium-avatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -24px;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.module-class-rank-podium-name {
|
||||
color: #1d2129;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 30px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
> i {
|
||||
height: 27px;
|
||||
background-size: 100% 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.module-class-rank-podium-icon1 {
|
||||
width: 14px;
|
||||
background-image: url("@/assets/images/Rank/1.png");
|
||||
}
|
||||
.module-class-rank-podium-icon2 {
|
||||
width: 27px;
|
||||
background-image: url("@/assets/images/Rank/2.png");
|
||||
}
|
||||
.module-class-rank-podium-icon3 {
|
||||
width: 26px;
|
||||
background-image: url("@/assets/images/Rank/3.png");
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-podium-item1 {
|
||||
height: 98px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 251, 238, 1),
|
||||
rgba(255, 251, 238, 0)
|
||||
);
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/first_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item2 {
|
||||
height: 80px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(238, 238, 238, 1),
|
||||
rgba(238, 238, 238, 0)
|
||||
);
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/second_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item3 {
|
||||
height: 70px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 239, 230, 1),
|
||||
rgba(238, 238, 238, 0)
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/third_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.module-class-rank-list {
|
||||
width: 320px;
|
||||
height: 152px;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.module-class-rank-list-item {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
background-color: #fafafa;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
|
||||
> i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.module-class-rank-list-item-icon4 {
|
||||
background-image: url("@/assets/images/Rank/icon4.png");
|
||||
}
|
||||
.module-class-rank-list-item-icon5 {
|
||||
background-image: url("@/assets/images/Rank/icon5.png");
|
||||
}
|
||||
.module-class-rank-list-item-icon6 {
|
||||
background-image: url("@/assets/images/Rank/icon6.png");
|
||||
}
|
||||
> p {
|
||||
font-size: 14px;
|
||||
height: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: #616065;
|
||||
margin-left: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
> span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #616065;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/components/Rank/index.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Avatar } from "@arco-design/web-react";
|
||||
import "./index.css";
|
||||
|
||||
const Rank = ({ className }) => {
|
||||
return (
|
||||
<div className={`module-class-rank ${className}`}>
|
||||
<p className="module-class-rank-title">班级排名</p>
|
||||
<ul className="module-class-rank-podium">
|
||||
<li className="module-class-rank-podium-item2">
|
||||
<Avatar className="module-class-rank-podium-avatar">
|
||||
<img
|
||||
alt="avatar"
|
||||
src="//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp"
|
||||
/>
|
||||
</Avatar>
|
||||
<span className="module-class-rank-podium-name">你好呀</span>
|
||||
<i className="module-class-rank-podium-icon2"></i>
|
||||
</li>
|
||||
<li className="module-class-rank-podium-item1">
|
||||
<Avatar className="module-class-rank-podium-avatar">
|
||||
<img
|
||||
alt="avatar"
|
||||
src="//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp"
|
||||
/>
|
||||
</Avatar>
|
||||
<span className="module-class-rank-podium-name">你好呀</span>
|
||||
<i className="module-class-rank-podium-icon1"></i>
|
||||
</li>
|
||||
<li className="module-class-rank-podium-item3">
|
||||
<Avatar className="module-class-rank-podium-avatar">
|
||||
<img
|
||||
alt="avatar"
|
||||
src="//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp"
|
||||
/>
|
||||
</Avatar>
|
||||
<span className="module-class-rank-podium-name">你好呀</span>
|
||||
<i className="module-class-rank-podium-icon3"></i>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="module-class-rank-list">
|
||||
<li className="module-class-rank-list-item">
|
||||
<i className="module-class-rank-list-item-icon4" />
|
||||
<p>张雪花</p>
|
||||
<span>100分</span>
|
||||
</li>
|
||||
<li className="module-class-rank-list-item">
|
||||
<i className="module-class-rank-list-item-icon5" />
|
||||
<p>张雪花</p>
|
||||
<span>100分</span>
|
||||
</li>
|
||||
<li className="module-class-rank-list-item">
|
||||
<i className="module-class-rank-list-item-icon6" />
|
||||
<p>张雪花</p>
|
||||
<span>100分</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rank;
|
||||
383
src/components/ResumeEditModal/index.css
Normal file
@@ -0,0 +1,383 @@
|
||||
/* 简历编辑弹窗样式 */
|
||||
.resume-edit-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.resume-edit-modal-content {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗头部 */
|
||||
.resume-edit-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.resume-edit-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111111;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resume-edit-modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #1e40af;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.resume-edit-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.resume-edit-modal-close:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 弹窗主体 */
|
||||
.resume-edit-modal-body {
|
||||
padding: 32px;
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 简历区域 */
|
||||
.resume-edit-section {
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.resume-edit-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.resume-edit-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111111;
|
||||
margin: 0 0 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resume-edit-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: #1e40af;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-item-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #555555;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
background: #ffffff;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
padding: 12px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 工作经历特殊样式 */
|
||||
.experience-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.responsibilities-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.responsibilities-list {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.responsibility-item {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 技能特长样式 */
|
||||
.skills-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
background: #e0edff;
|
||||
color: #1e40af;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b8daff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.skill-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1e40af;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.skill-remove:hover {
|
||||
background: #1e40af;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skill-add-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.skill-input:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
|
||||
}
|
||||
|
||||
.skill-add-btn {
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-add-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* 弹窗底部 */
|
||||
.resume-edit-modal-footer {
|
||||
padding: 20px 32px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #666666;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f8f9fa;
|
||||
color: #333333;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1e40af;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.resume-edit-modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.resume-edit-modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.resume-edit-modal-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.resume-edit-modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skill-add-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
455
src/components/ResumeEditModal/index.jsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./index.css";
|
||||
|
||||
const ResumeEditModal = ({
|
||||
resume,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
isEditMode = false,
|
||||
}) => {
|
||||
const [editData, setEditData] = useState({
|
||||
personalInfo: {
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
location: "",
|
||||
},
|
||||
education: {
|
||||
university: "",
|
||||
major: "",
|
||||
degree: "",
|
||||
graduationYear: "",
|
||||
},
|
||||
experience: {
|
||||
company: "",
|
||||
position: "",
|
||||
duration: "",
|
||||
responsibilities: [],
|
||||
},
|
||||
skills: [],
|
||||
});
|
||||
const [isEditing, setIsEditing] = useState(isEditMode);
|
||||
const [currentSkill, setCurrentSkill] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (resume && isOpen) {
|
||||
setEditData({
|
||||
personalInfo: {
|
||||
name: resume.personalInfo?.name || "",
|
||||
phone: resume.personalInfo?.phone || "",
|
||||
email: resume.personalInfo?.email || "",
|
||||
location: resume.personalInfo?.location || "",
|
||||
},
|
||||
education: {
|
||||
university: resume.education?.university || "",
|
||||
major: resume.education?.major || "",
|
||||
degree: resume.education?.degree || "",
|
||||
graduationYear: resume.education?.graduationYear || "",
|
||||
},
|
||||
experience: {
|
||||
company: resume.company || "",
|
||||
position: resume.name || "",
|
||||
duration: resume.experience || "",
|
||||
responsibilities: [
|
||||
"负责核心业务开发与维护",
|
||||
"参与系统架构设计",
|
||||
"协助团队制定技术规范",
|
||||
],
|
||||
},
|
||||
skills: resume.skills || [],
|
||||
});
|
||||
}
|
||||
}, [resume, isOpen]);
|
||||
|
||||
if (!isOpen || !resume) return null;
|
||||
|
||||
const handleInputChange = (section, field, value) => {
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
if (currentSkill.trim() && !editData.skills.includes(currentSkill.trim())) {
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
skills: [...prev.skills, currentSkill.trim()],
|
||||
}));
|
||||
setCurrentSkill("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSkill = (skillToRemove) => {
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
skills: prev.skills.filter((skill) => skill !== skillToRemove),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...resume,
|
||||
personalInfo: editData.personalInfo,
|
||||
education: editData.education,
|
||||
experience: editData.experience,
|
||||
skills: editData.skills,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="resume-edit-modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="resume-edit-modal-content">
|
||||
<div className="resume-edit-modal-header">
|
||||
<h3 className="resume-edit-modal-title">
|
||||
{isEditing ? "编辑简历" : "简历详情"}
|
||||
</h3>
|
||||
<div className="resume-edit-modal-actions">
|
||||
{!isEditing && (
|
||||
<button className="btn-edit" onClick={() => setIsEditing(true)}>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
<button className="resume-edit-modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="resume-edit-modal-body">
|
||||
{/* 个人信息 */}
|
||||
<div className="resume-edit-section">
|
||||
<h4 className="resume-edit-section-title">个人信息</h4>
|
||||
<div className="resume-edit-content">
|
||||
<div className="form-grid">
|
||||
<div className="form-item">
|
||||
<label className="form-label">姓名:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.personalInfo.name}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"personalInfo",
|
||||
"name",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.personalInfo.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">电话:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.personalInfo.phone}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"personalInfo",
|
||||
"phone",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.personalInfo.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">邮箱:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={editData.personalInfo.email}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"personalInfo",
|
||||
"email",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.personalInfo.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">地址:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.personalInfo.location}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"personalInfo",
|
||||
"location",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.personalInfo.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 教育背景 */}
|
||||
<div className="resume-edit-section">
|
||||
<h4 className="resume-edit-section-title">教育背景</h4>
|
||||
<div className="resume-edit-content">
|
||||
<div className="form-grid">
|
||||
<div className="form-item">
|
||||
<label className="form-label">院校:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.education.university}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"education",
|
||||
"university",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.education.university}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">专业:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.education.major}
|
||||
onChange={(e) =>
|
||||
handleInputChange("education", "major", e.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.education.major}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">学历:</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
className="form-input"
|
||||
value={editData.education.degree}
|
||||
onChange={(e) =>
|
||||
handleInputChange("education", "degree", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">请选择学历</option>
|
||||
<option value="专科">专科</option>
|
||||
<option value="本科">本科</option>
|
||||
<option value="硕士">硕士</option>
|
||||
<option value="博士">博士</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.education.degree}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">毕业年份:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.education.graduationYear}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"education",
|
||||
"graduationYear",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.education.graduationYear}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作经历 */}
|
||||
<div className="resume-edit-section">
|
||||
<h4 className="resume-edit-section-title">工作经历</h4>
|
||||
<div className="resume-edit-content">
|
||||
<div className="experience-item">
|
||||
<div className="form-grid">
|
||||
<div className="form-item">
|
||||
<label className="form-label">公司:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.experience.company}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"experience",
|
||||
"company",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.experience.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label">职位:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.experience.position}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"experience",
|
||||
"position",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.experience.position}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-item form-item-full">
|
||||
<label className="form-label">工作时间:</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.experience.duration}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
"experience",
|
||||
"duration",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="form-value">
|
||||
{editData.experience.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="responsibilities-section">
|
||||
<label className="form-label">工作职责:</label>
|
||||
<ul className="responsibilities-list">
|
||||
{editData.experience.responsibilities.map((resp, index) => (
|
||||
<li key={index} className="responsibility-item">
|
||||
{resp}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 技能特长 */}
|
||||
<div className="resume-edit-section">
|
||||
<h4 className="resume-edit-section-title">技能特长</h4>
|
||||
<div className="resume-edit-content">
|
||||
<div className="skills-container">
|
||||
<div className="skills-list">
|
||||
{editData.skills.map((skill, index) => (
|
||||
<div key={index} className="skill-item">
|
||||
<span className="skill-text">{skill}</span>
|
||||
{isEditing && (
|
||||
<button
|
||||
className="skill-remove"
|
||||
onClick={() => handleRemoveSkill(skill)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="skill-add-form">
|
||||
<input
|
||||
type="text"
|
||||
className="skill-input"
|
||||
value={currentSkill}
|
||||
onChange={(e) => setCurrentSkill(e.target.value)}
|
||||
placeholder="添加技能"
|
||||
onKeyPress={(e) => e.key === "Enter" && handleAddSkill()}
|
||||
/>
|
||||
<button className="skill-add-btn" onClick={handleAddSkill}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="resume-edit-modal-footer">
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button className="btn-primary" onClick={handleSave}>
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeEditModal;
|
||||
107
src/components/StageProgress/index.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.stage-progress-wrapper {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding-left: 20px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
|
||||
.stage-progress-item {
|
||||
height: 72px;
|
||||
background-size: 100% 100%;
|
||||
margin-right: -10px;
|
||||
}
|
||||
|
||||
.stage-progress-item1 {
|
||||
width: 204px;
|
||||
background-image: url("@/assets/images/StageProgress/step1.png");
|
||||
}
|
||||
|
||||
.stage-progress-item2 {
|
||||
width: 228px;
|
||||
background-image: url("@/assets/images/StageProgress/step2.png");
|
||||
}
|
||||
|
||||
.stage-progress-star {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 108px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-right: -10px;
|
||||
margin-left: 30px;
|
||||
margin-top: 20px;
|
||||
|
||||
> span {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
.star {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/StageProgress/star_active.png");
|
||||
background-size: 100% 100%;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(50%, -50%);
|
||||
width: 40px;
|
||||
height: 0px;
|
||||
border: 1px dashed #bfbfbf;
|
||||
}
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 0px;
|
||||
border: 1px dashed #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stage-progress-item3 {
|
||||
width: 294px;
|
||||
background-image: url("@/assets/images/StageProgress/step3.png");
|
||||
}
|
||||
|
||||
.stage-progress-item4 {
|
||||
width: 284px;
|
||||
background-image: url("@/assets/images/StageProgress/step4.png");
|
||||
}
|
||||
|
||||
.stage-progress-item1-active {
|
||||
width: 204px;
|
||||
background-image: url("@/assets/images/StageProgress/step1_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-item2-active {
|
||||
width: 228px;
|
||||
background-image: url("@/assets/images/StageProgress/step2_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-star-active {
|
||||
background-image: url("@/assets/images/StageProgress/star_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-item3-active {
|
||||
width: 294px;
|
||||
background-image: url("@/assets/images/StageProgress/step3_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-item4-active {
|
||||
width: 284px;
|
||||
background-image: url("@/assets/images/StageProgress/step4_active.png");
|
||||
}
|
||||
}
|
||||
25
src/components/StageProgress/index.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
const StageProgress = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClickStar = () => {
|
||||
navigate("/career-tree");
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="stage-progress-wrapper">
|
||||
<li className="stage-progress-item stage-progress-item1-active" />
|
||||
<li className="stage-progress-item stage-progress-item2" />
|
||||
<li className="stage-progress-star" onClick={handleClickStar}>
|
||||
<div className="star" />
|
||||
<span>垂直方向选择</span>
|
||||
</li>
|
||||
<li className="stage-progress-item stage-progress-item3" />
|
||||
<li className="stage-progress-item stage-progress-item4" />
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default StageProgress;
|
||||
36
src/components/TestEmotionalHooks.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* 测试组件的样式 */
|
||||
.test-component {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.test-component h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.test-component button {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.test-component button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.data-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
632
src/components/VideoPlayer/CourseEvaluationModal.jsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import React, { useState } from "react";
|
||||
import Portal from "@/components/common/Portal";
|
||||
|
||||
const CourseEvaluationModal = ({ isVisible, onClose, onSubmit }) => {
|
||||
// 评价状态
|
||||
const [ratings, setRatings] = useState({
|
||||
discipline: 0, // 课堂纪律
|
||||
teaching: 0, // 教学水平
|
||||
effectiveness: 0, // 课堂实效
|
||||
overall: 0, // 综合评价
|
||||
});
|
||||
|
||||
const [comment, setComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// 管理body的modal-open类
|
||||
React.useEffect(() => {
|
||||
if (isVisible) {
|
||||
document.body.classList.add("modal-open");
|
||||
} else {
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
document.body.classList.remove("modal-open");
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
// 评价维度配置
|
||||
const ratingDimensions = [
|
||||
{ key: "discipline", label: "课堂纪律" },
|
||||
{ key: "teaching", label: "教学水平" },
|
||||
{ key: "effectiveness", label: "课堂实效" },
|
||||
{ key: "overall", label: "课程收获" },
|
||||
];
|
||||
|
||||
// 处理星级评分
|
||||
const handleStarRating = (dimension, rating) => {
|
||||
setRatings((prev) => ({
|
||||
...prev,
|
||||
[dimension]: rating,
|
||||
}));
|
||||
};
|
||||
|
||||
// 渲染星级评分
|
||||
const renderStarRating = (dimension, currentRating) => {
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className={`star ${star <= currentRating ? "filled" : ""}`}
|
||||
onClick={() => handleStarRating(dimension, star)}
|
||||
onMouseEnter={(e) => {
|
||||
// 悬停效果
|
||||
const stars =
|
||||
e.currentTarget.parentElement.querySelectorAll(".star");
|
||||
stars.forEach((s, index) => {
|
||||
s.classList.toggle("hover", index < star);
|
||||
});
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
// 清除悬停效果
|
||||
const stars =
|
||||
e.currentTarget.parentElement.querySelectorAll(".star");
|
||||
stars.forEach((s) => s.classList.remove("hover"));
|
||||
}}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染评分条(模拟导师评分显示)
|
||||
const renderRatingBars = () => {
|
||||
const ratings = [
|
||||
{ stars: 5, percentage: 88.1 },
|
||||
{ stars: 4, percentage: 5.8 },
|
||||
{ stars: 3, percentage: 3.6 },
|
||||
{ stars: 2, percentage: 1.2 },
|
||||
{ stars: 1, percentage: 0.2 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rating-bars">
|
||||
{ratings.map((item) => (
|
||||
<div key={item.stars} className="rating-bar-row">
|
||||
<div className="stars-label">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`bar-star ${i < item.stars ? "filled" : ""}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="rating-bar">
|
||||
<div
|
||||
className="rating-fill"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="percentage">{item.percentage}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 模拟提交延迟
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
if (onSubmit) {
|
||||
onSubmit({ ratings, comment });
|
||||
}
|
||||
onClose();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal className="course-evaluation-portal">
|
||||
<div className="course-evaluation-overlay">
|
||||
<div className="course-evaluation-modal">
|
||||
{/* 弹窗头部 */}
|
||||
<div className="modal-header">
|
||||
<div className="header-content">
|
||||
<div className="book-icon">📖</div>
|
||||
<div className="header-text">
|
||||
<h2 className="modal-title">请对本节课进行评价</h2>
|
||||
<p className="modal-subtitle">请您客观公正的评价</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="modal-content">
|
||||
{/* 老师信息和评分展示 */}
|
||||
<div className="teacher-evaluation-section">
|
||||
<div className="teacher-info">
|
||||
<div className="teacher-avatar">
|
||||
<img
|
||||
src="/api/placeholder/80/80"
|
||||
alt="老师头像"
|
||||
onError={(e) => {
|
||||
e.target.style.display = "none";
|
||||
e.target.nextSibling.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="avatar-placeholder"
|
||||
style={{ display: "none" }}
|
||||
>
|
||||
顾
|
||||
</div>
|
||||
</div>
|
||||
<div className="teacher-details">
|
||||
<h3 className="teacher-name">顾华</h3>
|
||||
<p className="course-name">机械与智能制造班</p>
|
||||
<div className="teacher-rating">
|
||||
<span className="rating-score">9.5</span>
|
||||
<span className="rating-text">(863位学员评价)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评分条形图 */}
|
||||
<div className="rating-visualization">{renderRatingBars()}</div>
|
||||
</div>
|
||||
|
||||
{/* 评价维度 */}
|
||||
<div className="evaluation-dimensions">
|
||||
<div className="dimensions-grid">
|
||||
{ratingDimensions.map((dimension) => (
|
||||
<div key={dimension.key} className="dimension-item">
|
||||
<div className="dimension-label">{dimension.label}</div>
|
||||
{renderStarRating(dimension.key, ratings[dimension.key])}
|
||||
<div className="rating-text">
|
||||
({ratings[dimension.key]}/5)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细评价 */}
|
||||
<div className="detailed-evaluation">
|
||||
<div className="evaluation-label">课程优化建议(选填)</div>
|
||||
<textarea
|
||||
className="evaluation-textarea"
|
||||
placeholder="请分享您对本次课程的改进建议(选填)..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="char-count">{comment.length}/500</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗底部按钮 */}
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="submit-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "提交中..." : "提交评价"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 样式定义 */}
|
||||
<style>{`
|
||||
.course-evaluation-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.course-evaluation-modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: modalEnter 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
||||
padding: 24px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #2196f3;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.teacher-evaluation-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.teacher-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.teacher-avatar {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.teacher-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.teacher-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.teacher-name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.teacher-rating {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rating-score {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.rating-visualization {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rating-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rating-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stars-label {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bar-star {
|
||||
color: #ddd;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bar-star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.rating-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rating-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffc107 0%, #ff9800 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evaluation-dimensions {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dimensions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dimension-item {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dimension-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.star-rating {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.star {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.star.filled,
|
||||
.star.hover {
|
||||
color: #ffc107;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.star:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detailed-evaluation {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.evaluation-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.evaluation-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.evaluation-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
.char-count {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 24px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.cancel-button:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%);
|
||||
color: white;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
.submit-button:disabled,
|
||||
.cancel-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.course-evaluation-overlay {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dimensions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.teacher-info {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.teacher-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.teacher-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.rating-score {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseEvaluationModal;
|
||||
690
src/components/VideoPlayer/index.jsx
Normal file
@@ -0,0 +1,690 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import CourseEvaluationModal from "./CourseEvaluationModal.jsx";
|
||||
|
||||
const VideoPlayer = ({
|
||||
title,
|
||||
courseStatus,
|
||||
startTime,
|
||||
endTime,
|
||||
onReplayRequest,
|
||||
onFullscreenChange,
|
||||
onTimeUpdate,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showEvaluationModal, setShowEvaluationModal] = useState(false);
|
||||
const [hasVideoEnded, setHasVideoEnded] = useState(false);
|
||||
|
||||
const videoRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const controlsTimeoutRef = useRef(null);
|
||||
// 格式化课程时间显示
|
||||
const formatCourseTime = (timeString) => {
|
||||
try {
|
||||
const date = new Date(timeString);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const courseDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
);
|
||||
|
||||
const diffDays = Math.floor((courseDate - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `今天 ${date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `明天 ${date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
} else if (diffDays === -1) {
|
||||
return `昨天 ${date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
} else {
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return timeString;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理回放请求
|
||||
const handleReplayRequest = () => {
|
||||
if (onReplayRequest) {
|
||||
onReplayRequest();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理播放/暂停
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理静音
|
||||
const handleMute = () => {
|
||||
if (videoRef.current) {
|
||||
const newMutedState = !isMuted;
|
||||
videoRef.current.muted = newMutedState;
|
||||
setIsMuted(newMutedState);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理音量调节
|
||||
const handleVolumeChange = (e) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理全屏
|
||||
const handleFullscreen = () => {
|
||||
if (!isFullscreen) {
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标移动显示控制栏
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 3秒后隐藏控制栏(仅在播放时)
|
||||
if (isPlaying) {
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理课程评价弹窗关闭
|
||||
const handleEvaluationClose = () => {
|
||||
setShowEvaluationModal(false);
|
||||
};
|
||||
|
||||
// 处理课程评价提交
|
||||
const handleEvaluationSubmit = (evaluationData) => {
|
||||
setShowEvaluationModal(false);
|
||||
// 这里可以调用API提交评价数据
|
||||
};
|
||||
|
||||
// 视频事件处理
|
||||
const handleVideoEvents = {
|
||||
onPlay: () => {
|
||||
setIsPlaying(true);
|
||||
setIsLoading(false);
|
||||
setHasError(false);
|
||||
setHasVideoEnded(false);
|
||||
},
|
||||
onPause: () => {
|
||||
setIsPlaying(false);
|
||||
},
|
||||
onLoadStart: () => {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
},
|
||||
onCanPlay: () => {
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
},
|
||||
onVolumeChange: () => {
|
||||
if (videoRef.current) {
|
||||
setVolume(videoRef.current.volume);
|
||||
setIsMuted(videoRef.current.muted);
|
||||
}
|
||||
},
|
||||
onTimeUpdate: () => {
|
||||
if (videoRef.current && onTimeUpdate) {
|
||||
const currentTime = videoRef.current.currentTime;
|
||||
const duration = videoRef.current.duration;
|
||||
onTimeUpdate({
|
||||
currentTime,
|
||||
duration,
|
||||
progress: duration > 0 ? currentTime / duration : 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onEnded: () => {
|
||||
setIsPlaying(false);
|
||||
setHasVideoEnded(true);
|
||||
// 延迟一点显示弹窗,让用户看到视频结束
|
||||
setTimeout(() => {
|
||||
setShowEvaluationModal(true);
|
||||
}, 500);
|
||||
},
|
||||
};
|
||||
|
||||
// 监听全屏状态变化
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const fullscreen = !!document.fullscreenElement;
|
||||
setIsFullscreen(fullscreen);
|
||||
if (onFullscreenChange) {
|
||||
onFullscreenChange(fullscreen);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
};
|
||||
}, [onFullscreenChange]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 统一使用固定的视频源
|
||||
const videoSrc = "/live.mp4";
|
||||
|
||||
// 根据课程状态决定是否显示视频播放器
|
||||
const shouldShowVideo = courseStatus === "live" || courseStatus === "replay";
|
||||
|
||||
// 添加容器尺寸监控日志
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
// Container size monitoring
|
||||
}
|
||||
}, [shouldShowVideo, courseStatus]);
|
||||
|
||||
// 添加视频元素尺寸监控日志
|
||||
useEffect(() => {
|
||||
if (videoRef.current && shouldShowVideo) {
|
||||
const video = videoRef.current;
|
||||
const handleLoadedMetadata = () => {};
|
||||
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
return () => {
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}
|
||||
}, [shouldShowVideo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="video-player-container unified-container"
|
||||
onMouseMove={shouldShowVideo ? handleMouseMove : undefined}
|
||||
onMouseLeave={() =>
|
||||
shouldShowVideo && isPlaying && setShowControls(false)
|
||||
}
|
||||
>
|
||||
{/* 根据状态渲染不同内容 */}
|
||||
{shouldShowVideo ? (
|
||||
<>
|
||||
{/* 视频元素 */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="video-player"
|
||||
src={videoSrc}
|
||||
poster="/api/placeholder/800/450"
|
||||
autoPlay={courseStatus === "live"}
|
||||
muted={false}
|
||||
{...handleVideoEvents}
|
||||
/>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="video-overlay loading-overlay">
|
||||
<div className="loading-spinner"></div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{hasError && (
|
||||
<div className="video-overlay error-overlay">
|
||||
<div className="error-icon">⚠️</div>
|
||||
<div>视频加载失败</div>
|
||||
<button
|
||||
className="retry-button"
|
||||
onClick={() => {
|
||||
setHasError(false);
|
||||
setIsLoading(true);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放按钮覆盖层 */}
|
||||
{!isPlaying && !isLoading && !hasError && (
|
||||
<div className="video-overlay play-overlay">
|
||||
<button className="play-button-large" onClick={handlePlayPause}>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div className={`video-controls ${showControls ? "visible" : ""}`}>
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={handlePlayPause}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
|
||||
{/* 音量控制 */}
|
||||
<div className="volume-control">
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={handleMute}
|
||||
title={isMuted ? "取消静音" : "静音"}
|
||||
>
|
||||
{isMuted || volume === 0 ? "🔇" : volume < 0.5 ? "🔉" : "🔊"}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="volume-slider"
|
||||
title="音量"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 直播状态指示器 */}
|
||||
{courseStatus === "live" && (
|
||||
<div className="live-indicator">
|
||||
<span className="live-dot"></span>
|
||||
直播中
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回放状态指示器 */}
|
||||
{courseStatus === "replay" && (
|
||||
<div className="replay-indicator">
|
||||
<span className="replay-icon">📺</span>
|
||||
课程回放中
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧控制 */}
|
||||
<div className="controls-right">
|
||||
{/* 测试评价弹窗按钮(仅开发测试用) */}
|
||||
<button
|
||||
className="control-button test-evaluation-button"
|
||||
onClick={() => {
|
||||
setShowEvaluationModal(true);
|
||||
}}
|
||||
title="测试课程评价(开发测试功能)"
|
||||
style={{
|
||||
background: "rgba(255, 193, 7, 0.8)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
|
||||
{/* 全屏按钮 */}
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={handleFullscreen}
|
||||
title={isFullscreen ? "退出全屏" : "全屏"}
|
||||
>
|
||||
{isFullscreen ? "⛶" : "⛶"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 非播放状态的提示界面 */
|
||||
<div className="course-status-overlay">
|
||||
{courseStatus === "upcoming" && (
|
||||
<div className="upcoming-content">
|
||||
<div className="status-icon">⏰</div>
|
||||
<h3 className="status-title">此课程即将开始</h3>
|
||||
<p className="status-message">
|
||||
开始时间:{startTime ? formatCourseTime(startTime) : "待定"}
|
||||
</p>
|
||||
<div className="status-description">
|
||||
请耐心等待课程开始,届时将自动开启直播
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{courseStatus === "completed" && (
|
||||
<div className="completed-content">
|
||||
<div className="status-icon">✅</div>
|
||||
<h3 className="status-title">此课程已结束</h3>
|
||||
<p className="status-message">
|
||||
结束时间:{endTime ? formatCourseTime(endTime) : ""}
|
||||
</p>
|
||||
<button className="replay-button" onClick={handleReplayRequest}>
|
||||
查看课程回放
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 课程评价弹窗 */}
|
||||
<CourseEvaluationModal
|
||||
isVisible={showEvaluationModal}
|
||||
onClose={handleEvaluationClose}
|
||||
onSubmit={handleEvaluationSubmit}
|
||||
courseInfo={{
|
||||
title: title,
|
||||
teacher: "顾华",
|
||||
course: "机械与智能制造班",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 样式定义 */}
|
||||
<style>{`
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-button-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.play-button-large:hover {
|
||||
background: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 20px 16px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.video-controls.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.replay-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.replay-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.unified-container {
|
||||
aspect-ratio: 16/9;
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.course-status-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.6));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.upcoming-content, .completed-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 18px;
|
||||
color: #e5e7eb;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 16px;
|
||||
color: #d1d5db;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.replay-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.replay-button:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
30
src/components/common/Portal.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Portal = ({ children, className = "portal-container" }) => {
|
||||
const [container, setContainer] = useState(null);
|
||||
useEffect(() => {
|
||||
// 创建容器元素
|
||||
const portalContainer = document.createElement("div");
|
||||
portalContainer.className = className;
|
||||
|
||||
// 添加到body
|
||||
document.body.appendChild(portalContainer);
|
||||
setContainer(portalContainer);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (document.body.contains(portalContainer)) {
|
||||
document.body.removeChild(portalContainer);
|
||||
}
|
||||
};
|
||||
}, [className]);
|
||||
|
||||
// 只有容器存在时才渲染Portal
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, container);
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
3575
src/data/mockData.js
Normal file
9
src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
149
src/normalize.css
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/* ==============================
|
||||
现代 CSS 重置核心样式
|
||||
============================== */
|
||||
/* 1. 盒模型统一:强制使用 border-box(更符合直觉) */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 2. 基础元素边距重置(仅针对易引发问题的元素) */
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 3. 列表样式清除(保留语义化结构,仅去符号) */
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
/* 部分浏览器默认有内边距 */
|
||||
}
|
||||
|
||||
/* 4. 链接样式标准化 */
|
||||
a {
|
||||
color: inherit;
|
||||
/* 继承父级颜色,避免默认蓝色 */
|
||||
text-decoration: none;
|
||||
/* 清除下划线(可根据需求调整) */
|
||||
background-color: transparent;
|
||||
/* 清除 IE 默认背景色 */
|
||||
}
|
||||
|
||||
/* 5. 表单元素标准化(覆盖主流浏览器差异) */
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
/* 统一边框 */
|
||||
border-radius: 0;
|
||||
/* 清除 iOS 圆角 */
|
||||
font: inherit;
|
||||
/* 继承字体样式 */
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
/* 清除默认聚焦轮廓(需自行添加可访问性聚焦样式) */
|
||||
}
|
||||
|
||||
/* 特殊处理:单选/复选框、文件上传 */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: auto;
|
||||
/* 避免被父级宽度压缩 */
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
/* 仅允许垂直调整大小 */
|
||||
}
|
||||
|
||||
/* 6. 媒体元素标准化 */
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
/* 防止溢出容器 */
|
||||
height: auto;
|
||||
/* 保持宽高比 */
|
||||
border-style: none;
|
||||
/* 清除 IE 默认边框 */
|
||||
}
|
||||
|
||||
/* 7. 标题层级标准化(避免浏览器默认大小差异) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
/* 继承父级字体大小(可根据需求自定义层级) */
|
||||
font-weight: 500;
|
||||
/* 统一字重(避免浏览器默认过粗) */
|
||||
}
|
||||
|
||||
/* 8. 段落与行高优化 */
|
||||
p {
|
||||
line-height: 1.5;
|
||||
/* 更舒适的阅读行高 */
|
||||
}
|
||||
|
||||
/* 9. 引用与代码块标准化 */
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
/* 保留合理边距 */
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #eee;
|
||||
/* 左侧装饰线 */
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
/* 统一等宽字体 */
|
||||
font-size: 1em;
|
||||
/* 避免浏览器默认放大 */
|
||||
}
|
||||
|
||||
/* 10. 表格边框合并(避免双边框) */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* 11. 隐藏滚动条(可选,根据设计需求) */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
-ms-overflow-style: none;
|
||||
/* IE/Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
/* 12. 可访问性增强:保留聚焦状态 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #4a90e2;
|
||||
/* 自定义聚焦轮廓 */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
111
src/pages/CalendarPage/components/CalendarHeader.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
const CalendarHeader = ({
|
||||
currentDate,
|
||||
currentView,
|
||||
onViewChange,
|
||||
onNavigate
|
||||
}) => {
|
||||
const monthNames = [
|
||||
'1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月'
|
||||
];
|
||||
|
||||
const formatTitle = () => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
if (currentView === 'month') {
|
||||
return `${year}年${monthNames[month]}`;
|
||||
} else {
|
||||
// 周视图显示周的时间范围
|
||||
const startOfWeek = new Date(currentDate);
|
||||
const day = startOfWeek.getDay();
|
||||
startOfWeek.setDate(currentDate.getDate() - day);
|
||||
|
||||
return `${year}年${monthNames[month]}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
|
||||
if (currentView === 'month') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setDate(newDate.getDate() - 7);
|
||||
}
|
||||
|
||||
onNavigate(newDate);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
|
||||
if (currentView === 'month') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else {
|
||||
newDate.setDate(newDate.getDate() + 7);
|
||||
}
|
||||
|
||||
onNavigate(newDate);
|
||||
};
|
||||
|
||||
const handleToday = () => {
|
||||
onNavigate(new Date());
|
||||
};
|
||||
|
||||
const handleViewChange = (view) => {
|
||||
onViewChange(view);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calendar-header">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={handlePrevious}
|
||||
title={currentView === 'month' ? '上一月' : '上一周'}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<h2 className="calendar-title">{formatTitle()}</h2>
|
||||
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={handleNext}
|
||||
title={currentView === 'month' ? '下一月' : '下一周'}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={handleToday}
|
||||
title="回到今天"
|
||||
style={{ marginLeft: '16px' }}
|
||||
>
|
||||
今
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="view-switcher">
|
||||
<button
|
||||
className={`view-button ${currentView === 'month' ? 'active' : ''}`}
|
||||
onClick={() => handleViewChange('month')}
|
||||
>
|
||||
月
|
||||
</button>
|
||||
<button
|
||||
className={`view-button ${currentView === 'week' ? 'active' : ''}`}
|
||||
onClick={() => handleViewChange('week')}
|
||||
>
|
||||
日
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarHeader;
|
||||
139
src/pages/CalendarPage/components/EventDetailModal.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Portal from "@/components/common/Portal";
|
||||
|
||||
const EventDetailModal = ({ isOpen, event, onClose }) => {
|
||||
// ESC键关闭模态框
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscKey);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscKey);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 如果未打开或无事件数据,不渲染
|
||||
if (!isOpen || !event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 事件类型映射
|
||||
const eventTypeNames = {
|
||||
class: "课程",
|
||||
meeting: "会议",
|
||||
lab: "实验",
|
||||
exam: "考试",
|
||||
};
|
||||
|
||||
// 处理遮罩层点击
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理关闭按钮点击
|
||||
const handleCloseClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTimeRange = (startTime, endTime) => {
|
||||
const startDate = new Date(startTime.replace(" ", "T"));
|
||||
const endDate = new Date(endTime.replace(" ", "T"));
|
||||
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
date: formatDate(startDate),
|
||||
timeRange: `${formatTime(startDate)} - ${formatTime(endDate)}`,
|
||||
};
|
||||
};
|
||||
|
||||
const { date, timeRange } = formatTimeRange(event.startTime, event.endTime);
|
||||
|
||||
return (
|
||||
<Portal className="event-detail-portal">
|
||||
<div className="event-detail-overlay" onClick={handleOverlayClick}>
|
||||
<div className="event-detail-modal">
|
||||
{/* 模态框头部 */}
|
||||
<div className="event-detail-header">
|
||||
<h3 className="event-detail-title">事件详情</h3>
|
||||
<button
|
||||
className="event-detail-close"
|
||||
onClick={handleCloseClick}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 模态框内容 */}
|
||||
<div className="event-detail-content">
|
||||
{/* 事件标题 */}
|
||||
<div className="event-detail-field">
|
||||
<div className="event-detail-label">事件标题</div>
|
||||
<div className="event-detail-value">{event.title}</div>
|
||||
</div>
|
||||
|
||||
{/* 事件类型 */}
|
||||
<div className="event-detail-field">
|
||||
<div className="event-detail-label">事件类型</div>
|
||||
<div className="event-detail-value">
|
||||
<span className={`event-type-badge event-type-${event.type}`}>
|
||||
{eventTypeNames[event.type] || event.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日期 */}
|
||||
<div className="event-detail-field">
|
||||
<div className="event-detail-label">日期</div>
|
||||
<div className="event-detail-value">{date}</div>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="event-detail-field">
|
||||
<div className="event-detail-label">时间</div>
|
||||
<div className="event-detail-value">
|
||||
<div className="event-time-range">{timeRange}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细描述 */}
|
||||
{event.description && (
|
||||
<div className="event-detail-field">
|
||||
<div className="event-detail-label">详细描述</div>
|
||||
<div className="event-detail-value">{event.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
127
src/pages/CalendarPage/components/MonthView.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { getMonthDays } from "@/data/mockData";
|
||||
|
||||
const MonthView = ({
|
||||
currentDate,
|
||||
events,
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
selectedDate,
|
||||
}) => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const days = getMonthDays(year, month);
|
||||
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
// 获取指定日期的事件
|
||||
const getEventsForDate = (date, month, year) => {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
const dateString = `${year}-${(month + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}-${date.toString().padStart(2, "0")}`;
|
||||
|
||||
return events.filter((event) => {
|
||||
const eventDate = event.startTime.split(" ")[0];
|
||||
return eventDate === dateString;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateClick = (day) => {
|
||||
if (onDateClick) {
|
||||
const clickedDate = new Date(day.year, day.month, day.date);
|
||||
onDateClick(clickedDate);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (day) => {
|
||||
if (!selectedDate) return false;
|
||||
return (
|
||||
selectedDate.getFullYear() === day.year &&
|
||||
selectedDate.getMonth() === day.month &&
|
||||
selectedDate.getDate() === day.date
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventItem = (event, index, dayEvents) => {
|
||||
const maxVisible = 3; // 每个日期最多显示3个事件
|
||||
|
||||
if (
|
||||
index >= maxVisible - 1 &&
|
||||
index === maxVisible - 1 &&
|
||||
dayEvents.length > maxVisible
|
||||
) {
|
||||
// 显示"更多"指示器
|
||||
const remainingCount = dayEvents.length - maxVisible + 1;
|
||||
return (
|
||||
<div key={`more-${index}`} className="event-more">
|
||||
+{remainingCount}更多
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (index >= maxVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`event-item ${event.type}`}
|
||||
title={`${event.title} (${event.startTime.split(" ")[1]} - ${
|
||||
event.endTime.split(" ")[1]
|
||||
})`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="month-view">
|
||||
{/* 星期标题 */}
|
||||
<div className="month-header">
|
||||
{weekDays.map((day) => (
|
||||
<div key={day} className="weekday-header">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日期网格 */}
|
||||
<div className="month-grid">
|
||||
{days.map((day, index) => {
|
||||
const dayEvents = getEventsForDate(day.date, day.month, day.year);
|
||||
const isToday = day.isToday;
|
||||
const isCurrentMonth = day.isCurrentMonth;
|
||||
const isSelectedDate = isSelected(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`day-cell ${!isCurrentMonth ? "other-month" : ""} ${
|
||||
isToday ? "today" : ""
|
||||
} ${isSelectedDate ? "selected" : ""}`}
|
||||
onClick={() => handleDateClick(day)}
|
||||
>
|
||||
<div className="day-number">{day.date}</div>
|
||||
|
||||
<div className="event-list">
|
||||
{dayEvents.map((event, eventIndex) =>
|
||||
renderEventItem(event, eventIndex, dayEvents)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonthView;
|
||||
165
src/pages/CalendarPage/components/WeekView.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { getWeekDays } from "@/data/mockData";
|
||||
|
||||
const WeekView = ({ currentDate, events, onDateClick, onEventClick }) => {
|
||||
const containerRef = useRef(null);
|
||||
const weekDays = getWeekDays(currentDate);
|
||||
const timeSlots = Array.from(
|
||||
{ length: 24 },
|
||||
(_, i) => `${i.toString().padStart(2, "0")}:00`
|
||||
);
|
||||
const weekDayNames = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
// 滚动到当前时间
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const scrollTop = currentHour * 60; // 每小时60px
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = Math.max(0, scrollTop - 120); // 提前2小时显示
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取指定日期的事件
|
||||
const getEventsForDate = (date) => {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
const dateString = `${date.getFullYear()}-${(date.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
|
||||
|
||||
return events.filter((event) => {
|
||||
const eventDate = event.startTime.split(" ")[0];
|
||||
return eventDate === dateString;
|
||||
});
|
||||
};
|
||||
|
||||
// 计算事件在时间轴上的位置和高度
|
||||
const calculateEventStyle = (event) => {
|
||||
const startTime = event.startTime.split(" ")[1];
|
||||
const endTime = event.endTime.split(" ")[1];
|
||||
|
||||
const startHour = parseInt(startTime.split(":")[0]);
|
||||
const startMinute = parseInt(startTime.split(":")[1]);
|
||||
const endHour = parseInt(endTime.split(":")[0]);
|
||||
const endMinute = parseInt(endTime.split(":")[1]);
|
||||
|
||||
const startOffset = startHour * 60 + startMinute; // 转换为分钟
|
||||
const endOffset = endHour * 60 + endMinute;
|
||||
const duration = endOffset - startOffset;
|
||||
|
||||
const top = startOffset; // 1分钟 = 1px
|
||||
const height = Math.max(duration, 30); // 最小高度30px
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
if (onDateClick) {
|
||||
onDateClick(date);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentTimeLine = () => {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const totalMinutes = currentHour * 60 + currentMinute;
|
||||
|
||||
// 只在今天显示当前时间线
|
||||
const isToday = weekDays.some(
|
||||
(date) => date.toDateString() === now.toDateString()
|
||||
);
|
||||
|
||||
if (!isToday) return null;
|
||||
|
||||
return (
|
||||
<div className="current-time-line" style={{ top: `${totalMinutes}px` }} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="week-view">
|
||||
{/* 周标题 */}
|
||||
<div className="week-header">
|
||||
<div className="time-header">时间</div>
|
||||
{weekDays.map((date, index) => {
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
className={`day-header ${isToday ? "today" : ""}`}
|
||||
onClick={() => handleDateClick(date)}
|
||||
>
|
||||
<div className="day-name">{weekDayNames[index]}</div>
|
||||
<div className="day-date">{date.getDate()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 周网格 */}
|
||||
<div className="week-grid" ref={containerRef}>
|
||||
{/* 时间列 */}
|
||||
<div className="time-column">
|
||||
{timeSlots.map((time) => (
|
||||
<div key={time} className="time-slot">
|
||||
{time}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日期列 */}
|
||||
{weekDays.map((date) => {
|
||||
const dayEvents = getEventsForDate(date);
|
||||
|
||||
return (
|
||||
<div key={date.toISOString()} className="day-column">
|
||||
{/* 小时格子 */}
|
||||
{timeSlots.map((time) => (
|
||||
<div key={time} className="hour-slot" />
|
||||
))}
|
||||
|
||||
{/* 事件块 */}
|
||||
{dayEvents.map((event) => {
|
||||
const style = calculateEventStyle(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`event-block ${event.type}`}
|
||||
style={style}
|
||||
title={event.description}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="event-title">{event.title}</div>
|
||||
<div className="event-time">
|
||||
{event.startTime.split(" ")[1]} -{" "}
|
||||
{event.endTime.split(" ")[1]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 当前时间线 */}
|
||||
{getCurrentTimeLine()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeekView;
|
||||
758
src/pages/CalendarPage/index.css
Normal file
@@ -0,0 +1,758 @@
|
||||
/* 日历页面样式 */
|
||||
.calendar-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.calendar-page-wrapper {
|
||||
width: 1120px;
|
||||
height: 862px;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 日历头部控制栏 */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.view-button.active {
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-button:hover:not(.active) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 日历主体容器 */
|
||||
.calendar-container {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 月视图样式 */
|
||||
.month-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.month-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.weekday-header:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 1fr);
|
||||
gap: 1px;
|
||||
background: #e5e7eb;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
min-height: 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
background: #fafbfc;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
background: #eff6ff !important;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
background: #dbeafe !important;
|
||||
border: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.day-cell.today .day-number {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.event-item.class {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.event-item.meeting {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.event-item.lab {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.event-item.exam {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.event-more {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 周视图样式 */
|
||||
.week-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px repeat(7, 1fr);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header); /* 使用全局header层级,确保不干扰弹窗 */
|
||||
}
|
||||
|
||||
.time-header {
|
||||
padding: 12px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.day-header:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.day-date {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.day-header.today .day-date {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 60px repeat(7, 1fr);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #fafbfc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.time-slot:nth-child(odd) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
border-right: 1px solid #f3f4f6;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.day-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.hour-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hour-slot:nth-child(odd) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.event-block {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px;
|
||||
z-index: 5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.event-block:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.event-block.class {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.event-block.meeting {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.event-block.lab {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.event-block.exam {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当前时间线 - 使用合理的层级 */
|
||||
.current-time-line {
|
||||
position: absolute;
|
||||
left: 60px;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #ef4444;
|
||||
z-index: var(--z-content); /* 使用内容层级,不需要很高 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.current-time-line::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 事件详情提示 - 使用全局z-index系统 */
|
||||
.event-tooltip {
|
||||
position: absolute;
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: var(--z-tooltip); /* 使用全局tooltip层级 */
|
||||
max-width: 200px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid var(--text-primary);
|
||||
}
|
||||
|
||||
/* 事件详情模态框样式 - 遵循跨国企业级后台系统设计哲学v2.0 */
|
||||
|
||||
/* 模态框遮罩层 */
|
||||
.event-detail-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: overlayFadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框主体 - 零线原则,依赖留白分隔 */
|
||||
.event-detail-modal {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15), 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
animation: modalSlideIn 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框头部 - 呼吸感间距 */
|
||||
.event-detail-header {
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 标题 - 意图驱动的信息层级 L1 */
|
||||
.event-detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 关闭按钮 - 上下文感知交互 */
|
||||
.event-detail-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.event-detail-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.event-detail-close:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 模态框内容区域 - 慷慨的留白 */
|
||||
.event-detail-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 字段容器 - 基于8px栅格的间距 */
|
||||
.event-detail-field {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.event-detail-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 字段标签 - 信息层级 L3 */
|
||||
.event-detail-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 字段值 - 信息层级 L2 */
|
||||
.event-detail-value {
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 事件类型徽章 - 语义化状态色板 */
|
||||
.event-type-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* 克制的色彩语言 - 柔和且低饱和度 */
|
||||
.event-type-class {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.event-type-meeting {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.event-type-lab {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.event-type-exam {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* 时间范围显示 - 等宽字体增强可读性 */
|
||||
.event-time-range {
|
||||
font-family: "SF Mono", "Monaco", "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
background: #f9fafb;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.calendar-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
min-height: 60px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
font-size: 9px;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calendar-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
grid-template-columns: 50px repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.time-header,
|
||||
.time-column {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
padding: 2px 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
min-height: 50px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
font-size: 8px;
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.calendar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #f3f4f6;
|
||||
border-top: 3px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.event-detail-modal {
|
||||
width: 95vw;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.event-detail-header,
|
||||
.event-detail-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-detail-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
93
src/pages/CalendarPage/index.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from "react";
|
||||
import { mockData } from "@/data/mockData";
|
||||
import CalendarHeader from "./components/CalendarHeader";
|
||||
import MonthView from "./components/MonthView";
|
||||
import WeekView from "./components/WeekView";
|
||||
import EventDetailModal from "./components/EventDetailModal";
|
||||
import "./index.css";
|
||||
|
||||
const CalendarPage = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [currentView, setCurrentView] = useState("month");
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [showEventDetail, setShowEventDetail] = useState(false);
|
||||
|
||||
const { calendarEvents } = mockData;
|
||||
|
||||
const handleViewChange = (view) => {
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
const handleNavigate = (newDate) => {
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
setSelectedDate(date);
|
||||
|
||||
// 如果在月视图中点击日期,可以切换到周视图
|
||||
if (currentView === "month") {
|
||||
setCurrentDate(date);
|
||||
// 可选:自动切换到周视图
|
||||
// setCurrentView('week');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
setShowEventDetail(true);
|
||||
};
|
||||
|
||||
const handleCloseEventDetail = () => {
|
||||
setShowEventDetail(false);
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
|
||||
const renderCalendarView = () => {
|
||||
if (currentView === "month") {
|
||||
return (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
events={calendarEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<WeekView
|
||||
currentDate={currentDate}
|
||||
events={calendarEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calendar-page">
|
||||
<div className="calendar-page-wrapper">
|
||||
{/* 日历头部控制栏 */}
|
||||
<CalendarHeader
|
||||
currentDate={currentDate}
|
||||
currentView={currentView}
|
||||
onViewChange={handleViewChange}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
{/* 日历主体 */}
|
||||
<div className="calendar-container">{renderCalendarView()}</div>
|
||||
{/* 事件详情模态框 */}
|
||||
<EventDetailModal
|
||||
isOpen={showEventDetail}
|
||||
event={selectedEvent}
|
||||
onClose={handleCloseEventDetail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
679
src/pages/CareerTreePage/index.css
Normal file
@@ -0,0 +1,679 @@
|
||||
/* 就业管家知识树页面样式 */
|
||||
.career-tree-page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
/* 与整体设计系统保持一致的色彩变量 */
|
||||
--primary-color: #3b82f6;
|
||||
--primary-light: #dbeafe;
|
||||
--primary-lighter: #eff6ff;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 全局强制垂直布局规则 - 最高优先级 */
|
||||
.career-tree-page .section-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
justify-content: flex-start !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.career-tree-page .center-item {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: none !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.career-tree-page.tall-screen {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.career-tree-page.tall-screen .top-content-wrapper {
|
||||
height: 1080px !important;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding-top: 80px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
background-size: cover;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.career-tree-page.tall-screen .top-content-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.career-tree-page.tall-screen .top-content {
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.top-content-wrapper {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
background-size: cover;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 1400px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tree-layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 顶部菜单容器样式 */
|
||||
.top-menu-container {
|
||||
width: 100%;
|
||||
margin-bottom: 30px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
background: var(--card-bg);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 移除menu-header相关样式,不再需要标题 */
|
||||
|
||||
.menu-item {
|
||||
background: #f8fafc;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--primary-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item.selected {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-item.selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 左栏样式 */
|
||||
.left-column {
|
||||
width: 20%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
background: var(--card-bg);
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
z-index: 66;
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.left-item {
|
||||
background: var(--primary-lighter);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.left-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 16px;
|
||||
background: white;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-item.clickable:hover {
|
||||
background: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.resource-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-icon {
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.view-detail-button {
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.view-detail-button:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 中栏样式 */
|
||||
.center-column {
|
||||
width: 36%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
background: var(--card-bg);
|
||||
padding: 32px 24px;
|
||||
border-radius: 16px;
|
||||
width: 360px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
/* 确保center-panel自身布局稳定 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.center-section {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
/* margin-bottom已移除,使用parent的gap代替 */
|
||||
}
|
||||
|
||||
/* 项目将按照DOM顺序显示,在JSX中已经通过.sort()方法保证了正确的排序 */
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
border: 2px solid #efefef;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.section-header.checked-course {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
background: var(--primary-lighter);
|
||||
}
|
||||
|
||||
.section-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
margin-top: 16px;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px;
|
||||
/* 确保在所有屏幕尺寸下都保持垂直排列 */
|
||||
align-items: stretch !important;
|
||||
width: 100%;
|
||||
/* 防止flex容器收缩或任何异常布局 */
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
background: var(--primary-lighter);
|
||||
padding: 12px 48px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
/* 移除可能引起布局问题的order属性,改用自然文档流排序 */
|
||||
/* order: var(--item-order, 0); */
|
||||
/* 确保item始终占满容器宽度 */
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
/* 确保flex item不会收缩 */
|
||||
flex-shrink: 0 !important;
|
||||
/* 强制块级显示,防止内联或其他异常显示模式 */
|
||||
display: block !important;
|
||||
/* 确保没有浮动 */
|
||||
float: none !important;
|
||||
/* 防止绝对定位 */
|
||||
position: relative !important;
|
||||
/* 确保垂直排列时的底部间距 */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.center-item:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.center-item.checked-course-item {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.center-item.checked-course-item .item-content {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 右栏样式 */
|
||||
.right-column {
|
||||
width: 20%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
background: var(--card-bg);
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
z-index: 66;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.right-item {
|
||||
background: var(--primary-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.right-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* SVG 连接线样式 */
|
||||
.connection-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
stroke: rgba(59, 130, 246, 0.3);
|
||||
stroke-width: 55px;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 所有屏幕尺寸确保布局稳定性 */
|
||||
@media (min-width: 1201px) {
|
||||
.section-content {
|
||||
/* 强制垂直布局,防止任何意外的flex布局变化 */
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
/* 确保item的布局稳定 */
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-bottom: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-basis: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超大屏幕额外保障 */
|
||||
@media (min-width: 1601px) {
|
||||
.section-content {
|
||||
/* 在超大屏幕上再次强制垂直布局 */
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
/* 在超大屏幕上确保item的布局稳定 */
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式样式 */
|
||||
@media (max-width: 1600px) {
|
||||
.canvas-container {
|
||||
width: 1200px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.top-menu-container {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
width: 320px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.canvas-container {
|
||||
width: 1000px;
|
||||
}
|
||||
|
||||
.tree-layout {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
width: 300px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.top-menu-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
overflow-x: auto;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.tree-layout {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.left-column,
|
||||
.center-column,
|
||||
.right-column {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.left-panel,
|
||||
.right-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.connection-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 确保小屏幕下也保持垂直布局 */
|
||||
.section-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-menu-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
font-size: 10px;
|
||||
padding: 5px 8px;
|
||||
flex: 0 0 calc(33.333% - 4px);
|
||||
}
|
||||
|
||||
.top-content {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
width: 100%;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
/* 确保移动端也保持垂直布局 */
|
||||
.section-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||