feat: 初始化实践教学评价平台项目

- 创建Vue3项目结构,使用ElementPlus和ECharts
- 实现统一样式系统和可复用组件(BaseCard、BaseButton、PageLayout)
- 完成登录页面设计,支持四种用户角色(学生、教师、企业、专家)
- 实现登录功能和基于角色的权限控制系统
- 配置路由守卫和状态管理(Pinia)
- 创建Mock数据生成器支持演示功能

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sladro 2025-09-14 17:50:23 +08:00
commit b3d4f5d7c3
25 changed files with 3089 additions and 0 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"mcp__puppeteer__puppeteer_screenshot",
"mcp__puppeteer__puppeteer_click",
"mcp__puppeteer__puppeteer_fill",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Dependencies
node_modules/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Build output
dist/
build/
# Environment variables
.env.local
.env.development.local
.env.test.local
.env.production.local
# Temporary files
*.tmp
*.temp

81
CLAUDE.md Normal file
View File

@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
这是一个实践教学评价平台演示项目,支持企业、教师、行业协会专家、学生四类角色进行实践成果提交、评价、画像展示、报告生成等功能。
## 技术栈
- **前端**: Vue3 + ElementPlus + ECharts
- **数据**: Mock数据无后端
- **目标平台**: PC Web端不支持移动端
## 核心架构
### 用户角色系统
- 学校管理员:仅维护账号
- 企业:对学生实践成果进行指标打分、上传佐证材料
- 教师:评价 + 查看学生画像 + 生成报告
- 行业协会专家:输入评价指标与意见
- 学生:提交成果、自评、互评
### 主要功能模块
1. **登录鉴权** - 统一登录页,基于角色的权限控制
2. **评价流程** - 筛选页 + 角色专属评价页
3. **学生画像** - 成绩分布图表、能力矩阵雷达图(仅教师可见)
4. **评价报告** - 发展监测、专项分析、报告生成
### 路由结构
- `/` - 首页双入口
- `/evaluate` - 评价页面
- `/portrait` - 学生能力画像(仅教师)
- `/report/{studentId}` - 评价报告页
- `/report/{id}/monitor` - 发展监测
- `/report/{id}/analysis` - 能力报告分析
### Mock数据结构设计
- 用户信息与角色数据
- 项目信息(学期、年级、班级)
- 评价数据
- 学生画像数据
- 成绩分布和能力矩阵数据
## 开发注意事项
### 演示项目特点
- 纯前端项目所有数据使用Mock生成
- 无后端接口,数据存储在前端内存/LocalStorage
- 无复杂加密要求
- 重点关注前端交互和界面展示
### 权限控制
- 使用前端路由守卫模拟权限控制
- 学生画像页面仅教师角色可见
- 不同角色在评价页面显示不同功能按钮
### 文件上传模拟
- 支持类型pdf/doc/jpg/png
- 单文件大小≤10MB
- 前端模拟文件上传,显示文件列表
### 图表组件
- 使用ECharts实现柱状图、雷达图、折线图
- 画像页面包含成绩分布柱状图和能力矩阵三角形雷达图
- 使用Mock数据生成随机但合理的图表数据
### 页面交互
- 弹框输入评价指标和意见
- Toast提示完成状态
- 表格展示学生名单和评分
- PDF报告下载可使用静态文件
## Mock数据管理
- 使用统一的Mock数据生成器
- 角色登录状态使用LocalStorage持久化
- 评价数据和文件上传记录存储在前端
- 图表数据实时生成,确保演示效果
## 当前状态
项目目前只有PRD文档需要从零开始搭建Vue3纯前端演示项目所有数据通过Mock生成。

158
Readme.md Normal file
View File

