原型设计系统

This commit is contained in:
junge
2026-02-02 17:40:51 +08:00
commit b553d9aab7
22 changed files with 5554 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
node_modules

99
DESIGN_PROMPTS.md Normal file
View File

@@ -0,0 +1,99 @@
# 产品设计系统 - 提示词 (Prompt) 约束指南
本指南旨在规范如何使用 AI 辅助生成符合 `Funtime Design System` 标准的代码和内容。本系统基于 **Vue 3 + TypeScript + Element Plus + Pinia** 构建。
## 1. 技术栈约束 (Tech Stack Constraints)
在生成代码时,请始终遵循以下技术栈版本和规范:
- **框架**: Vue 3 (Composition API, `<script setup lang="ts">`)
- **语言**: TypeScript (严格模式)
- **UI 组件库**: Element Plus
- **状态管理**: Pinia
- **构建工具**: Vite
- **CSS 预处理**: CSS / SCSS (scoped)
- **图标库**: `@element-plus/icons-vue`
## 2. 代码生成提示词模板 (Code Generation Prompts)
### 2.1 新增组件 (New Component)
当你需要 AI 生成一个新的 UI 组件时,请使用以下结构:
> **Role**: Senior Vue 3 Engineer
> **Task**: Create a new component named `[ComponentName]`
> **Requirements**:
> 1. Use `<script setup lang="ts">`.
> 2. Use **Element Plus** components for UI.
> 3. Implement props interface definition using TypeScript.
> 4. Use **Pinia** store if global state is needed (refer to `useDesignStore`).
> 5. Ensure responsive design and proper styling (scoped CSS).
> 6. Do NOT use `Options API`.
> **Context**: This component is part of a Product Design System.
### 2.2 模拟数据 (Mock Data)
当需要填充数据时:
> **Task**: Generate mock data for `[FeatureName]`
> **Format**: JSON or TypeScript array
> **Constraint**:
> - Use `vite-plugin-mock` structure if creating an API mock.
> - Data should look realistic for a product design context (e.g., project names, status, user counts).
> - Refer to `mock/index.ts` for existing patterns.
### 2.3 状态管理 (State Management)
当修改 Pinia Store 时:
> **Task**: Update `src/store/design.ts`
> **Action**: Add state/actions for `[Feature]`
> **Constraint**:
> - Use Setup Store syntax (`defineStore('id', () => { ... })`).
> - Keep state reactive using `ref` or `reactive`.
> - Export a composable hook `use[StoreName]`.
## 3. 设计规范 (Design Guidelines)
- **布局**: 使用 `src/layout/index.vue` 作为主布局,包含侧边栏导航和顶部 Header。
- **颜色**:
- Primary: `#409eff` (Element Plus Default)
- Background: `#f5f7fa`
- **字体**: Helvetica Neue, Arial, sans-serif
## 4. 交互规范 (Interaction Rules)
- **反馈**: 所有的增删改操作必须有用户反馈 (使用 `ElMessage.success``ElMessage.error`)。
- **加载状态**: 异步操作期间应展示 Loading 状态。
- **表单验证**: 所有输入表单必须包含基本的非空验证。
## 5. 示例 (Example)
**请求**: "创建一个用户列表页面,包含搜索功能。"
**AI 应回复的代码结构**:
```vue
<template>
<div class="user-list">
<el-card>
<template #header>
<div class="header-actions">
<el-input v-model="searchQuery" placeholder="Search users..." />
<el-button type="primary">Add User</el-button>
</div>
</template>
<el-table :data="filteredUsers">
<!-- Columns -->
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// Imports...
// Logic...
</script>
```

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Funtime Design System</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

20
mock/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { MockMethod } from 'vite-plugin-mock'
export default [
{
url: '/api/stats',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: [
{ label: 'Total Users', value: '1,234' },
{ label: 'Active Projects', value: '56' },
{ label: 'Design Assets', value: '892' },
{ label: 'Pending Reviews', value: '12' }
],
}
},
},
] as MockMethod[]

37
mock/modules.ts Normal file
View File

@@ -0,0 +1,37 @@
import { MockMethod } from 'vite-plugin-mock'
export default [
{
url: '/api/modules',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: [
{
id: 1,
title: 'Personnel Evaluation',
description: 'Evaluate employee performance and manage reviews.',
icon: 'User',
path: '/personnel'
},
{
id: 2,
title: 'Training',
description: 'Manage training programs and track employee progress.',
icon: 'School',
path: '/training'
},
{
id: 3,
title: 'Data Dashboard',
description: 'View key performance indicators and analytics.',
icon: 'DataLine',
path: '/data-dashboard'
}
]
}
}
}
] as MockMethod[]

163
mock/training.ts Normal file
View File

