添加文件

This commit is contained in:
junge
2026-02-03 14:20:17 +08:00
parent d8668ac1d3
commit 57e7d9b385
12 changed files with 1708 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# 学习任务详情页 (Study Task Detail) 产品需求文档
## 1. 文档概述
本文档描述了“学习任务详情页”的功能需求、界面布局及交互逻辑。该页面旨在为用户提供一个高效、沉浸式的任务查看与评估环境,支持视频回放、标准示例对照、实时评估打分等核心功能。
## 2. 页面布局与视觉风格
### 2.1 整体布局
- **结构**采用左右分栏设计的桌面端布局Web Desktop Layout
- **左侧栏 (Media Column)**:占据约 58% 宽度,专注于多媒体内容展示。
- **右侧栏 (Info Column)**:占据约 42% 宽度,承载任务信息、标准对照及评估操作。
- **底部栏 (Bottom Bar)**:固定悬浮于页面底部,展示核心评分数据。
### 2.2 视觉风格 (High-end Design)
- **色调**:背景采用柔和浅灰 (`#f2f3f5`),营造沉浸感;卡片采用纯白背景。
- **质感**
- 卡片无边框,使用细腻的悬浮阴影 (`box-shadow`) 增强层次感。
- 底部栏采用**毛玻璃效果** (`backdrop-filter: blur`),提升现代感。
- **圆角**:统一使用 `12px` 大圆角,视觉更加柔和。
- **排版**
- 标题使用加粗字体,配合蓝色竖条装饰 (`#409eff`) 进行视觉引导。
- 数字展示采用 DIN 等宽字体,强调数据专业性。
## 3. 功能模块详解
### 3.1 顶部导航 (Page Header)
- **返回按钮**点击左侧“Back”按钮返回上一级列表页。
- **操作区**
- **保存**:主操作按钮,保存当前评估结果。
- **更多/查看**:辅助功能入口(图标按钮)。
### 3.2 媒体播放区 (左侧栏)
- **视频播放器**
- 16:9 宽屏展示。
- 支持视频播放/暂停、进度控制。
- 无信号时显示占位符及提示文案。
- **控制条 (Controls Bar)**
- **回放/直播切换**:下拉选择器,支持切换“回放”与“直播”模式。
- **监控点位选择**:下拉选择器,支持切换不同监控视角(如“监控点位一”、“监控点位二”)。
### 3.3 任务信息与评估区 (右侧栏)
#### A. 任务基础信息
- **标题**:大字号展示任务名称,辅以圆形 Tag 标签显示任务类型/状态。
- **描述**
- 展示任务详细说明。
- **交互**:支持长文本折叠/展开功能(当前设计为默认展开,预留收起按钮)。
#### B. 标准示例 (Standard Examples)
- **展示形式**:网格布局展示标准作业图片。
- **交互**
- 鼠标悬停图片时有放大动效 (`scale 1.05`)。
- 点击图片支持大图预览Lightbox 模式)。
- **数据展示**:标题旁显示图片总数量。
#### C. 评估操作 (Evaluation)
- **评估项内容**:显示具体的评估标准文本。
- **评估状态选择**
- 三态选择器:**不合格 (Failed)**、**不适用 (N/A)**、**合格 (Passed)**。
- **样式**:宽大卡片式按钮,选中后高亮显示对应颜色(红/灰/绿),提供明确的视觉反馈。
#### D. 建议反馈 (Suggestion)
- **输入框**:多行文本域,支持输入评估建议。
- **限制**:最大支持 1000 字,右下角显示字数统计。
#### E. 上次扣分情况 (Last Deduction) - *条件渲染*
- **触发条件**:仅当存在历史扣分记录时显示。
- **样式**:淡红色背景 (`#fff0f0`) 警示卡片。
- **内容**
- 历史扣分分值(如 `-2分`)。
- 违规现场照片缩略图。
- 历史整改建议。
### 3.4 底部状态栏 (Bottom Bar)
- **定位**Fixed 定位底部z-index 层级最高。
- **核心指标**
1. **分值**:该任务的总分值(蓝色高亮)。
2. **扣分规则**:简要显示的文字规则。
3. **当前扣分**:实时计算的扣分数值(红色高亮)。
## 4. 数据接口需求 (Mock Schema)
### 4.1 详情接口
- **Endpoint**: `/api/study-tasks/detail`
- **Method**: `GET`
- **Params**: `id` (Task ID)
- **Response Structure**:
```typescript
interface TaskDetail {
id: number;
title: string; // 任务标题
tag: string; // 标签(如"日常任务"
longDescription: string;// 详细描述
videoUrl: string; // 视频地址
standardImages: string[]; // 标准示例图片URL数组
evaluationContent: string;// 评估标准文本
score: number; // 总分
rule: string; // 扣分规则文本
deduction: number; // 当前扣分
lastDeduction?: { // 上次扣分记录(可选)
score: number;
photos: string[];
suggestion: string;
};
}
```
## 5. 后续规划
- 视频播放器对接真实的流媒体服务HLS/FLV
- 评估状态与扣分逻辑的自动联动(如选择“不合格”自动计算扣分)。