@ -0,0 +1,158 @@
实践教学评价平台
产品需求文档PRD
版本V1.0
撰写日期2024-05-XX
撰写人XXX
1. 文档目的
本 PRD 用于指导开发、测试、UI/UX、运维团队一次性、零偏差地实现《平台设计.docx》中提出的全部功能。任何需求冲突以本 PRD 为准。
2. 项目背景
学校需要一套线上实践教学评价系统,支持企业、教师、行业协会专家、学生四类角色,完成实践成果提交、评价、画像展示、报告生成等闭环流程。
3. 产品范围
• 范围包含:登录、首页双入口、评价流程、学生能力画像、发展监测、专项分析、报告下载。
• 非范围:真实 AI 影像/音频/文本分析算法、真实数据对接、移动端适配(本期仅支持 PC Web
4. 目标用户
角色 | 描述
-----------|------------------------------
学校管理员 | 仅维护账号,不参与业务
企业 | 对学生实践成果进行指标打分、上传佐证材料
教师 | 同上 + 查看学生画像、生成报告
行业协会专家 | 仅输入评价指标与意见
学生 | 提交成果、自评、互评
5. 功能需求
以下章节采用「用户故事 + 验收标准」格式,可直接转为测试用例。
5.1 登录与鉴权
US-1 作为任意角色用户,我可以通过学校统一登录页进入系统,以便进行后续操作。
• 字段:用户名、密码、角色下拉(学生/教师/企业/行业协会专家)。
• 登录后根据角色写 Session / JWT后续路由守卫校验。
• 失败提示:「用户名或密码错误」。
5.2 首页双入口
US-2 登录成功后,我可以看到两个卡片入口:
A. 评价页面(所有角色可见)
B. 学生能力评价画像(仅教师角色可见,其余角色前端隐藏)
5.3 评价流程
5.3.1 筛选页
US-3 点击「评价页面」后,我首先进入筛选页:
• 下拉学期2023-2024-1…、年级2021…、班级计科1班…、项目名称XX公司实训
• 「确认」按钮,校验四项全部已选才可点击。
5.3.2 角色专属评价页
路由:/evaluate
US-4 企业/教师:
• 两个按钮:
①「输入评价指标」——弹框输入文本,保存后按钮变「已填写」。
②「上传学生材料」——多文件上传pdf/doc/jpg/png大小≤10 MB列表展示文件名、大小、删除图标。
US-5 行业协会专家:
• 两个按钮:
①「输入评价指标」——同上。
②「输入评价意见」——同上。
US-6 学生:
• 三个按钮:
①「提交实践成果」——文件上传同上。
②「自评」——弹框打分1-5 星)+ 文字评语。
③「互评」——表格列出同组同学,每人打分+评语。
• 全部完成后显示绿色「提交完成」Toast2 秒后自动返回首页。
5.4 学生能力评价画像(仅教师)
路由:/portrait
静态页面,假数据即可。布局如下:
┌────────────┬────────────┬────────────┐
│ 成绩分布 │ 中上:学生人数 │ 学生名单 │
│ 柱状图 │ 文本+图标 │ 姓名/学号/ │
│ │ │ 年级/超链 │
├────────────┼────────────┼────────────┤
│ 能力矩阵 │ 中下:信息块 │ 多维度检测 │
│ 三角形雷达 │ │ 三个按钮 │
└────────────┴────────────┴────────────┘
• 成绩分布ECharts 柱状图X 轴 5 档成绩Y 轴人数。
• 能力矩阵:三角形雷达,三指标(专业基础、专业综合、数智应用),值随机 60-95。
• 学生名单:表格,最后一列「评价报告」为蓝色按钮,点击跳转到 /report/{studentId}。
• 多维度检测:影像分析 / 音频分析 / 文本分析(仅 UI 按钮,点击弹 Toast「功能暂未开放」
• 右下角再置两个按钮:
①「评价报告」——同名单里的跳转。
②「历史记录查看」——暂留空页面,二期实现。
5.5 评价报告页(/report/{studentId}
布局:左侧垂直导航(平台首页 | 发展监测 | 能力报告分析),右侧动态内容。
5.5.1 平台首页
• 点击直接返回 /portrait。
5.5.2 发展监测
路由:/report/{id}/monitor
• 顶部三筛选:学年区间、班级、项目(与评价流程一致)。
• 上左:学生基本信息卡片(姓名、学期、年级、参加项目)。
• 上右:柱状图,展示企业/行业/教师/学生四类主体评分(假数据)。
• 中间:
左:大数字「总体分数 88」。
右:横向条形图(示例:专业能力 90团队协作 85…
5.5.3 能力报告分析(专项分析)
路由:/report/{id}/analysis
• 三栏文本:
① 优势分析(占位段落)
② 不足分析(占位段落)
③ 改进建议(占位段落)
• 历史评分折线图X 轴学年Y 轴分数,随机 3 个点。
• 底部按钮「生成评价报告」:
- 点击调用后端接口 /api/report/generate返回 PDF URL浏览器下载。
- 若后端未就绪,前端 mock下载一个静态 PDF占位
6. 非功能需求
• 技术栈Vue3 + ElementPlus + ECharts + SpringBoot + MySQL。
• 兼容性Chrome 90+、Edge 90+。
• 性能:页面首屏 < 2s接口 95% < 500 ms
• 安全密码加盐哈希bcrypt上传文件类型白名单XSS/CSRF 防护。
• 可用性:上线率 99.9%,数据每日自动备份。
7. 数据字典(核心表)
表 user
id | username | password_hash | role(enum)
表 project
id | name | semester | grade | class
表 evaluation
id | project_id | student_id | evaluator_role | evaluator_id | score | comment | file_urls(json)
表 portrait_data假数据用
student_id | score_distribution_json | ability_matrix_json
8. 接口清单(关键)
POST /api/login
GET /api/projects?semester=&grade=&class=
POST /api/evaluation
GET /api/portrait/{studentId}
GET /api/report/{studentId}/monitor
GET /api/report/{studentId}/analysis
POST /api/report/generate
9. 里程碑
阶段 | 时间 | 交付物
---------|-----------|------------------
需求冻结 | T0 | 本 PRD + 原型
UI 设计 | T0+3 天 | Figma 高保真
前端完成 | T0+2 周 | 静态页面 + 联调
后端完成 | T0+3 周 | 接口 + 假数据
测试 | T0+4 周 | 测试报告
上线 | T0+5 周 | 生产环境
10. 风险与应对
风险:专家角色并发量低,可能遗漏测试。
应对:在测试用例中强制覆盖专家流程。
11. 附录
• 原型链接Figmahttps://figma.com/xxx
• 设计稿切图命名规范见《UI规范.pdf》
—— 以上,如无异议即进入开发。

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>实践教学评价平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1505
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "zhangqipro",
"version": "1.0.0",
"description": "实践教学评价平台",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vitejs/plugin-vue": "^6.0.1",
"echarts": "^5.5.0",
"element-plus": "^2.8.8",
"pinia": "^2.2.7",
"vite": "^7.1.5",
"vue": "^3.5.21",
"vue-router": "^4.5.1"
}
}

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'App'
}
</script>
<style>
@import './assets/styles/variables.css';
@import './assets/styles/common.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--bg-secondary);
color: var(--text-primary);
}
#app {
min-height: 100vh;
}
</style>