@@ -0,0 +1,163 @@
import { MockMethod } from 'vite-plugin-mock'
const courses = [
{
id: 1,
title: '餐饮职业服务管理实操从入门到精通',
chapters: 18,
learners: 3,
status: 0, // 0: 未学习, 1: 进行中, 2: 已学习
cover: 'https://dummyimage.com/300x400/409eff/ffffff&text=Service+Management',
category: 'personal_growth'
},
{
id: 2,
title: '企业前厅培训服务流程',
chapters: 18,
learners: 3,
status: 1,
cover: 'https://dummyimage.com/300x400/79bbff/ffffff&text=Front+Desk+Training',
category: 'personal_growth'
},
{
id: 3,
title: '一本书搞懂餐厅经营管理',
chapters: 18,
learners: 3,
status: 1,
cover: 'https://dummyimage.com/300x400/337ecc/ffffff&text=Restaurant+Management',
category: 'personal_growth'
},
{
id: 4,
title: '从零开始学习做餐饮管理之经营篇实操要点说明',
chapters: 18,
learners: 3,
status: 2,
cover: 'https://dummyimage.com/300x400/529b2e/ffffff&text=Zero+to+Hero',
category: 'personal_growth'
},
{
id: 5,
title: '餐饮门店营销实战',
chapters: 12,
learners: 15,
status: 0,
cover: 'https://dummyimage.com/300x400/e6a23c/ffffff&text=Marketing',
category: 'skill'
},
{
id: 6,
title: '食品安全与卫生管理',
chapters: 8,
learners: 42,
status: 2,
cover: 'https://dummyimage.com/300x400/f56c6c/ffffff&text=Safety',
category: 'rules'
}
]
// Mock Detail Data
const courseDetails = {
1: {
...courses[0],
updateTime: '2025-10-20 23:24',
totalTime: '4时 28分 38秒',
views: 18390,
completedChapters: 3,
totalChapters: 20, // Override simple list count for detail realism
outline: [
{ id: 101, title: '第1章—基础入门课程UI/UX基础入门...', type: 'video', duration: '3分23秒', status: 2 },
{ id: 102, title: '第2章—转战B端UI设计入门课程', type: 'audio', duration: '3分23秒', status: 2 },
{ id: 103, title: '第3章—多风格插画设计赋能', type: 'doc', duration: '3分23秒', status: 2 },
{ id: 104, title: '第4章—基础入门课程UI/UX基础入门...', type: 'video', duration: '暂未学习', status: 0 },
{ id: 105, title: '第5章—进阶交互设计实战', type: 'video', duration: '15分00秒', status: 0 },
{ id: 106, title: '第6章—设计系统搭建指南', type: 'doc', duration: '10页', status: 0 },
]
}
}
// Mock Chapter Detail Data
const chapterDetails = {
101: {
id: 101,
title: '第1章—基础入门课程UI/UX基础入门...',
videoUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
content: '本章节主要介绍了UI/UX设计的基础概念包括色彩理论、排版原则以及用户体验的核心要素。通过实际案例分析帮助学员建立正确的设计思维。',
comments: [
{ id: 1, user: '范平', role: '设计师', content: '我体会到了不仅要在专业技能上有所提升用稻盛讲到“要用正确的人做我体会到了不仅要在专业技能上有所提升...', time: '1小时前', likes: 99 },
{ id: 2, user: '赵婵涛', role: 'UI设计师', content: '灵感是业余者的专属,我们专业人士只要在早上打卡上班即可。', time: '1小时前', likes: 99 },
{ id: 3, user: '巩香晓', role: '平面设计师', content: '永远不要跟别人比幸运,我从来没想过我比别人幸运,我也许比他们更有毅力,在最困难的时候,他们熬不住了,我可以多熬一秒钟。', time: '1小时前', likes: 99 }
]
},
102: {
id: 102,
title: '第2章—转战B端UI设计入门课程',
videoUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
content: 'B端设计注重效率和逻辑。本章深入探讨B端产品的特点如何构建清晰的信息架构以及复杂表单和数据可视化的设计技巧。',
comments: []
}
}
export default [
{
url: '/api/training/courses',
method: 'get',
response: ({ query }: { query: any }) => {
const { category, type } = query
return {
code: 0,
message: 'ok',
data: {
list: courses,
stats: {
learned: 5,
total: 15
}
}
}
}
},
{
url: '/api/training/course/detail',
method: 'get',
response: ({ query }: { query: any }) => {
const { id } = query
const numericId = Number(id)
const detail = courseDetails[numericId as keyof typeof courseDetails] || {
...courses.find(c => c.id === numericId) || courses[0],
updateTime: '2025-10-20 23:24',
totalTime: '2时 15分 00秒',
views: 1205,
completedChapters: 0,
totalChapters: 18,
outline: [
{ id: 201, title: '第1章—课程介绍', type: 'video', duration: '5分00秒', status: 0 },
{ id: 202, title: '第2章—基础知识', type: 'video', duration: '10分00秒', status: 0 }
]
}
return {
code: 0,
message: 'ok',
data: detail
}
}
},
{
url: '/api/training/chapter/detail',
method: 'get',
response: ({ query }: { query: any }) => {
const { id } = query
const numericId = Number(id)
// Default to chapter 101 if not found for demo purposes
const detail = chapterDetails[numericId as keyof typeof chapterDetails] || chapterDetails[101]
return {
code: 0,
message: 'ok',
data: detail
}
}
}
] as MockMethod[]