View File

@@ -29,6 +29,13 @@ export default [
description: 'View key performance indicators and analytics.', description: 'View key performance indicators and analytics.',
icon: 'DataLine', icon: 'DataLine',
path: '/data-dashboard' path: '/data-dashboard'
},
{
id: 4,
title: 'Study Task',
description: 'Manage study tasks and track completion status.',
icon: 'List',
path: '/study-task'
} }
] ]
} }

86
mock/store-inspection.ts Normal file
View File

@@ -0,0 +1,86 @@
import { MockMethod } from 'vite-plugin-mock'
const inspections = [
{
id: 1,
storeName: '百年神厨 (格林店)',
templateName: '2023 Q4 标准巡检模板',
inspectors: ['陈经理', '张欣欣', '李雷', '韩梅梅'],
date: '2023-10-20 13:00 至 2023-10-20 15:00',
totalScore: 400.0,
scoreRate: 50.5,
rating: '非常的优秀',
score: 200.0,
totalDeduction: 199.0,
naScore: 1,
totalItems: 999,
status: 'completed',
modules: [
{ name: '01 产品', rating: '非常的优秀', score: 20, deduction: 10, rate: 33.33 },
{ name: '02 服务', rating: '优秀', score: 10, deduction: 100, rate: 50.00 },
{ name: '03 安全', rating: 'B', score: 5, deduction: 10, rate: 33.33 },
{ name: '04 生产生产', rating: 'C', score: 1, deduction: 5, rate: 83.41 },
{ name: '05 内容显示', rating: 'D', score: 0, deduction: 10, rate: 100.00 },
],
redLine: {
deduction: 12,
total: 20
},
recheck: {
lastIssues: 3,
recurringIssues: 4,
rate: 30
},
na: {
count: 1,
totalCount: 20,
score: 5,
totalScore: 100
},
additional: {
suddenIssues: 18,
highlights: 24
}
},
{
id: 2,
storeName: '百年神厨 (万达店)',
templateName: '2023 Q4 标准巡检模板',
inspectors: ['王督导'],
date: '2023-10-21 09:00 至 2023-10-21 11:00',
totalScore: 95.0,
scoreRate: 95.0,
rating: '优秀',
status: 'pending'
}
]
export default [
{
url: '/api/inspections',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: inspections
}
}
},
{
url: '/api/inspections/detail',
method: 'get',
response: ({ query }: { query: any }) => {
const id = Number(query.id)
const item = inspections.find(i => i.id === id)
if (!item) {
return { code: 1, message: 'Inspection not found' }
}
return {
code: 0,
message: 'ok',
data: item
}
}
}
] as MockMethod[]

135
mock/study-task.ts Normal file
View File