View File

@ -0,0 +1,109 @@
/* 通用样式类 */
.container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: var(--spacing-lg);
border: 1px solid var(--border-light);
}
.card-header {
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-light);
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
}
.grid {
display: grid;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-lg);
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.mb-xl { margin-bottom: var(--spacing-xl); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }
.mt-xl { margin-top: var(--spacing-xl); }
.p-sm { padding: var(--spacing-sm); }
.p-md { padding: var(--spacing-md); }
.p-lg { padding: var(--spacing-lg); }
.p-xl { padding: var(--spacing-xl); }
.full-width {
width: 100%;
}
.page-container {
min-height: 100vh;
background: var(--bg-secondary);
padding: var(--spacing-lg);
}

View File

@ -0,0 +1,64 @@
:root {
/* 主题色彩 */
--primary-color: #409eff;
--primary-light: #79bbff;
--primary-dark: #337ecc;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
--info-color: #909399;
/* 文字颜色 */
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #c0c4cc;
/* 背景色 */
--bg-primary: #ffffff;
--bg-secondary: #f5f7fa;
--bg-tertiary: #ebeef5;
/* 边框颜色 */
--border-light: #dcdfe6;
--border-base: #e4e7ed;
--border-dark: #d3d4d6;
/* 间距 */
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-xxl: 3rem;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* 阴影 */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.15);
/* 字体 */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-xxl: 1.5rem;
/* 页面布局 */
--header-height: 60px;
--sidebar-width: 240px;
--container-max-width: 1200px;
/* 渐变背景 */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}