3477
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "funtime-design-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/mockjs": "^1.0.10",
"@types/node": "^20.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"mockjs": "^1.1.0",
"sass": "^1.70.0",
"typescript": "^5.2.0",
"vite": "^5.0.0",
"vite-plugin-mock": "^3.0.0",
"vue-tsc": "^1.8.0"
}
}

19
src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
</script>
<template>
<router-view />
</template>
<style>
#app {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
body {
margin: 0;
}
</style>

6
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

52
src/layout/index.vue Normal file
View File

@@ -0,0 +1,52 @@
<template>
<el-container class="layout-container">
<el-aside width="200px">
<el-menu
class="el-menu-vertical-demo"
router
:default-active="$route.path"
>
<el-menu-item index="/dashboard">
<el-icon><Menu /></el-icon>
<span>工作台</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header>
<div class="header-content">
<h2>Funtime Product Design</h2>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
// Icons are registered globally in main.ts, but can also be imported if needed.
// Using global registration for simplicity as done in main.ts
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.el-aside {
background-color: #fff;
border-right: 1px solid #e6e6e6;
}
.el-header {
background-color: #fff;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
padding: 0 20px;
}
.header-content h2 {
margin: 0;
color: #303133;
}
</style>

20
src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
const pinia = createPinia()
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(pinia)
app.use(router)
app.mount('#app')