@@ -0,0 +1,135 @@
import { MockMethod } from 'vite-plugin-mock'
let tasks = [
{
id: 1,
title: 'Learn Vue 3',
description: 'Study Composition API and script setup',
dueDate: '2023-12-31',
status: 'pending'
},
{
id: 2,
title: 'Master TypeScript',
description: 'Understand generics and utility types',
dueDate: '2023-11-30',
status: 'completed'
},
{
id: 3,
title: 'Element Plus Basics',
description: 'Learn grid system and basic components',
dueDate: '2024-01-15',
status: 'pending'
}
]
export default [
{
url: '/api/study-tasks',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: tasks
}
}
},
{
url: '/api/study-tasks/detail',
method: 'get',
response: ({ query }: { query: any }) => {
const id = Number(query.id)
const task = tasks.find(t => t.id === id)
if (!task) {
return {
code: 1,
message: 'Task not found'
}
}
// Enrich with detail data
return {
code: 0,
message: 'ok',
data: {
...task,
// Extra fields for detail view
videoUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
tag: '服务岗位',
longDescription: '争吵打架争吵打架争吵打架争吵打架争吵打架争吵打架争吵打架争吵打架争吵打架\n\n员工和员工之间发生吵架、打架与顾客发生争吵、打架。员工和员工之间发生吵架、打架与顾客发生争吵。',
evaluationContent: '争吵打架争吵打架争吵打架争吵打架争吵打架',
standardImages: [
'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg',
'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg'
],
livePhotos: [
'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg',
'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg'
],
lastDeduction: {
photos: [
'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg'
],
suggestion: '工作期间应该注重仪容仪表工作期间应该注重仪容仪表',
score: -3
},
score: 999,
rule: '一次性扣除',
deduction: -999
}
}
}
},
{
url: '/api/study-tasks',
method: 'post',
response: ({ body }: { body: any }) => {
const newTask = {
id: tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 1,
...body
}
tasks.push(newTask)
return {
code: 0,
message: 'ok',
data: newTask
}
}
},
{
url: '/api/study-tasks',
method: 'put',
response: ({ body }: { body: any }) => {
const index = tasks.findIndex(t => t.id === body.id)
if (index !== -1) {
tasks[index] = { ...tasks[index], ...body }
return {
code: 0,
message: 'ok',
data: tasks[index]
}
}
return {
code: 1,
message: 'Task not found'
}
}
},
{
url: '/api/study-tasks',
method: 'delete',
response: ({ query }: { query: any }) => {
const id = Number(query.id)
tasks = tasks.filter(t => t.id !== id)
return {
code: 0,
message: 'ok'
}
}
}
] as MockMethod[]

26
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0", "axios": "^1.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.5.0", "element-plus": "^2.5.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",
@@ -1828,6 +1829,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@@ -2776,6 +2787,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -3472,6 +3489,15 @@
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0", "axios": "^1.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.5.0", "element-plus": "^2.5.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",

View File

@@ -10,6 +10,14 @@
<el-icon><Menu /></el-icon> <el-icon><Menu /></el-icon>
<span>工作台</span> <span>工作台</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/study-task">
<el-icon><List /></el-icon>
<span>Study Task</span>
</el-menu-item>
<el-menu-item index="/store-inspection">
<el-icon><Shop /></el-icon>
<span>巡店管理</span>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>

View File

@@ -42,6 +42,30 @@ const routes: Array<RouteRecordRaw> = [
name: 'DataDashboard', name: 'DataDashboard',
component: () => import('../views/data-dashboard/index.vue'), component: () => import('../views/data-dashboard/index.vue'),
meta: { title: '数据工作台' } meta: { title: '数据工作台' }
},
{
path: 'study-task',
name: 'StudyTask',
component: () => import('../views/study-task/index.vue'),
meta: { title: 'Study Task' }
},
{
path: 'study-task/detail/:id',
name: 'StudyTaskDetail',
component: () => import('../views/study-task/detail.vue'),
meta: { title: 'Task Detail' }
},
{
path: 'store-inspection',
name: 'StoreInspection',
component: () => import('../views/store-inspection/index.vue'),
meta: { title: '巡店管理' }
},
{
path: 'store-inspection/detail/:id',
name: 'StoreInspectionDetail',
component: () => import('../views/store-inspection/detail.vue'),
meta: { title: '巡店报告' }
} }
] ]
} }

View File