View File

@ -0,0 +1,83 @@
<template>
<el-button
:type="type"
:size="size"
:loading="loading"
:disabled="disabled"
:icon="icon"
:class="['base-button', `btn-${variant}`]"
@click="handleClick"
>
<slot></slot>
</el-button>
</template>
<script>
export default {
name: 'BaseButton',
props: {
type: {
type: String,
default: 'primary'
},
size: {
type: String,
default: 'default'
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'gradient', 'outline'].includes(value)
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
icon: {
type: String,
default: ''
}
},
emits: ['click'],
methods: {
handleClick(event) {
if (!this.loading && !this.disabled) {
this.$emit('click', event)
}
}
}
}
</script>
<style scoped>
.base-button {
transition: all 0.3s ease;
border-radius: var(--radius-md);
}
.btn-gradient {
background: var(--gradient-primary);
border: none;
color: white;
}
.btn-gradient:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-outline {
background: transparent;
border: 2px solid var(--primary-color);
color: var(--primary-color);
}
.btn-outline:hover {
background: var(--primary-color);
color: white;
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<div class="base-card" :class="[`card-${size}`, { 'card-shadow': shadow }]">
<div v-if="title || $slots.header" class="card-header">
<slot name="header">
<h3 class="card-title">{{ title }}</h3>
</slot>
</div>
<div class="card-content">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'BaseCard',
props: {
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg'].includes(value)
},
shadow: {
type: Boolean,
default: true
}
}
}
</script>
<style scoped>
.base-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
overflow: hidden;
}
.card-shadow {
box-shadow: var(--shadow-md);
}
.card-sm {
padding: var(--spacing-md);
}
.card-md {
padding: var(--spacing-lg);
}
.card-lg {
padding: var(--spacing-xl);
}
.card-header {
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-light);
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.card-footer {
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-light);
margin-top: var(--spacing-lg);
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div class="page-layout">
<div v-if="showHeader" class="page-header">
<slot name="header">
<div class="header-content">
<h2 class="page-title">{{ title }}</h2>
<div v-if="$slots.actions" class="page-actions">
<slot name="actions"></slot>
</div>
</div>
</slot>
</div>
<div class="page-main">
<div class="page-content" :class="[`content-${layout}`]">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PageLayout',
props: {
title: {
type: String,
default: ''
},
showHeader: {
type: Boolean,
default: true
},
layout: {
type: String,
default: 'default',
validator: (value) => ['default', 'centered', 'fluid'].includes(value)
}
}
}
</script>
<style scoped>
.page-layout {
min-height: 100vh;
background: var(--bg-secondary);
}
.page-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
padding: var(--spacing-lg) 0;
box-shadow: var(--shadow-sm);
}
.header-content {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--spacing-lg);
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title {
font-size: var(--font-size-xxl);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.page-main {
padding: var(--spacing-lg);
}
.content-default {
max-width: var(--container-max-width);
margin: 0 auto;
}
.content-centered {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--header-height) - var(--spacing-lg) * 2);
}
.content-fluid {
width: 100%;
}
</style>

18
src/components/index.js Normal file
View File

@ -0,0 +1,18 @@
import BaseCard from './BaseCard.vue'
import BaseButton from './BaseButton.vue'
import PageLayout from './PageLayout.vue'
// 全局注册组件
export default {
install(app) {
app.component('BaseCard', BaseCard)
app.component('BaseButton', BaseButton)
app.component('PageLayout', PageLayout)
}
}
export {
BaseCard,
BaseButton,
PageLayout
}

23
src/main.js Normal file
View File

@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import GlobalComponents from './components'
const app = createApp(App)
const pinia = createPinia()
// 注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(GlobalComponents)
app.mount('#app')

71
src/router/index.js Normal file
View File