55
src/router/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Layout from '../layout/index.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/dashboard/index.vue'),
meta: { title: '工作台' }
},
{
path: 'personnel',
name: 'Personnel',
component: () => import('../views/personnel/index.vue'),
meta: { title: 'Personnel Evaluation' }
},
{
path: 'training',
name: 'Training',
component: () => import('../views/training/index.vue'),
meta: { title: 'Training' }
},
{
path: 'training/detail/:id',
name: 'TrainingDetail',
component: () => import('../views/training/detail.vue'),
meta: { title: 'Course Detail' }
},
{
path: 'training/study/:courseId/:chapterId',
name: 'TrainingStudy',
component: () => import('../views/training/study.vue'),
meta: { title: 'Chapter Learning' }
},
{
path: 'data-dashboard',
name: 'DataDashboard',
component: () => import('../views/data-dashboard/index.vue'),
meta: { title: '数据工作台' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

8
src/style.css Normal file
View File

@@ -0,0 +1,8 @@
:root {
--el-color-primary: #409eff;
}
body {
margin: 0;
padding: 0;
background-color: #f5f7fa;
}

View File

@@ -0,0 +1,120 @@
<template>
<div class="dashboard-container">
<h1 class="welcome-title">Welcome to Funtime Design System</h1>
<p class="subtitle">Select a module to get started</p>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8" :lg="8" v-for="module in modules" :key="module.id">
<el-card class="module-card" shadow="hover" @click="navigateTo(module.path)">
<div class="module-icon">
<component :is="module.icon" v-if="module.icon" />
</div>
<h3 class="module-title">{{ module.title }}</h3>
<p class="module-desc">{{ module.description }}</p>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
interface ModuleItem {
id: number
title: string
description: string
icon: string
path: string
}
const router = useRouter()
const modules = ref<ModuleItem[]>([])
const navigateTo = (path: string) => {
router.push(path)
}
onMounted(async () => {
try {
const res = await axios.get('/api/modules')
if (res.data.code === 0) {
modules.value = res.data.data
}
} catch (error) {
console.error('Failed to fetch modules:', error)
}
})
</script>
<style scoped>
.dashboard-container {
padding: 40px 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.welcome-title {
text-align: center;
margin-bottom: 12px;
font-size: 32px;
font-weight: 600;
color: #303133;
}
.subtitle {
text-align: center;
margin-bottom: 60px;
font-size: 16px;
color: #909399;
}
.module-card {
cursor: pointer;
transition: all 0.3s ease;
height: 100%;
border: none;
border-radius: 12px;
margin-bottom: 20px;
}
.module-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #ecf5ff;
display: flex;
justify-content: center;
align-items: center;
font-size: 36px;
color: #409eff;
margin-bottom: 24px;
transition: all 0.3s;
}
.module-card:hover .icon-wrapper {
background-color: #409eff;
color: #ffffff;
transform: scale(1.1);
}
.module-title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.module-desc {
margin: 0;
font-size: 14px;
color: #606266;
line-height: 1.6;
text-align: center;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div>
<h1>数据工作台</h1>
<el-row :gutter="20">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>{{ item.label }}</span>
</div>
</template>
<div class="card-value">{{ item.value }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card header="Recent Activity">
<el-timeline>
<el-timeline-item timestamp="2024/04/12" placement="top">
<el-card>
<h4>Update Design System</h4>
<p>Tom committed 2024/04/12 20:46</p>
</el-card>
</el-timeline-item>
<el-timeline-item timestamp="2024/04/03" placement="top">
<el-card>
<h4>New Component Added</h4>
<p>Tom committed 2024/04/03 20:46</p>
</el-card>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
<el-col :span="12">
<el-card header="Quick Actions">
<el-button type="primary">Create New Project</el-button>
<el-button>Import Assets</el-button>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
interface StatItem {
label: string
value: string
}
const stats = ref<StatItem[]>([])
onMounted(async () => {
try {
const res = await axios.get('/api/stats')
if (res.data.code === 0) {
stats.value = res.data.data
}
} catch (error) {
console.error('Failed to fetch stats:', error)
}
})
</script>
<style scoped>
.card-value {
font-size: 24px;
font-weight: bold;
color: #409eff;
}
.card-header {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<div class="page-container">
<h1>Personnel Evaluation</h1>
<p>Welcome to the Personnel Evaluation module.</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.page-container {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<div class="course-detail-container" v-loading="loading">
<div class="back-nav">
<el-page-header @back="goBack" title="课程详情" />
</div>
<!-- Top Section: Course Info -->
<div class="course-header-card">
<div class="header-content">
<div class="course-cover-wrapper">
<img :src="course.cover" class="course-cover" v-if="course.cover" />
</div>
<div class="course-info-wrapper">
<h1 class="course-title">{{ course.title }}</h1>
<p class="update-time">更新时间{{ course.updateTime }}</p>
<div class="stats-row">
<div class="stat-item">
<div class="stat-value">{{ course.totalTime }}</div>
<div class="stat-label">累计学习</div>
</div>
<el-divider direction="vertical" class="stat-divider" />
<div class="stat-item">
<div class="stat-value">{{ course.views }}</div>
<div class="stat-label">浏览次数</div>
</div>
<el-divider direction="vertical" class="stat-divider" />
<div class="stat-item">
<div class="stat-value highlight">
{{ course.completedChapters }}<span class="total">/{{ course.totalChapters }}</span>
</div>
<div class="stat-label">已学/总章节</div>
</div>
</div>
<div class="action-row">
<el-button type="primary" size="large" class="start-btn" @click="startLearning">
{{ course.completedChapters > 0 ? '继续学习' : '开始学习' }}
</el-button>
</div>
</div>
</div>
</div>
<!-- Main Content: Tabs -->
<div class="course-content-card">
<el-tabs v-model="activeTab" class="course-tabs">
<el-tab-pane label="大纲" name="outline">
<div class="outline-list">
<div
v-for="chapter in course.outline"
:key="chapter.id"
class="chapter-item"
:class="{ 'is-completed': chapter.status === 2 }"
@click="goToStudy(chapter.id)"
>
<div class="chapter-icon-wrapper">
<el-icon class="chapter-icon" v-if="chapter.type === 'video'"><VideoPlay /></el-icon>
<el-icon class="chapter-icon" v-else-if="chapter.type === 'audio'"><Headset /></el-icon>
<el-icon class="chapter-icon" v-else><Document /></el-icon>
</div>
<div class="chapter-info">
<div class="chapter-title">{{ chapter.title }}</div>
<div class="chapter-meta">
<span v-if="chapter.status === 0">暂未学习</span>
<span v-else>学习时长: {{ chapter.duration }}</span>
</div>
</div>
<div class="chapter-status">
<el-tag v-if="chapter.status === 2" type="success" effect="plain" round>已学完</el-tag>
<el-tag v-else-if="chapter.status === 1" type="warning" effect="plain" round>进行中</el-tag>
<el-button v-else link type="primary">开始</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="评论" name="reviews">
<el-empty description="暂无评论" />
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
import { VideoPlay, Document, Headset } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const activeTab = ref('outline')
const course = ref<any>({
title: '',
cover: '',
updateTime: '',
totalTime: '',
views: 0,
completedChapters: 0,
totalChapters: 0,
outline: []
})
const goBack = () => {
router.back()
}
const goToStudy = (chapterId: number) => {
router.push(`/training/study/${route.params.id}/${chapterId}`)
}
const startLearning = () => {
if (course.value.outline && course.value.outline.length > 0) {
// Find first uncompleted or just first chapter
const nextChapter = course.value.outline.find((c: any) => c.status !== 2) || course.value.outline[0]
goToStudy(nextChapter.id)
}
}
const fetchDetail = async () => {
loading.value = true
try {
const res = await axios.get('/api/training/course/detail', {
params: { id: route.params.id }
})
if (res.data.code === 0) {
course.value = res.data.data
}
} catch (error) {
console.error('Failed to fetch course detail:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.course-detail-container {
padding: 20px 40px;
max-width: 1200px;
margin: 0 auto;
}
.back-nav {
margin-bottom: 20px;
}
/* Header Card */
.course-header-card {
background: #fff;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 24px;
.header-content {
display: flex;
gap: 40px;
.course-cover-wrapper {
flex-shrink: 0;
width: 240px;
height: 320px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
.course-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.course-info-wrapper {
flex: 1;
display: flex;
flex-direction: column;
.course-title {
font-size: 28px;
color: #303133;
margin: 0 0 12px;
line-height: 1.4;
}
.update-time {
font-size: 14px;
color: #909399;
margin-bottom: 40px;
}
.stats-row {
display: flex;
align-items: center;
margin-bottom: 40px;
.stat-divider {
height: 40px;
margin: 0 40px;
border-color: #ebeef5;
}
.stat-item {
text-align: center;
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
&.highlight {
color: #409eff;
.total {
color: #303133;
font-size: 20px;
}
}
}
.stat-label {
font-size: 14px;
color: #909399;
}
}
}
.action-row {
margin-top: auto;
.start-btn {
width: 180px;
font-weight: bold;
}
}
}
}
}
/* Content Card */
.course-content-card {
background: #fff;
border-radius: 12px;
padding: 20px 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
min-height: 400px;
}
.course-tabs {
:deep(.el-tabs__item) {
font-size: 18px;
height: 50px;
line-height: 50px;
}
}
.outline-list {
margin-top: 10px;
.chapter-item {
display: flex;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #f0f2f5;
transition: background-color 0.3s;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f9fafe;
padding-left: 10px;
padding-right: 10px;
margin: 0 -10px;
border-radius: 4px;
}
.chapter-icon-wrapper {
width: 48px;
height: 48px;
border-radius: 8px;
background-color: #ecf5ff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
.chapter-icon {
font-size: 24px;
color: #409eff;
}
}
.chapter-info {
flex: 1;
.chapter-title {
font-size: 16px;
color: #303133;
margin-bottom: 8px;
font-weight: 500;
}
.chapter-meta {
font-size: 13px;
color: #909399;
}
}
.chapter-status {
margin-left: 20px;
}
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.course-header-card .header-content {
flex-direction: column;
align-items: center;
text-align: center;
.course-cover-wrapper {
width: 180px;
height: 240px;
margin-bottom: 20px;
}
.course-info-wrapper {
width: 100%;
.stats-row {
justify-content: center;
.stat-divider { margin: 0 15px; }
}
.action-row {
display: flex;
justify-content: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,464 @@
<template>
<div class="training-container">
<!-- Left Sidebar: Categories -->
<div class="training-sidebar">
<h2 class="sidebar-title">课程学习</h2>
<el-menu
default-active="all"
class="category-menu"
@select="handleCategorySelect"
>
<el-menu-item index="all">
<span>全部</span>
</el-menu-item>
<el-menu-item index="personal_growth">
<span>个人成长</span>
</el-menu-item>
<el-menu-item index="rules">
<span>规章制度</span>
</el-menu-item>
<el-menu-item index="skills">
<span>技能项</span>
</el-menu-item>
<el-menu-item index="team">
<span>团队提升与管理</span>
</el-menu-item>
</el-menu>
</div>
<!-- Right Main Content -->
<div class="training-main">
<!-- Top Bar: Search and Tabs -->
<div class="top-bar">
<div class="search-wrapper">
<el-input
v-model="searchQuery"
placeholder="请输入课程名称"
:prefix-icon="Search"
clearable
class="course-search"
/>
</div>
<div class="tabs-wrapper">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="通用课程" name="general" />
<el-tab-pane label="本岗课程" name="position" />
<el-tab-pane label="它岗课程" name="other" />
</el-tabs>
</div>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stats-left">
<el-icon class="bell-icon"><Bell /></el-icon>
<span class="stats-text">
已学课程 <span class="highlight">{{ stats.learned }}</span> | 全部 {{ stats.total }}
</span>
</div>
</div>
<!-- Course List (Grid) -->
<div class="course-list" v-loading="loading">
<el-row :gutter="20">
<el-col
v-for="course in filteredCourses"
:key="course.id"
:xs="24"
:sm="12"
:md="8"
:lg="6"
:xl="6"
>
<el-card class="course-card" :body-style="{ padding: '0px' }" shadow="hover" @click="goToDetail(course.id)">
<div class="course-cover">
<img :src="course.cover" class="image" />
<div class="chapter-badge">{{ course.chapters }}章节</div>
<div class="status-tag" :class="getStatusClass(course.status)">
{{ getStatusText(course.status) }}
</div>
</div>
<div class="course-info">
<h3 class="course-title" :title="course.title">{{ course.title }}</h3>
<div class="course-meta">
<span class="learners">学习总人数 {{ course.learners }}</span>
<el-button type="primary" link v-if="course.status === 1">继续学习</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- Bottom Action Bar (Desktop optimized as floating or fixed bottom) -->
<div class="continue-learning-bar" v-if="lastActiveCourse">
<div class="cl-info">
<img :src="lastActiveCourse.cover" class="cl-cover" />
<div class="cl-text">
<div class="cl-title">{{ lastActiveCourse.title }}</div>
<div class="cl-progress">已学 70%</div>
</div>
</div>
<el-button type="primary" round>继续学习</el-button>
<el-icon class="close-bar"><Close /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { Search, Bell, Close } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
// Interfaces
interface Course {
id: number
title: string
chapters: number
learners: number
status: number // 0: Not Started, 1: In Progress, 2: Completed
cover: string
category: string
}
interface Stats {
learned: number
total: number
}
// State
const router = useRouter()
const loading = ref(false)
const searchQuery = ref('')
const activeTab = ref('general')
const activeCategory = ref('all')
const courses = ref<Course[]>([])
const stats = ref<Stats>({ learned: 0, total: 0 })
// Derived State
const filteredCourses = computed(() => {
return courses.value.filter(course => {
// Filter by search
const matchSearch = course.title.toLowerCase().includes(searchQuery.value.toLowerCase())
// Filter by category (mock logic)
const matchCategory = activeCategory.value === 'all' || course.category === activeCategory.value
// Filter by tab (mock logic - assuming tabs might filter by type, but here we just show all for demo unless we add type to mock)
// For now, we assume all courses belong to 'general' or match current tab logic if we had that data
return matchSearch && matchCategory
})
})
const lastActiveCourse = computed(() => {
return courses.value.find(c => c.status === 1) || courses.value[0]
})
// Methods
const fetchData = async () => {
loading.value = true
try {
const res = await axios.get('/api/training/courses', {
params: {
category: activeCategory.value,
type: activeTab.value
}
})
if (res.data.code === 0) {
courses.value = res.data.data.list
stats.value = res.data.data.stats
}
} catch (error) {
console.error('Failed to fetch courses:', error)
} finally {
loading.value = false
}
}
const handleCategorySelect = (index: string) => {
activeCategory.value = index
// fetchData() // Uncomment if backend supports filtering
}
const handleTabClick = () => {
// fetchData() // Uncomment if backend supports filtering
}
const getStatusText = (status: number) => {
switch (status) {
case 0: return '未学习'
case 1: return '进行中'
case 2: return '已学习'
default: return ''
}
}
const getStatusClass = (status: number) => {
switch (status) {
case 0: return 'status-not-started'
case 1: return 'status-in-progress'
case 2: return 'status-completed'
default: return ''
}
}
const goToDetail = (id: number) => {
router.push(`/training/detail/${id}`)
}
// Lifecycle
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.training-container {
display: flex;
height: calc(100vh - 80px); /* Adjust based on global layout header height */
background-color: #f5f7fa;
overflow: hidden;
}
/* Sidebar */
.training-sidebar {
width: 240px;
background-color: #fff;
border-right: 1px solid #e6e6e6;
display: flex;
flex-direction: column;
padding: 20px 0;
.sidebar-title {
padding: 0 20px;
margin-bottom: 20px;
font-size: 20px;
font-weight: bold;
color: #303133;
}
.category-menu {
border-right: none;
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
font-size: 15px;
&.is-active {
background-color: #ecf5ff;
border-right: 3px solid #409eff;
color: #409eff;
font-weight: 500;
}
}
}
}
/* Main Content */
.training-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px 30px;
overflow-y: auto;
position: relative;
}
.top-bar {
margin-bottom: 20px;
.search-wrapper {
max-width: 400px;
margin-bottom: 15px;
}
.tabs-wrapper {
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
background-color: #e4e7ed;
}
:deep(.el-tabs__item) {
font-size: 16px;
font-weight: 500;
}
}
}
.stats-bar {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
.stats-left {
display: flex;
align-items: center;
color: #606266;
.bell-icon {
font-size: 20px;
color: #409eff;
margin-right: 10px;
}
.stats-text {
font-size: 14px;
.highlight {
color: #303133;
font-weight: bold;
font-size: 16px;
}
}
}
}
.course-list {
padding-bottom: 80px; /* Space for bottom bar */
}
.course-card {
border: none;
margin-bottom: 20px;
transition: transform 0.3s;
cursor: pointer;
&:hover {
transform: translateY(-5px);
}
.course-cover {
position: relative;
height: 160px;
overflow: hidden;
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.chapter-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.status-tag {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.status-not-started {
background-color: #fdf6ec;
color: #e6a23c;
}
&.status-in-progress {
background-color: #ecf5ff;
color: #409eff;
}
&.status-completed {
background-color: #f0f9eb;
color: #67c23a;
}
}
}
.course-info {
padding: 14px;
.course-title {
margin: 0 0 10px;
font-size: 16px;
color: #303133;
line-height: 1.4;
height: 44px; /* 2 lines */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.course-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #909399;
}
}
}
.continue-learning-bar {
position: fixed;
bottom: 20px;
right: 20px;
width: 360px;
background-color: rgba(50, 50, 50, 0.9);
backdrop-filter: blur(10px);
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 100;
.cl-info {
display: flex;
align-items: center;
flex: 1;
margin-right: 15px;
.cl-cover {
width: 40px;
height: 50px;
object-fit: cover;
border-radius: 4px;
margin-right: 10px;
}
.cl-text {
flex: 1;
overflow: hidden;
.cl-title {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.cl-progress {
font-size: 12px;
color: #ccc;
}
}
}
.close-bar {
margin-left: 10px;
cursor: pointer;
font-size: 18px;
color: #909399;
&:hover { color: #fff; }
}
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="study-container" v-loading="loading">
<!-- Breadcrumb -->
<div class="breadcrumb-nav">
<el-page-header @back="goBack" title="返回">
<template #breadcrumb>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/training' }">培训课程</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/training/detail/${route.params.courseId}` }">{{ course.title || '课程详情' }}</el-breadcrumb-item>
<el-breadcrumb-item>章节学习</el-breadcrumb-item>
</el-breadcrumb>
</template>
<template #content>
<span class="page-header-content"> 章节学习 </span>
</template>
</el-page-header>
</div>
<div class="study-content">
<el-row :gutter="24">
<!-- Left: Main Learning Area -->
<el-col :xs="24" :sm="24" :md="16" :lg="17" :xl="18">
<div class="video-section">
<div class="video-player-wrapper">
<video
v-if="currentChapter.videoUrl"
:src="currentChapter.videoUrl"
controls
class="video-player"
poster="https://dummyimage.com/800x450/000/fff&text=Video+Player"
></video>
<div v-else class="video-placeholder">
<el-empty description="暂无视频资源" />
</div>
</div>
<div class="chapter-header">
<h1 class="chapter-title">{{ currentChapter.title }}</h1>
<div class="chapter-actions">
<el-button type="primary" link icon="Share">分享</el-button>
<el-button type="primary" link icon="Star">收藏</el-button>
</div>
</div>
</div>
<div class="content-tabs-wrapper">
<el-tabs v-model="activeTab" class="content-tabs">
<el-tab-pane label="章节介绍" name="intro">
<div class="chapter-intro">
<p>{{ currentChapter.content || '暂无介绍' }}</p>
</div>
</el-tab-pane>
<el-tab-pane label="评论" name="reviews">
<div class="reviews-list">
<div v-if="currentChapter.comments && currentChapter.comments.length > 0">
<div v-for="comment in currentChapter.comments" :key="comment.id" class="review-item">
<div class="review-avatar">
<el-avatar :size="40" :src="comment.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
</div>
<div class="review-content">
<div class="review-header">
<span class="user-name">{{ comment.user }}</span>
<span class="user-role">{{ comment.role }}</span>
</div>
<div class="review-text">{{ comment.content }}</div>
<div class="review-footer">
<span class="review-time">{{ comment.time }}</span>
<div class="review-actions">
<el-button link size="small" icon="Star">{{ comment.likes || 0 }}</el-button>
<el-button link size="small" icon="ChatDotRound">{{ comment.replies || 0 }}</el-button>
</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无评论,快来抢沙发吧!" />
<!-- Comment Input -->
<div class="comment-input-area">
<el-input
v-model="newComment"
type="textarea"
:rows="3"
placeholder="友善评论,文明发言"
resize="none"
/>
<div class="comment-submit">
<el-button type="primary" size="small" @click="submitComment">发表评论</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-col>
<!-- Right: Chapter Navigation -->
<el-col :xs="24" :sm="24" :md="8" :lg="7" :xl="6">
<div class="chapter-sidebar">
<div class="sidebar-header">
<h3>课程目录</h3>
<span class="progress-text">已完成 {{ course.completedChapters }}/{{ course.totalChapters }}</span>
</div>
<div class="sidebar-content">
<div
v-for="chapter in course.outline"
:key="chapter.id"
class="sidebar-chapter-item"
:class="{ 'is-active': chapter.id === Number(route.params.chapterId), 'is-completed': chapter.status === 2 }"
@click="switchChapter(chapter.id)"
>
<div class="sidebar-chapter-icon">
<el-icon v-if="chapter.status === 2"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="chapter.id === Number(route.params.chapterId)"><VideoPlay /></el-icon>
<el-icon v-else><CircleCheck /></el-icon>
</div>
<div class="sidebar-chapter-info">
<div class="sidebar-chapter-title">{{ chapter.title }}</div>
<div class="sidebar-chapter-duration">{{ chapter.duration }}</div>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { VideoPlay, CircleCheck, CircleCheckFilled, Share, Star, ChatDotRound } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const activeTab = ref('reviews') // Default to reviews as per screenshot implication or user pref
const newComment = ref('')
const course = ref<any>({
title: '',
outline: [],
completedChapters: 0,
totalChapters: 0
})
const currentChapter = ref<any>({
title: '',
videoUrl: '',
content: '',
comments: []
})
const fetchCourseDetail = async () => {
try {
const res = await axios.get('/api/training/course/detail', {
params: { id: route.params.courseId }
})
if (res.data.code === 0) {
course.value = res.data.data
}
} catch (error) {
console.error('Failed to fetch course detail:', error)
}
}
const fetchChapterDetail = async () => {
loading.value = true
try {
const res = await axios.get('/api/training/chapter/detail', {
params: { id: route.params.chapterId }
})
if (res.data.code === 0) {
currentChapter.value = res.data.data
}
} catch (error) {
console.error('Failed to fetch chapter detail:', error)
} finally {
loading.value = false
}
}
const switchChapter = (chapterId: number) => {
if (chapterId === Number(route.params.chapterId)) return
router.push(`/training/study/${route.params.courseId}/${chapterId}`)
}
const goBack = () => {
router.push(`/training/detail/${route.params.courseId}`)
}
const submitComment = () => {
if (!newComment.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
// Mock submission
currentChapter.value.comments.unshift({
id: Date.now(),
user: '我',
role: '学员',
content: newComment.value,
time: '刚刚',
likes: 0
})
newComment.value = ''
ElMessage.success('评论发表成功')
}
watch(() => route.params.chapterId, () => {
fetchChapterDetail()
})
onMounted(() => {
fetchCourseDetail()
fetchChapterDetail()
})
</script>
<style scoped lang="scss">
.study-container {
padding: 20px 40px;
max-width: 1400px; /* Wider for study view */
margin: 0 auto;
}
.breadcrumb-nav {
margin-bottom: 20px;
}
.page-header-content {
font-weight: 600;
font-size: 16px;
color: #303133;
}
/* Video Section */
.video-section {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
.video-player-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
}
.chapter-header {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #f0f2f5;
.chapter-title {
font-size: 20px;
color: #303133;
margin: 0;
line-height: 1.4;
flex: 1;
}
.chapter-actions {
flex-shrink: 0;
margin-left: 20px;
}
}
}
/* Content Tabs */
.content-tabs-wrapper {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
min-height: 300px;
:deep(.el-tabs__item) {
font-size: 16px;
}
}
.chapter-intro {
padding: 10px 0;
line-height: 1.6;
color: #606266;
}
/* Reviews */
.reviews-list {
padding-top: 10px;
.review-item {
display: flex;
padding: 20px 0;
border-bottom: 1px solid #f0f2f5;
&:last-child {
border-bottom: none;
}
.review-avatar {
margin-right: 16px;
flex-shrink: 0;
}
.review-content {
flex: 1;
.review-header {
margin-bottom: 8px;
.user-name {
font-weight: bold;
color: #303133;
margin-right: 8px;
}
.user-role {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
}
}
.review-text {
color: #606266;
line-height: 1.5;
margin-bottom: 12px;
}
.review-footer {
display: flex;
justify-content: space-between;
align-items: center;
.review-time {
font-size: 12px;
color: #909399;
}
.review-actions {
display: flex;
gap: 16px;
}
}
}
}
.comment-input-area {
margin-top: 20px;
background: #f9fafe;
padding: 20px;
border-radius: 8px;
.comment-submit {
margin-top: 10px;
text-align: right;
}
}
}
/* Sidebar */
.chapter-sidebar {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
position: sticky;
top: 20px;
.sidebar-header {
padding: 15px 20px;
background: #fafafa;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
.progress-text {
font-size: 12px;
color: #909399;
}
}
.sidebar-content {
max-height: calc(100vh - 200px);
overflow-y: auto;
.sidebar-chapter-item {
padding: 15px 20px;
cursor: pointer;
display: flex;
align-items: flex-start;
transition: all 0.3s;
border-left: 3px solid transparent;
&:hover {
background-color: #f5f7fa;
}
&.is-active {
background-color: #ecf5ff;
border-left-color: #409eff;
.sidebar-chapter-title {
color: #409eff;
font-weight: 500;
}
.sidebar-chapter-icon {
color: #409eff;
}
}
.sidebar-chapter-icon {
margin-right: 12px;
color: #c0c4cc;
font-size: 18px;
margin-top: 2px;
&.is-completed {
color: #67c23a;
}
}
.sidebar-chapter-info {
flex: 1;
.sidebar-chapter-title {
font-size: 14px;
color: #606266;
margin-bottom: 4px;
line-height: 1.4;
}
.sidebar-chapter-duration {
font-size: 12px;
color: #909399;
}
}
}
}
}
@media (max-width: 992px) {
.study-container {
padding: 20px;
}
.chapter-sidebar {
margin-top: 20px;
position: static;
}
}
</style>

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "element-plus/global"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
viteMockServe({
mockPath: 'mock',
enable: true,
}),
],
})