@@ -0,0 +1,556 @@
<template>
<div class="inspection-detail-container" v-loading="loading">
<!-- Header -->
<div class="page-header">
<div class="left">
<el-button link @click="$router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="title">{{ data.storeName || '门店详情' }}</span>
</div>
<div class="right">
<el-button circle><el-icon><More /></el-icon></el-button>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="报表" name="report" />
<el-tab-pane label="扣分项" name="deduction" />
<el-tab-pane label="红线扣分" name="redline" />
<el-tab-pane label="复查项" name="recheck" />
<el-tab-pane label="不适用" name="na" />
<el-tab-pane label="附件" name="attachment" />
</el-tabs>
<div class="content-wrapper" v-if="activeTab === 'report'">
<!-- Info & Score Card -->
<el-card class="score-overview-card">
<div class="info-section">
<h3>{{ data.templateName }}</h3>
<p>巡店人{{ data.inspectors?.join('、') }} {{ data.inspectors?.length }}</p>
<p>时间{{ data.date }}</p>
</div>
<div class="score-section">
<div class="score-main">
<div class="score-item big">
<div class="value">{{ data.totalScore }}</div>
<div class="label">总分</div>
</div>
<div class="score-item">
<div class="value">{{ data.scoreRate }}%</div>
<div class="label">得分率</div>
</div>
<div class="score-badge">
<span class="badge-text">{{ data.rating }}</span>
</div>
</div>
<div class="score-sub">
<div class="sub-item">
<span class="val">{{ data.score }} / {{ data.totalDeduction }}</span>
<span class="lbl">得分/扣分</span>
</div>
<div class="sub-item">
<span class="val">{{ data.naScore }}%</span>
<span class="lbl">不适用分</span>
</div>
</div>
<div class="total-items">本次扣分评估项总数 {{ data.totalItems }}</div>
</div>
</el-card>
<el-row :gutter="20" class="main-content">
<!-- Left Column: Scoring Details -->
<el-col :span="14">
<el-card class="section-card">
<template #header>
<div class="card-header">
<span class="blue-mark"></span>
<span>评分细则</span>
</div>
</template>
<!-- Chart Area -->
<div class="chart-container" ref="gaugeChartRef"></div>
<!-- Table Area -->
<el-table :data="data.modules" style="width: 100%" :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column prop="name" label="板块名称" />
<el-table-column prop="rating" label="评级" align="center">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold' }">{{ row.rating }}</span>
</template>
</el-table-column>
<el-table-column prop="deduction" label="扣分" align="center" />
<el-table-column prop="score" label="得分" align="center" />
<el-table-column prop="rate" label="得分率" align="center">
<template #default="{ row }">
{{ row.rate }}%
</template>
</el-table-column>
</el-table>
<div class="view-all">
<el-button link type="primary">查看全部</el-button>
</div>
</el-card>
</el-col>
<!-- Right Column: Other Metrics -->
<el-col :span="10">
<!-- Red Line Items -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span class="blue-mark"></span>
<span>红线项</span>
</div>
</template>
<div class="metric-box">
<div class="metric-label">
红线项扣分数 <span class="highlight-red">{{ data.redLine?.deduction }}</span> / {{ data.redLine?.total }}
</div>
<el-progress
:percentage="(data.redLine?.deduction / data.redLine?.total) * 100"
:show-text="false"
color="#f56c6c"
:stroke-width="10"
/>
<div class="metric-footer">
<el-button link type="primary">查看详情</el-button>
</div>
</div>
</el-card>
<!-- Problem Recheck -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span class="blue-mark"></span>
<span>问题复查</span>
</div>
</template>
<div class="recheck-container">
<div class="recheck-stats">
<div class="stat-item">上次问题数 <span class="blue-text">{{ data.recheck?.lastIssues }}</span></div>
<div class="stat-item">问题复现数 <span class="blue-text">{{ data.recheck?.recurringIssues }}</span></div>
</div>
<div class="recheck-chart" ref="recheckChartRef"></div>
</div>
<div class="metric-footer">
<el-button link type="primary">查看详情</el-button>
</div>
</el-card>
<!-- N/A Items -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span class="blue-mark"></span>
<span>不适用</span>
</div>
</template>
<div class="na-container">
<div class="na-box">
<div class="na-val"><span class="blue-text">{{ data.na?.count }}</span>/{{ data.na?.totalCount }}</div>
<div class="na-lbl">不适用数/总数</div>
</div>
<div class="na-box">
<div class="na-val"><span class="blue-text">{{ data.na?.score }}</span>/{{ data.na?.totalScore }}</div>
<div class="na-lbl">不适用分/总分</div>
</div>
</div>
<div class="metric-footer">
<el-button link type="primary">查看详情</el-button>
</div>
</el-card>
<!-- Additional Items -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span class="blue-mark"></span>
<span>附加项</span>
</div>
</template>
<div class="additional-bar">
<div class="add-label">
<span>突发问题数</span>
<span>亮点数</span>
</div>
<div class="bar-visual">
<div class="bar-segment red" :style="{ flex: data.additional?.suddenIssues }">{{ data.additional?.suddenIssues }}</div>
<div class="bar-segment yellow" :style="{ flex: data.additional?.highlights }">{{ data.additional?.highlights }}</div>
</div>
</div>
<div class="metric-footer">
<el-button link type="primary">查看详情</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- Bottom Bar -->
<div class="bottom-action-bar">
<el-button class="action-btn">
<el-icon><Camera /></el-icon> 现场拍摄 {{ data.totalItems }}
</el-button>
<el-button type="primary" class="confirm-btn">报告确认</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
const route = useRoute()
const loading = ref(false)
const activeTab = ref('report')
const data = ref<any>({})
const gaugeChartRef = ref<HTMLElement | null>(null)
const recheckChartRef = ref<HTMLElement | null>(null)
const fetchData = async () => {
loading.value = true
try {
const res = await axios.get('/api/inspections/detail', {
params: { id: route.params.id }
})
if (res.data.code === 0) {
data.value = res.data.data
nextTick(() => {
initCharts()
})
}
} catch (error) {
ElMessage.error('Failed to load detail')
} finally {
loading.value = false
}
}
const initCharts = () => {
if (gaugeChartRef.value) {
const chart = echarts.init(gaugeChartRef.value)
chart.setOption({
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
splitNumber: 5,
itemStyle: {
color: '#58D9F9',
shadowColor: 'rgba(0,138,255,0.45)',
shadowBlur: 10,
shadowOffsetX: 2,
shadowOffsetY: 2
},
progress: {
show: true,
roundCap: true,
width: 18
},
pointer: { show: false },
axisLine: {
roundCap: true,
lineStyle: {
width: 18
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: { show: false },
detail: { show: false },
data: [{ value: 60 }]
}
]
})
}
if (recheckChartRef.value) {
const chart = echarts.init(recheckChartRef.value)
chart.setOption({
series: [
{
type: 'pie',
radius: ['60%', '80%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: '{d}%\n复检率',
fontSize: 14,
fontWeight: 'bold'
},
labelLine: { show: false },
data: [
{ value: data.value.recheck?.rate || 30, name: 'Recheck', itemStyle: { color: '#409eff' } },
{ value: 100 - (data.value.recheck?.rate || 30), name: 'Other', itemStyle: { color: '#f0f2f5' } }
]
}
]
})
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.inspection-detail-container {
padding: 20px;
background: #f5f7fa;
min-height: 100vh;
padding-bottom: 80px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 10px 20px;
border-radius: 8px;
margin-bottom: 10px;
}
.page-header .left {
display: flex;
align-items: center;
gap: 10px;
}
.page-header .title {
font-size: 18px;
font-weight: bold;
}
.detail-tabs {
background: #fff;
padding: 0 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.score-overview-card {
background: linear-gradient(135deg, #e6f7ff 0%, #ffffff 100%);
border-radius: 12px;
margin-bottom: 20px;
}
.info-section {
color: #606266;
font-size: 13px;
margin-bottom: 20px;
border-bottom: 1px dashed #dcdfe6;
padding-bottom: 10px;
}
.info-section h3 {
color: #303133;
font-size: 16px;
margin-bottom: 8px;
}
.score-section {
position: relative;
}
.score-main {
display: flex;
align-items: center;
gap: 40px;
margin-bottom: 20px;
}
.score-item .value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.score-item.big .value {
font-size: 36px;
}
.score-item .label {
color: #909399;
font-size: 12px;
}
.score-badge {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 100,25 100,75 50,100 0,75 0,25" fill="%23409eff" opacity="0.1"/></svg>') no-repeat center;
background-size: contain;
width: 120px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #409eff;
font-weight: bold;
font-size: 16px;
border: 1px solid #a0cfff;
border-radius: 4px;
transform: rotate(-5deg);
}
.score-sub {
display: flex;
gap: 40px;
margin-bottom: 10px;
}
.sub-item {
display: flex;
flex-direction: column;
}
.sub-item .val {
font-weight: 600;
font-size: 16px;
}
.sub-item .lbl {
font-size: 12px;
color: #909399;
}
.total-items {
color: #909399;
font-size: 12px;
}
.section-card {
margin-bottom: 20px;
border-radius: 8px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.blue-mark {
width: 4px;
height: 16px;
background: #409eff;
border-radius: 2px;
}
.chart-container {
height: 200px;
width: 100%;
}
.view-all {
text-align: center;
margin-top: 10px;
}
.metric-box {
padding: 10px 0;
}
.metric-label {
margin-bottom: 10px;
font-size: 14px;
}
.highlight-red {
color: #f56c6c;
font-weight: bold;
font-size: 18px;
}
.metric-footer {
text-align: center;
margin-top: 15px;
}
.recheck-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-item {
background: #f5f7fa;
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
font-size: 13px;
width: 140px;
display: flex;
justify-content: space-between;
}
.blue-text {
color: #409eff;
font-weight: bold;
}
.recheck-chart {
width: 120px;
height: 120px;
}
.na-container {
display: flex;
justify-content: space-around;
padding: 20px 0;
}
.na-box {
text-align: center;
}
.na-val {
font-size: 20px;
font-weight: bold;
margin-bottom: 5px;
}
.na-lbl {
color: #909399;
font-size: 12px;
}
.additional-bar {
padding: 10px 0;
}
.add-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 5px;
}
.bar-visual {
display: flex;
height: 20px;
border-radius: 10px;
overflow: hidden;
}
.bar-segment {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
}
.bar-segment.red {
background: #f56c6c;
}
.bar-segment.yellow {
background: #e6a23c;
}
.bottom-action-bar {
position: fixed;
bottom: 0;
left: 200px; /* Sidebar width */
right: 0;
height: 60px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
z-index: 99;
}
.action-btn {
flex: 1;
margin-right: 10px;
}
.confirm-btn {
flex: 2;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="inspection-list-container">
<div class="page-header">
<h2>巡店管理</h2>
<el-button type="primary">新建巡店</el-button>
</div>
<el-card>
<el-table :data="tableData" v-loading="loading" style="width: 100%">
<el-table-column prop="storeName" label="门店名称" width="200" />
<el-table-column prop="templateName" label="模板名称" min-width="200" />
<el-table-column label="巡店人员" width="200">
<template #default="{ row }">
{{ row.inspectors?.join(', ') }}
</template>
</el-table-column>
<el-table-column prop="date" label="巡店时间" width="300" />
<el-table-column prop="scoreRate" label="得分率" width="100">
<template #default="{ row }">
{{ row.scoreRate }}%
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleDetail(row)">查看报告</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const tableData = ref([])
const fetchList = async () => {
loading.value = true
try {
const res = await axios.get('/api/inspections')
if (res.data.code === 0) {
tableData.value = res.data.data
}
} catch (error) {
ElMessage.error('Failed to load inspections')
} finally {
loading.value = false
}
}
const handleDetail = (row: any) => {
router.push(`/store-inspection/detail/${row.id}`)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.inspection-list-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,494 @@
<template>
<div class="task-detail-container" v-loading="loading">
<!-- Header -->
<div class="page-header">
<div class="left">
<el-button link @click="$router.back()">
<el-icon><ArrowLeft /></el-icon>
Back
</el-button>
<span class="title">详情</span>
</div>
<div class="right">
<el-button type="primary" size="small">保存</el-button>
<el-button link>
<el-icon><More /></el-icon>
</el-button>
<el-button link>
<el-icon><View /></el-icon>
</el-button>
</div>
</div>
<el-row :gutter="20" class="content-row">
<!-- Left Column: Media -->
<el-col :span="14">
<el-card class="media-card" :body-style="{ padding: '0px' }">
<div class="video-wrapper">
<video
v-if="taskData.videoUrl"
:src="taskData.videoUrl"
controls
class="video-player"
></video>
<div v-else class="video-placeholder">
<el-icon size="48"><VideoPlay /></el-icon>
<span>No Video Available</span>
</div>
</div>
<div class="controls-bar">
<el-select v-model="playbackType" placeholder="回放" style="width: 120px">
<el-option label="回放" value="playback" />
<el-option label="直播" value="live" />
</el-select>
<el-select v-model="monitorPoint" placeholder="监控点位一" style="width: 150px; margin-left: 10px;">
<el-option label="监控点位一" value="point1" />
<el-option label="监控点位二" value="point2" />
</el-select>
</div>
</el-card>
</el-col>
<!-- Right Column: Info & Evaluation -->
<el-col :span="10">
<el-card class="info-card" :body-style="{ padding: '32px' }">
<div class="task-info">
<h2 class="task-title">
{{ taskData.title }}
<el-tag size="small" type="primary" effect="light" round>{{ taskData.tag }}</el-tag>
</h2>
<div class="task-desc">
{{ taskData.longDescription }}
<el-button link type="primary" size="small" style="margin-left: 8px;">收起 <el-icon><ArrowUp /></el-icon></el-button>
</div>
</div>
<div class="standard-examples-section">
<h3 class="sub-title">
标准示例
<span class="count" style="font-size: 12px; color: #86909c; font-weight: normal; margin-left: 8px;">{{ taskData.standardImages?.length || 0 }}</span>
</h3>
<div class="image-grid">
<el-image
v-for="(url, index) in taskData.standardImages"
:key="index"
:src="url"
:preview-src-list="taskData.standardImages"
fit="cover"
class="grid-image"
/>
</div>
</div>
<div class="evaluation-section">
<h3 class="sub-title">评估项内容</h3>
<p class="eval-content">{{ taskData.evaluationContent }}</p>
<div class="eval-actions">
<el-radio-group v-model="evaluationStatus" size="large">
<el-radio-button label="failed">
<el-icon><CircleClose /></el-icon> 不合格
</el-radio-button>
<el-radio-button label="na">
<el-icon><Remove /></el-icon> 不适用
</el-radio-button>
<el-radio-button label="passed">
<el-icon><CircleCheck /></el-icon> 合格
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="suggestion-section">
<h3 class="sub-title">建议</h3>
<el-input
v-model="suggestion"
type="textarea"
:rows="4"
placeholder="请输入建议..."
show-word-limit
maxlength="1000"
resize="none"
/>
</div>
<div class="last-deduction-section" v-if="taskData.lastDeduction">
<h3 class="sub-title" style="color: #f56c6c;">上次扣分情况</h3>
<div class="deduction-card">
<div class="deduction-header">
<span>现场拍摄</span>
<span class="deduction-score">-{{ taskData.lastDeduction.score }}</span>
</div>
<div class="image-grid mini">
<el-image
v-for="(url, index) in taskData.lastDeduction.photos"
:key="index"
:src="url"
fit="cover"
class="grid-image"
/>
</div>
<div class="deduction-suggestion">
建议{{ taskData.lastDeduction.suggestion }}
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="bottom-bar">
<div class="score-item">
<span class="label">分值</span>
<span class="value blue">{{ taskData.score }} <span style="font-size: 12px; font-weight: normal;"></span></span>
</div>
<div class="score-item">
<span class="label">扣分规则</span>
<span class="value" style="font-size: 16px; font-weight: 600;">{{ taskData.rule }}</span>
</div>
<div class="score-item">
<span class="label">当前扣分</span>
<span class="value red">{{ taskData.deduction }} <span style="font-size: 12px; font-weight: normal;"></span></span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const route = useRoute()
const loading = ref(false)
const taskData = ref<any>({})
const playbackType = ref('playback')
const monitorPoint = ref('point1')
const evaluationStatus = ref('failed')
const suggestion = ref('工作期间应该注重仪容仪表工作期间应该注重仪容仪表,工作期间应该注重仪容仪表工作期间应该注重仪容仪表工作期间应该注重仪容仪表工作期间应该注重仪容仪表工作期间应该注重仪容仪表')
const fetchTaskDetail = async () => {
const id = route.params.id
loading.value = true
try {
const res = await axios.get('/api/study-tasks/detail', { params: { id } })
if (res.data.code === 0) {
taskData.value = res.data.data
} else {
ElMessage.error(res.data.message)
}
} catch (error) {
ElMessage.error('Failed to load task detail')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchTaskDetail()
})
</script>
<style scoped>
.task-detail-container {
padding: 24px;
background-color: #f2f3f5;
min-height: calc(100vh - 60px);
padding-bottom: 90px;
}
/* Page Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: transparent;
padding: 0;
border-radius: 0;
box-shadow: none;
}
.page-header .left {
display: flex;
align-items: center;
gap: 12px;
}
.page-header .title {
font-size: 20px;
font-weight: 700;
color: #1d2129;
}
/* Card Common Styles */
:deep(.el-card) {
border: none;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
:deep(.el-card:hover) {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
/* Media Card */
.media-card {
overflow: hidden;
background: #000;
}
.video-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
display: flex;
justify-content: center;
align-items: center;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-placeholder {
color: rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.controls-bar {
padding: 16px 24px;
background: #fff;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
}
/* Info Card */
.info-card {
height: 100%;
display: flex;
flex-direction: column;
}
.task-info {
margin-bottom: 24px;
}
.task-title {
font-size: 24px;
line-height: 1.4;
margin: 0 0 12px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
color: #1d2129;
}
.task-desc {
font-size: 14px;
color: #4e5969;
line-height: 1.8;
white-space: pre-wrap;
background: #f7f8fa;
padding: 16px;
border-radius: 8px;
}
.sub-title {
font-size: 16px;
color: #1d2129;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
margin: 24px 0 16px 0;
position: relative;
padding-left: 12px;
}
.sub-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background: #409eff;
border-radius: 2px;
}
/* Standard Examples */
.standard-examples-section .image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
}
.grid-image {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
border: 1px solid #e5e6eb;
}
.grid-image:hover {
transform: scale(1.05);
}
/* Evaluation */
.eval-content {
font-size: 15px;
color: #1d2129;
margin-bottom: 20px;
line-height: 1.6;
}
.eval-actions {
display: flex;
justify-content: flex-start;
margin-bottom: 24px;
}
:deep(.el-radio-group) {
display: flex;
gap: 12px;
width: 100%;
}
:deep(.el-radio-button) {
flex: 1;
}
:deep(.el-radio-button__inner) {
width: 100%;
border: 1px solid #e5e6eb;
border-radius: 8px !important;
border-left: 1px solid #e5e6eb !important;
box-shadow: none !important;
padding: 12px 0;
height: auto;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
font-weight: 600;
}
/* Suggestion */
.suggestion-section :deep(.el-textarea__inner) {
border-radius: 8px;
padding: 12px;
background-color: #f7f8fa;
border-color: transparent;
transition: all 0.3s;
}
.suggestion-section :deep(.el-textarea__inner:focus) {
background-color: #fff;
border-color: #409eff;
}
/* Last Deduction */
.deduction-card {
background: #fff0f0;
padding: 16px;
border-radius: 8px;
border: 1px solid #ffdede;
}
.deduction-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-weight: 600;
color: #f56c6c;
}
.image-grid.mini {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.image-grid.mini .grid-image {
width: 60px;
height: 60px;
border-radius: 4px;
}
.deduction-suggestion {
font-size: 13px;
color: #f56c6c;
background: rgba(255, 255, 255, 0.6);
padding: 8px;
border-radius: 4px;
}
/* Bottom Bar */
.bottom-bar {
position: fixed;
bottom: 0;
left: 200px; /* Width of sidebar */
right: 0;
height: 72px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
z-index: 100;
transition: all 0.3s;
}
.score-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.score-item .label {
font-size: 12px;
color: #86909c;
}
.score-item .value {
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 24px;
line-height: 1;
}
.score-item .value.blue {
color: #409eff;
}
.score-item .value.red {
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="study-task-container">
<div class="header">
<h2>Study Task Management</h2>
<el-button type="primary" @click="handleAdd">Add Task</el-button>
</div>
<el-table :data="taskList" style="width: 100%" v-loading="loading">
<el-table-column label="Title" width="180">
<template #default="{ row }">
<el-link type="primary" @click="handleDetail(row)">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column prop="description" label="Description" />
<el-table-column prop="dueDate" label="Due Date" width="180" />
<el-table-column prop="status" label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Actions" width="250">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="handleDetail(row)">Detail</el-button>
<el-button size="small" @click="handleEdit(row)">Edit</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? 'Edit Task' : 'Add Task'" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="Title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="Description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="Due Date">
<el-date-picker
v-model="form.dueDate"
type="date"
placeholder="Pick a day"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="Status">
<el-select v-model="form.status" placeholder="Select status">
<el-option label="Pending" value="pending" />
<el-option label="Completed" value="completed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleSave">Confirm</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
interface Task {
id: number
title: string
description: string
dueDate: string
status: 'pending' | 'completed'
}
const router = useRouter()
const loading = ref(false)
const taskList = ref<Task[]>([])
const fetchTasks = async () => {
loading.value = true
try {
const res = await axios.get('/api/study-tasks')
if (res.data.code === 0) {
taskList.value = res.data.data
}
} catch (error) {
ElMessage.error('Failed to fetch tasks')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchTasks()
})
const dialogVisible = ref(false)
const isEdit = ref(false)
const form = reactive<Omit<Task, 'id'>>({
title: '',
description: '',
dueDate: '',
status: 'pending'
})
const currentId = ref<number | null>(null)
const handleAdd = () => {
isEdit.value = false
form.title = ''
form.description = ''
form.dueDate = ''
form.status = 'pending'
dialogVisible.value = true
}
const handleDetail = (row: Task) => {
router.push(`/study-task/detail/${row.id}`)
}
const handleEdit = (row: Task) => {
isEdit.value = true
currentId.value = row.id
form.title = row.title
form.description = row.description
form.dueDate = row.dueDate
form.status = row.status
dialogVisible.value = true
}
const handleDelete = (row: Task) => {
ElMessageBox.confirm('Are you sure to delete this task?', 'Warning', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(async () => {
try {
await axios.delete('/api/study-tasks', { params: { id: row.id } })
ElMessage.success('Delete completed')
fetchTasks()
} catch (error) {
ElMessage.error('Delete failed')
}
}).catch(() => {
ElMessage.info('Delete canceled')
})
}
const handleSave = async () => {
try {
if (isEdit.value && currentId.value !== null) {
const res = await axios.put('/api/study-tasks', { ...form, id: currentId.value })
if (res.data.code === 0) {
ElMessage.success('Update completed')
}
} else {
const res = await axios.post('/api/study-tasks', form)
if (res.data.code === 0) {
ElMessage.success('Add completed')
}
}
dialogVisible.value = false
fetchTasks()
} catch (error) {
ElMessage.error('Operation failed')
}
}
</script>
<style scoped>
.study-task-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
</style>