@ -0,0 +1,71 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const routes = [
{
path: '/',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/home',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true }
},
{
path: '/evaluate',
name: 'Evaluate',
component: () => import('@/views/Evaluate.vue'),
meta: { requiresAuth: true }
},
{
path: '/portrait',
name: 'Portrait',
component: () => import('@/views/Portrait.vue'),
meta: { requiresAuth: true, role: 'teacher' }
},
{
path: '/report/:studentId',
name: 'Report',
component: () => import('@/views/Report.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
// 检查登录状态
authStore.checkAuth()
// 如果访问登录页且已登录,重定向到首页
if (to.name === 'Login' && authStore.isLoggedIn) {
return next('/home')
}
// 如果路由需要认证
if (to.meta.requiresAuth) {
if (!authStore.isLoggedIn) {
ElMessage.error('请先登录')
return next('/')
}
// 如果路由需要特定角色
if (to.meta.role && authStore.user?.role !== to.meta.role) {
ElMessage.error('您没有权限访问该页面')
return next('/home')
}
}
next()
})
export default router

30
src/stores/auth.js Normal file
View File

@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
isLoggedIn: false
}),
actions: {
login(userInfo) {
this.user = userInfo
this.isLoggedIn = true
localStorage.setItem('user', JSON.stringify(userInfo))
},
logout() {
this.user = null
this.isLoggedIn = false
localStorage.removeItem('user')
},
checkAuth() {
const stored = localStorage.getItem('user')
if (stored) {
this.user = JSON.parse(stored)
this.isLoggedIn = true
}
}
}
})

41
src/utils/mockData.js Normal file
View File

@ -0,0 +1,41 @@
// Mock用户数据
export const mockUsers = [
{ username: 'student1', password: '123456', role: 'student', name: '张三' },
{ username: 'teacher1', password: '123456', role: 'teacher', name: '李老师' },
{ username: 'company1', password: '123456', role: 'company', name: '企业代表' },
{ username: 'expert1', password: '123456', role: 'expert', name: '行业专家' }
]
// Mock项目选择数据
export const mockOptions = {
semesters: ['2023-2024-1', '2023-2024-2', '2024-2025-1'],
grades: ['2021级', '2022级', '2023级'],
classes: ['计科1班', '计科2班', '软工1班', '软工2班'],
projects: ['XX公司实训', 'YY企业项目', 'ZZ技术实习']
}
// Mock学生数据
export const mockStudents = [
{ id: 1, name: '张三', studentId: '202101001', grade: '2021级', class: '计科1班' },
{ id: 2, name: '李四', studentId: '202101002', grade: '2021级', class: '计科1班' },
{ id: 3, name: '王五', studentId: '202101003', grade: '2021级', class: '计科1班' },
{ id: 4, name: '赵六', studentId: '202101004', grade: '2021级', class: '计科1班' }
]
// Mock图表数据生成器
export const generateChartData = () => {
return {
scoreDistribution: [
{ grade: '优秀', count: Math.floor(Math.random() * 10) + 5 },
{ grade: '良好', count: Math.floor(Math.random() * 15) + 10 },
{ grade: '中等', count: Math.floor(Math.random() * 20) + 15 },
{ grade: '及格', count: Math.floor(Math.random() * 10) + 5 },
{ grade: '不及格', count: Math.floor(Math.random() * 5) + 1 }
],
abilityMatrix: {
professional: Math.floor(Math.random() * 35) + 60,
comprehensive: Math.floor(Math.random() * 35) + 60,
digital: Math.floor(Math.random() * 35) + 60
}
}
}

9
src/views/Evaluate.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<div>评价页面 - 开发中</div>
</template>
<script>
export default {
name: 'Evaluate'
}
</script>

158
src/views/Home.vue Normal file
View File

