This commit is contained in:
2025-08-15 16:16:41 +08:00
commit 182abccc60
171 changed files with 26833 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
# README
## 安装
```
pnpm
```
## 运行命令
### 开发环境
```
pnpm dev
```

53
eslint.config.js Normal file
View 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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

40
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

14
postcss.config.js Normal file
View 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

Binary file not shown.

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

71
src/App.jsx Normal file
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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);
}
}

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}
}
}

View 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;

View 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;
}
}

View 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;

View 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");
}
}

View 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;

View 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;
}

View 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;

View 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;

View 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

File diff suppressed because it is too large Load Diff

9
src/main.jsx Normal file
View 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
View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More