@ -0,0 +1,158 @@
<template>
<PageLayout title="实践教学评价平台">
<div class="home-container">
<div class="welcome-section">
<h2 class="welcome-text">欢迎{{ user?.name }}</h2>
<p class="role-text">当前角色{{ getRoleText(user?.role) }}</p>
</div>
<div class="entry-cards">
<BaseCard class="entry-card" @click="goToEvaluate">
<div class="card-content">
<div class="card-icon">
<EditPen :size="48" />
</div>
<h3 class="card-title">评价页面</h3>
<p class="card-description">进行实践成果评价</p>
</div>
</BaseCard>
<BaseCard
v-if="user?.role === 'teacher'"
class="entry-card"
@click="goToPortrait"
>
<div class="card-content">
<div class="card-icon">
<User :size="48" />
</div>
<h3 class="card-title">学生能力评价画像</h3>
<p class="card-description">查看学生能力分析</p>
</div>
</BaseCard>
</div>
<div class="logout-section">
<BaseButton @click="handleLogout" variant="outline">
退出登录
</BaseButton>
</div>
</div>
</PageLayout>
</template>
<script>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
export default {
name: 'Home',
setup() {
const router = useRouter()
const authStore = useAuthStore()
const user = computed(() => authStore.user)
const getRoleText = (role) => {
const roleMap = {
student: '学生',
teacher: '教师',
company: '企业',
expert: '行业协会专家'
}
return roleMap[role] || role
}
const goToEvaluate = () => {
router.push('/evaluate')
}
const goToPortrait = () => {
router.push('/portrait')
}
const handleLogout = () => {
authStore.logout()
ElMessage.success('已退出登录')
router.push('/')
}
return {
user,
getRoleText,
goToEvaluate,
goToPortrait,
handleLogout
}
}
}
</script>
<style scoped>
.home-container {
max-width: 800px;
margin: 0 auto;
}
.welcome-section {
text-align: center;
margin-bottom: var(--spacing-xxl);
}
.welcome-text {
font-size: var(--font-size-xxl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.role-text {
font-size: var(--font-size-lg);
color: var(--text-secondary);
}
.entry-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-xl);
margin-bottom: var(--spacing-xxl);
}
.entry-card {
cursor: pointer;
transition: all 0.3s ease;
}
.entry-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.card-content {
text-align: center;
padding: var(--spacing-xl);
}
.card-icon {
color: var(--primary-color);
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.card-description {
color: var(--text-secondary);
font-size: var(--font-size-md);
}
.logout-section {
text-align: center;
}
</style>

415
src/views/Login.vue Normal file
View File

@ -0,0 +1,415 @@
<template>
<div class="homepage">
<!-- 顶部导航栏 -->
<header class="header">
<div class="header-content">
<div class="logo-section">
<School class="logo-icon" />
<span class="platform-title">实践教学评价平台</span>
</div>
<div class="header-info">
<span class="version">V1.0</span>
</div>
</div>
</header>
<!-- 主要内容区域 -->
<main class="main-content">
<!-- 左侧功能介绍 -->
<div class="intro-section">
<div class="intro-content">
<h1 class="main-title">智能化实践教学评价系统</h1>
<p class="main-subtitle">支持多角色协同评价的综合性教学管理平台</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<User />
</div>
<h3>多角色支持</h3>
<p>学生教师企业专家四类用户</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<TrendCharts />
</div>
<h3>智能分析</h3>
<p>能力画像与发展监测</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<Document />
</div>
<h3>报告生成</h3>
<p>自动生成评价报告</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<DataAnalysis />
</div>
<h3>数据可视化</h3>
<p>图表展示评价结果</p>
</div>
</div>
</div>
</div>
<!-- 右侧登录区域 -->
<div class="login-section">
<div class="login-card">
<div class="card-header">
<h2>系统登录</h2>
<p>请选择您的身份登录系统</p>
</div>
<el-form
ref="loginForm"
:model="form"
:rules="rules"
class="login-form"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
size="large"
clearable
>
<template #prefix>
<User class="input-icon" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
clearable
show-password
>
<template #prefix>
<Lock class="input-icon" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="role">
<el-select
v-model="form.role"
placeholder="选择角色"
size="large"
class="full-width"
>
<el-option label="学生" value="student" />
<el-option label="教师" value="teacher" />
<el-option label="企业" value="company" />
<el-option label="行业协会专家" value="expert" />
</el-select>
</el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
class="login-btn"
>
登录
</el-button>
</el-form>
<div class="demo-accounts">
<el-divider><span class="demo-title">快速体验</span></el-divider>
<div class="demo-buttons">
<el-button size="small" @click="quickLogin('student')">学生演示</el-button>
<el-button size="small" @click="quickLogin('teacher')">教师演示</el-button>
</div>
</div>
</div>
</div>
</main>
<!-- 底部信息 -->
<footer class="footer">
<div class="footer-content">
<p>&copy; 2024 实践教学评价平台 - 演示版本</p>
</div>
</footer>
</div>
</template>
<script>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { mockUsers } from '@/utils/mockData'
export default {
name: 'Login',
setup() {
const router = useRouter()
const authStore = useAuthStore()
const form = reactive({
username: '',
password: '',
role: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
const loading = ref(false)
const loginForm = ref(null)
const handleLogin = async () => {
await loginForm.value.validate()
loading.value = true
//
const user = mockUsers.find(u =>
u.username === form.username &&
u.password === form.password &&
u.role === form.role
)
if (user) {
authStore.login(user)
ElMessage.success('登录成功')
router.push('/home')
} else {
ElMessage.error('用户名或密码错误')
}
loading.value = false
}
const quickLogin = (role) => {
if (role === 'student') {
form.username = 'student1'
form.password = '123456'
form.role = 'student'
} else if (role === 'teacher') {
form.username = 'teacher1'
form.password = '123456'
form.role = 'teacher'
}
}
return {
form,
rules,
loading,
loginForm,
handleLogin,
quickLogin
}
}
}
</script>
<style scoped>
.homepage {
height: 100vh;
background: var(--gradient-primary);
display: grid;
grid-template-rows: 80px 1fr 60px;
}
.header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
}
.header-content {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.logo-section {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.logo-icon {
font-size: var(--font-size-xl);
color: var(--bg-primary);
}
.platform-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--bg-primary);
}
.version {
color: var(--text-placeholder);
font-size: var(--font-size-sm);
}
.main-content {
display: grid;
grid-template-columns: 1fr 380px;
gap: var(--spacing-xl);
max-width: var(--container-max-width);
margin: 0 auto;
padding: var(--spacing-md) var(--spacing-xl);
align-items: center;
}
.intro-section {
color: var(--bg-primary);
display: flex;
flex-direction: column;
height: 100%;
}
.main-title {
font-size: calc(var(--font-size-xxl) * 1.5);
font-weight: 700;
margin-bottom: var(--spacing-sm);
line-height: 1.3;
}
.main-subtitle {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
opacity: 0.9;
}
.features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
flex: 1;
}
.feature-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
text-align: center;
}
.feature-icon {
font-size: calc(var(--font-size-xxl) * 1.5);
margin-bottom: var(--spacing-md);
color: white;
}
.feature-card h3 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-sm);
font-weight: 600;
}
.feature-card p {
opacity: 0.8;
font-size: var(--font-size-sm);
}
.login-section {
display: flex;
justify-content: center;
align-items: center;
}
.login-card {
background: var(--bg-primary);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
box-shadow: var(--shadow-xl);
width: 100%;
max-width: 380px;
}
.card-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.card-header h2 {
font-size: var(--font-size-xxl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.card-header p {
color: var(--text-secondary);
}
.login-form {
margin-bottom: var(--spacing-lg);
}
.login-form .el-form-item {
margin-bottom: var(--spacing-lg);
}
.input-icon {
color: var(--text-secondary);
}
.login-btn {
width: 100%;
height: 48px;
font-size: var(--font-size-lg);
font-weight: 600;
}
.demo-accounts {
margin-top: var(--spacing-lg);
}
.demo-title {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.demo-buttons {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
margin-top: var(--spacing-md);
}
.footer {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding: var(--spacing-sm) 0;
}
.footer-content {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--spacing-lg);
text-align: center;
}
.footer-content p {
color: rgba(255, 255, 255, 0.8);
font-size: var(--font-size-sm);
margin: 0;
}
</style>

9
src/views/Portrait.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<div>学生画像页面 - 开发中</div>
</template>
<script>
export default {
name: 'Portrait'
}
</script>

9
src/views/Report.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<div>评价报告页面 - 开发中</div>
</template>
<script>
export default {
name: 'Report'
}
</script>

16
vite.config.js Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
}
})

BIN
平台设计.pdf Normal file

Binary file not shown.