初始化代码

This commit is contained in:
duhao 2026-09-18 16:50:27 +08:00
commit 5f047b085c
44 changed files with 12225 additions and 0 deletions

23
.dockerignore Normal file
View File

@ -0,0 +1,23 @@
# 构建上下文排除项,加速构建并避免冗余文件进入镜像
node_modules/
dist/
.vite/
# IDE & 系统
.idea/
.vscode/
.DS_Store
# 日志
*.log
npm-debug.log*
# Git
.git/
.gitignore
# 文档(不需要进入镜像)
*.md
# 环境配置(开发用,构建时不需要进入镜像)
.env.development

4
.env.development Normal file
View File

@ -0,0 +1,4 @@
# 开发环境配置
# 文件访问基础 URL:后端 SysFile 存储的相对路径(/static/...)需拼接此前缀才能直接访问
# 与 LAOP-admin application-dev.yml 的 laop.file-base-url 保持一致
VITE_FILE_BASE_URL=http://localhost:8081/laop

6
.env.production Normal file
View File

@ -0,0 +1,6 @@
# 生产环境配置
# --------------------------------------------------------------------
# 文件访问基础 URL:留空走相对路径 /static/xxx,由 nginx 反向代理到后端
# /laop/static/xxx。这样不依赖后端 18081 端口对外暴露。
# 如果希望浏览器直连后端(不经 nginx),改为 http://<server-ip>:18081/laop
VITE_FILE_BASE_URL=

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
*.local
# Editor / IDE
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Env
.env
.env.local
.env.*.local

60
Dockerfile Normal file
View File

@ -0,0 +1,60 @@
# =====================================================================
# 翼云Hub · LAOP-web 前端镜像 Dockerfile
# 多阶段构建:阶段1 用 Node 构建 Vite 产物;阶段2 用 nginx 提供静态服务
# --------------------------------------------------------------------
# 说明:
# - 前端是 Vue3 + Vite SPA,构建产物为 dist/ 静态文件
# - 运行时只需 nginx 提供静态服务 + 反向代理后端 API
# - 与后端 laop-admin 容器通过 docker 网络互通,nginx 用 service name 解析
# - 挂载宿主机 /home/laop/web 时,首次启动会自动从镜像内置备份初始化
# 静态目录,避免空挂载导致 nginx 403
# =====================================================================
# -------------------------------------------------------------------
# 阶段1:构建期(Node + Vite)
# -------------------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
# 先 COPY 依赖描述文件,利用 Docker 缓存层加速安装
COPY package.json package-lock.json ./
# 安装依赖(使用 npm ci 保证可重复构建)
RUN npm ci
# 复制源码
COPY . .
# 构建生产环境产物(Vite 会自动读取 .env.production)
RUN npm run build
# -------------------------------------------------------------------
# 阶段2:运行期(nginx 静态服务)
# -------------------------------------------------------------------
FROM nginx:stable-alpine
LABEL maintainer="laop-web"
LABEL description="翼云Hub · 无人机信息开放平台 前端 Web 服务"
# 时区设置
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 清空 nginx 默认配置,使用自定义配置(包含 SPA 路由 + API 反向代理)
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 拷贝构建产物到备份目录(运行时由 entrypoint 脚本复制到 nginx 静态目录)
# 不直接放到 /usr/share/nginx/html 是为了支持宿主机目录挂载:
# 挂载点会覆盖 /usr/share/nginx/html,但 /app/dist-backup 不受影响
COPY --from=builder /app/dist /app/dist-backup
# 注册初始化脚本到 nginx entrypoint 自动执行目录(/docker-entrypoint.d/*.sh)
# nginx 官方镜像 entrypoint 会按文件名顺序执行该目录下所有 .sh 脚本
COPY docker-entrypoint.d/30-init-static.sh /docker-entrypoint.d/30-init-static.sh
RUN chmod +x /docker-entrypoint.d/30-init-static.sh
# 容器内暴露 80 端口(由 docker-compose 映射到宿主机 18080)
EXPOSE 80

View File

@ -0,0 +1,29 @@
#!/bin/sh
# =====================================================================
# nginx 静态目录初始化脚本(由 nginx 官方 entrypoint 自动调用)
# --------------------------------------------------------------------
# 作用:当宿主机挂载 /home/laop/web 到 /usr/share/nginx/html 时,
# 首次挂载目录为空,nginx 启动会 403。此脚本检测空目录后,
# 自动从镜像内置备份 /app/dist-backup 复制一份,实现首次部署零配置。
# 后续更新前端:直接替换 /home/laop/web 内文件,无需重建镜像。
# =====================================================================
set -e
NGINX_HTML_DIR=/usr/share/nginx/html
BACKUP_DIR=/app/dist-backup
# 备份目录不存在,说明镜像构建异常,直接退出交由 nginx 处理
if [ ! -d "$BACKUP_DIR" ]; then
echo "[init-static] backup dir $BACKUP_DIR not found, skip init."
exit 0
fi
# 检查 nginx 静态目录是否为空(不存在 / 无任何文件)
if [ ! -d "$NGINX_HTML_DIR" ] || [ -z "$(ls -A "$NGINX_HTML_DIR" 2>/dev/null)" ]; then
echo "[init-static] $NGINX_HTML_DIR is empty, initializing from $BACKUP_DIR ..."
mkdir -p "$NGINX_HTML_DIR"
cp -r "$BACKUP_DIR"/. "$NGINX_HTML_DIR"/
echo "[init-static] done."
else
echo "[init-static] $NGINX_HTML_DIR already has files, skip init."
fi

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>翼云Hub · 无人机信息开放平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

109
nginx.conf Normal file
View File

@ -0,0 +1,109 @@
# =====================================================================
# 翼云Hub · LAOP-web 前端 nginx 配置
# --------------------------------------------------------------------
# 职责:
# 1) SPA 静态资源服务 + History 路由 fallback
# 2) 反向代理前端 axios 请求(baseURL=/api)到后端容器 laop-admin:8081
# 3) 反向代理后端静态文件(/static)到后端 laop-admin:8081/laop/static
# --------------------------------------------------------------------
# 后端 context-path = /laop;controller 路径分两类:
# - 带 /api 前缀:/api/file, /api/mini/*, /api/wechat/mp → 完整路径 /laop/api/...
# - 不带 /api 前缀:/user, /banner, /course ... → 完整路径 /laop/...
# 前端 axios baseURL=/api,所以请求分两种:
# - /api/file/xxx, /api/mini/xxx, /api/wechat/xxx → 重写为 /laop/api/...
# - /api/user/xxx 等其余 → 重写为 /laop/...
# 用 nginx location 精确匹配实现,避免使用 if(nginx "if is evil")
# =====================================================================
server {
listen 80;
server_name _;
# 字符集 & 日志
charset utf-8;
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log warn;
# SPA 根目录
root /usr/share/nginx/html;
index index.html;
# ----- 静态资源缓存(带 hash 的 assets 长缓存)-----
location ~* \.(?:js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|webp|svg|ico)$ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
access_log off;
}
# ----- 后端反向代理:文件上传/下载 /api/file/* → /laop/api/file/* -----
location /api/file/ {
proxy_pass http://laop-admin:8081/laop/api/file/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 文件上传放宽超时(与后端 max-request-size 15MB 配合)
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
client_max_body_size 20m;
}
# ----- 后端反向代理:小程序端 /api/mini/* → /laop/api/mini/* -----
location /api/mini/ {
proxy_pass http://laop-admin:8081/laop/api/mini/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ----- 后端反向代理:微信公众号回调 /api/wechat/* → /laop/api/wechat/* -----
location /api/wechat/ {
proxy_pass http://laop-admin:8081/laop/api/wechat/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ----- 后端反向代理:其余 /api/* → /laop/* -----
# 适配 Web 管理端 controller(路径不带 /api 前缀,如 /user /banner /course 等)
location /api/ {
proxy_pass http://laop-admin:8081/laop/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ----- 后端静态资源(上传的图片/视频/文件)/static/* → /laop/static/* -----
# 前端通过 VITE_FILE_BASE_URL 留空时,浏览器以相对路径访问 /static/xxx
location /static/ {
proxy_pass http://laop-admin:8081/laop/static/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 图片/视频下载放宽超时
proxy_read_timeout 120s;
}
# ----- 健康检查 -----
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ----- SPA History 路由 fallback(兜底放在最后)-----
location / {
try_files $uri $uri/ /index.html;
}
}

2796
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "laop-web",
"private": true,
"version": "1.0.0",
"description": "翼云Hub · 无人机信息开放平台 Web 管理端",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.7",
"echarts": "^6.1.0",
"element-plus": "^2.8.4",
"pinia": "^2.2.2",
"vue": "^3.5.10",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"sass": "^1.79.3",
"vite": "^5.4.8"
}
}

10
src/App.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<script setup>
/**
* 根组件
* 仅作为路由出口,不承载业务逻辑
*/
</script>

60
src/api/announcement.js Normal file
View File

@ -0,0 +1,60 @@
import request from './request'
/**
* 通知公告模块 API 封装(Web 管理端)
* 后端接口前缀:/announcement/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询公告列表
* @param {object} params 查询参数 { title?, status?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageAnnouncement(params) {
return request.get('/announcement/list', { params })
}
/**
* 查询公告详情(编辑回显)
* @param {number} id 公告 ID
* @returns {Promise<object>} 公告 VO
*/
export function getAnnouncementDetail(id) {
return request.get(`/announcement/${id}`)
}
/**
* 新增公告
* @param {object} data 公告表单数据 { title, content, status }
* @returns {Promise<number>} 新增公告的 ID
*/
export function createAnnouncement(data) {
return request.post('/announcement', data)
}
/**
* 编辑公告(全量更新)
* @param {number} id 公告 ID
* @param {object} data 公告表单数据
*/
export function updateAnnouncement(id, data) {
return request.put(`/announcement/${id}`, data)
}
/**
* 删除公告(软删除)
* @param {number} id 公告 ID
*/
export function removeAnnouncement(id) {
return request.delete(`/announcement/${id}`)
}
/**
* 切换公告展示状态(显示/隐藏)
* @param {number} id 公告 ID
* @param {number} status 目标状态:0隐藏 1显示
*/
export function updateAnnouncementStatus(id, status) {
return request.post(`/announcement/${id}/status`, null, { params: { status } })
}

69
src/api/banner.js Normal file
View File

@ -0,0 +1,69 @@
import request from './request'
/**
* 首页轮播图管理模块 API 封装(Web 管理端)
* 后端接口前缀:/banner/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询轮播图列表
* @param {object} params 查询参数 { status?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageBanner(params) {
return request.get('/banner/list', { params })
}
/**
* 查询轮播图详情(编辑回显)
* @param {number} id 轮播图 ID
* @returns {Promise<object>} 轮播图 VO
*/
export function getBannerDetail(id) {
return request.get(`/banner/${id}`)
}
/**
* 新增轮播图
* @param {object} data 轮播图表单数据
* @returns {Promise<number>} 新增轮播图的 ID
*/
export function createBanner(data) {
return request.post('/banner', data)
}
/**
* 编辑轮播图(全量更新)
* @param {number} id 轮播图 ID
* @param {object} data 轮播图表单数据
*/
export function updateBanner(id, data) {
return request.put(`/banner/${id}`, data)
}
/**
* 删除轮播图(软删除)
* @param {number} id 轮播图 ID
*/
export function removeBanner(id) {
return request.delete(`/banner/${id}`)
}
/**
* 切换轮播图展示状态(显示/隐藏)
* @param {number} id 轮播图 ID
* @param {number} status 目标状态:0隐藏 1显示
*/
export function updateBannerStatus(id, status) {
return request.post(`/banner/${id}/status`, null, { params: { status } })
}
/**
* 调整轮播图排序值
* @param {number} id 轮播图 ID
* @param {number} sortOrder 目标排序值
*/
export function updateBannerSort(id, sortOrder) {
return request.post(`/banner/${id}/sort`, null, { params: { sortOrder } })
}

105
src/api/course.js Normal file
View File

@ -0,0 +1,105 @@
import request from './request'
/**
* 课程模块 API 封装(Web 管理端)
* 后端接口前缀:/course/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询课程列表
* @param {object} params 查询参数 { name?, status?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageCourse(params) {
return request.get('/course/list', { params })
}
/**
* 查询课程详情(编辑回显)
* @param {number} id 课程 ID
* @returns {Promise<object>} 课程 VO
*/
export function getCourseDetail(id) {
return request.get(`/course/${id}`)
}
/**
* 新增课程
* @param {object} data 课程表单数据
* @returns {Promise<number>} 新增课程的 ID
*/
export function createCourse(data) {
return request.post('/course', data)
}
/**
* 编辑课程(全量更新)
* @param {number} id 课程 ID
* @param {object} data 课程表单数据
*/
export function updateCourse(id, data) {
return request.put(`/course/${id}`, data)
}
/**
* 删除课程(软删除)
* @param {number} id 课程 ID
*/
export function removeCourse(id) {
return request.delete(`/course/${id}`)
}
/**
* 课程上架/下架
* @param {number} id 课程 ID
* @param {number} status 目标状态:0下架 1上架
*/
export function updateCourseStatus(id, status) {
return request.post(`/course/${id}/status`, null, { params: { status } })
}
/**
* 上传课程图片(宣传图 / 富文本内嵌图)
* 后端文件类型枚举:COURSE_IMAGE
* @param {File} file 图片文件
* @returns {Promise<{fileId: number, fileUrl: string, fileSize: number, fileSuffix: string}>}
*/
export function uploadCourseImage(file) {
const formData = new FormData()
formData.append('file', file)
formData.append('fileType', 'COURSE_IMAGE')
return request.post('/file/upload', formData)
}
// 文件访问基础 URL(取自环境变量,与后端 file-base-url 一致)
const FILE_BASE_URL = import.meta.env.VITE_FILE_BASE_URL || ''
/**
* 将后端返回的相对文件路径拼接为可直接访问的绝对 URL
* @param {string} url 相对路径(如 /static/course_image/xxx.jpg)或已是绝对路径
* @returns {string} 绝对 URL
*/
export function resolveFileUrl(url) {
if (!url) {
return ''
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
const prefix = FILE_BASE_URL.endsWith('/')
? FILE_BASE_URL.slice(0, -1)
: FILE_BASE_URL
const path = url.startsWith('/') ? url : `/${url}`
return prefix + path
}
/**
* 查询某课程下的预约名单(管理端)
* @param {object} params { courseId, pageNo, pageSize }
* courseId 为必传,pageNo 为 0 基页码
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function listCourseAppointments(params) {
return request.get('/course/appointments', { params })
}

25
src/api/dashboard.js Normal file
View File

@ -0,0 +1,25 @@
import request from './request'
/**
* 数据驾驶舱 API 封装(Web 管理端首页)
* 后端接口前缀:/dashboard/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 获取数据驾驶舱全量统计数据(单接口聚合返回,避免瀑布式加载)
* @returns {Promise<{
* userTotalCount: number,
* merchantCount: number,
* pilotCount: number,
* userMonthlyTrend: { months: string[], values: number[] },
* taskTotalCount: number,
* taskCompletedCount: number,
* taskInProgressCount: number,
* taskMonthlyTrend: { months: string[], values: number[] },
* taskTypeDistribution: Array<{ typeCode: number, typeName: string, count: number }>,
* taskStatusDistribution: Array<{ statusCode: number, statusName: string, count: number }>
* }>} Dashboard 聚合 VO
*/
export function getDashboardStats() {
return request.get('/dashboard/stats')
}

87
src/api/exam.js Normal file
View File

@ -0,0 +1,87 @@
import request from './request'
// 文件访问基础 URL(取自环境变量,与后端 file-base-url 一致)
const FILE_BASE_URL = import.meta.env.VITE_FILE_BASE_URL || ''
// ==================== 培训模块 ====================
/**
* 获取门店培训信息
*/
export function getTrainingStore() {
return request.get('/exam/training')
}
/**
* 编辑门店培训信息
* @param {object} data { name, address, contactName, contactPhone, trainingContent, coverImage }
*/
export function saveTrainingStore(data) {
return request.put('/exam/training', data)
}
/**
* 上传培训封面图片(后端文件类型枚举:TRAINING_COVER)
* @param {File} file 图片文件
* @returns {Promise<{fileId: number, fileUrl: string}>}
*/
export function uploadTrainingCover(file) {
const formData = new FormData()
formData.append('file', file)
formData.append('fileType', 'TRAINING_COVER')
return request.post('/file/upload', formData)
}
// ==================== 题库管理模块 ====================
/**
* 获取所有有效题库列表(下拉筛选用,带实时聚合的题目数量)
*/
export function listExamBanks() {
return request.get('/exam/banks')
}
/**
* 管理端分页查询题目,支持多条件筛选
* @param {object} params { bankId?, questionType?, keyword?, pageNo, pageSize }
* @returns {Promise<{list: QuestionVO[], total: number}>}
*/
export function listAdminQuestions(params) {
return request.get('/exam/questions', { params })
}
/**
* 新增题目
* @param {object} data ExamQuestionSaveDTO
*/
export function createQuestion(data) {
return request.post('/exam/questions', data)
}
/**
* 编辑题目
* @param {number} id 题目ID
* @param {object} data ExamQuestionSaveDTO
*/
export function updateQuestion(id, data) {
return request.put(`/exam/questions/${id}`, data)
}
/**
* 软删除题目
* @param {number} id 题目ID
*/
export function deleteQuestion(id) {
return request.delete(`/exam/questions/${id}`)
}
/**
* 将后端返回的相对文件路径拼接为可直接访问的绝对 URL
*/
export function resolveFileUrl(url) {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
const prefix = FILE_BASE_URL.endsWith('/') ? FILE_BASE_URL.slice(0, -1) : FILE_BASE_URL
const path = url.startsWith('/') ? url : `/${url}`
return prefix + path
}

23
src/api/miniConfig.js Normal file
View File

@ -0,0 +1,23 @@
import request from './request'
/**
* 小程序功能配置模块 API
* 后端接口前缀:/mini/config/*
*/
/**
* 获取功能配置项列表(当前只有消息推送开关)
* @returns {Promise<{configKey: string, configValue: string, remark: string}[]>}
*/
export function getMiniConfigList() {
return request.get('/mini/config/list')
}
/**
* 更新指定配置项的值
* @param {string} configKey 配置键,如 'mp_push_enabled'
* @param {string|number} configValue 配置值(布尔开关用 '0' 或 '1')
*/
export function updateMiniConfig(configKey, configValue) {
return request.put(`/mini/config/${configKey}`, { configValue })
}

54
src/api/request.js Normal file
View File

@ -0,0 +1,54 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
// 创建 axios 实例
const service = axios.create({
baseURL: '/api',
timeout: 15000
})
/**
* 请求拦截器:自动注入 token
*/
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
/**
* 响应拦截器:统一错误处理
* 后端统一返回结构:{ code, message, data }
*/
service.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== undefined && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
const status = error?.response?.status
if (status === 401) {
// token 失效,清理本地状态并跳转登录页
const userStore = useUserStore()
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`)
} else {
ElMessage.error(error?.response?.data?.message || error.message || '网络异常')
}
return Promise.reject(error)
}
)
export default service

41
src/api/task.js Normal file
View File

@ -0,0 +1,41 @@
import request from './request'
/**
* 任务管理模块 API 封装(Web 管理端)
* 后端接口前缀:/task/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询任务列表(全量任务,含发布商家信息)
* @param {object} params 查询参数 { keyword?, type?, status?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageTask(params) {
return request.get('/task/list', { params })
}
/**
* 查询任务详情(全量任务信息 + 发布商家信息 + 场景图 URL)
* @param {number} id 任务 ID
* @returns {Promise<object>} 任务管理 VO
*/
export function getTaskDetail(id) {
return request.get(`/task/${id}`)
}
/**
* 取消任务(管理员干预),将状态置为"已取消"
* @param {number} id 任务 ID
*/
export function cancelTask(id) {
return request.post(`/task/${id}/cancel`)
}
/**
* 软删除任务(管理员干预)
* @param {number} id 任务 ID
*/
export function removeTask(id) {
return request.delete(`/task/${id}`)
}

74
src/api/user.js Normal file
View File

@ -0,0 +1,74 @@
import request from './request'
/**
* 用户管理模块 API 封装(Web 管理端)
* 后端接口前缀:/user/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询用户列表(全量注册用户,含飞手/商家认证状态)
* @param {object} params 查询参数 { keyword?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageUser(params) {
return request.get('/user/list', { params })
}
/**
* 查询指定用户的商家认证信息(注册时提交的全部内容)
* @param {number} userId 用户 ID
* @returns {Promise<object>} 商家认证 VO(含门头照/营业执照 URL)
*/
export function getUserMerchantDetail(userId) {
return request.get(`/user/${userId}/merchant`)
}
/**
* 查询指定用户的飞手认证信息(注册时提交的全部内容)
* @param {number} userId 用户 ID
* @returns {Promise<object>} 飞手认证 VO(含执照照片 URL、服务类型编码列表)
*/
export function getUserPilotDetail(userId) {
return request.get(`/user/${userId}/pilot`)
}
/**
* 强制取消商家认证(软删除 merchant 记录 + 重置 user 绑定标记)
* 若该商家存在"待接单/已接单/执行中"的任务,后端会拒绝取消并返回提示
* @param {number} userId 用户 ID
* @returns {Promise<void>}
*/
export function revokeMerchantCert(userId) {
return request.post(`/user/${userId}/revoke-merchant`)
}
/**
* 强制取消飞手认证(软删除 pilot 记录 + 重置 user 绑定标记)
* 若该飞手存在"待接单/已接单/执行中"的任务,后端会拒绝取消并返回提示
* @param {number} userId 用户 ID
* @returns {Promise<void>}
*/
export function revokePilotCert(userId) {
return request.post(`/user/${userId}/revoke-pilot`)
}
/**
* 分页查询指定用户的积分增减明细流水
* @param {number} userId 用户 ID
* @param {object} params 分页参数 { pageNo, pageSize },pageNo 为 0 基
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function getUserPointsDetail(userId, params) {
return request.get(`/user/${userId}/points`, { params })
}
/**
* 手动增减指定用户积分(用户管理 → 积分明细弹窗)
* @param {number} userId 用户 ID
* @param {object} data 调整请求 { changeType: 1新增|2扣减, points: number, remark: string }
* @returns {Promise<number>} 调整后的最新积分余额
*/
export function adjustUserPoints(userId, data) {
return request.post(`/user/${userId}/points/adjust`, data)
}

16
src/api/wxMessage.js Normal file
View File

@ -0,0 +1,16 @@
import request from './request'
/**
* 微信模板消息发送日志模块 API 封装(Web 管理端)
* 后端接口前缀:/wx-message-log/*(经 vite 代理转发至 Spring Boot)
*/
/**
* 分页查询模板消息发送日志
* @param {object} params 查询参数 { userId?, bizType?, sendStatus?, openId?, pageNo, pageSize }
* pageNo 为 0 基页码(与后端 Spring Data 分页约定一致)
* @returns {Promise<{list: Array, totalElements: number, totalPages: number, pageNo: number, pageSize: number}>}
*/
export function pageWxMessageLog(params) {
return request.get('/wx-message-log/list', { params })
}

BIN
src/assets/login-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
src/assets/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

10
src/assets/logo.svg Normal file
View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C6AF8"/>
<stop offset="100%" stop-color="#5E46F6"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="28" height="28" rx="8" fill="url(#g)"/>
<path d="M16 7 L22 22 L16 18 L10 22 Z" fill="#fff" opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

439
src/layouts/MainLayout.vue Normal file
View File

@ -0,0 +1,439 @@
<template>
<el-container class="main-layout">
<!-- 侧边栏 -->
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
<div class="logo-area">
<img src="@/assets/logo.png" alt="logo" class="logo-img" v-if="!collapsed" />
<span class="logo-text" v-if="!collapsed">翼云Hub</span>
<span class="logo-text-mini" v-else>YH</span>
</div>
<el-menu
:default-active="activeMenu"
:default-openeds="openedSubMenus"
:collapse="collapsed"
:collapse-transition="false"
background-color="#1f2937"
text-color="#d1d5db"
active-text-color="#ffffff"
router
unique-opened
>
<template v-for="item in menuList" :key="item.path || item.groupKey">
<!-- 二级菜单(有 children) -->
<el-sub-menu v-if="item.meta?.isSubMenu && item.children?.length" :index="item.groupKey">
<template #title>
<el-icon>
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
>
<template #title>
<span>{{ child.meta.title }}</span>
<span v-if="child.meta.subTitle" class="sub-title-tag">{{ child.meta.subTitle }}</span>
</template>
</el-menu-item>
</el-sub-menu>
<!-- 平级菜单项 -->
<el-menu-item v-else :index="item.path">
<el-icon>
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="collapsed = !collapsed">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<template v-for="(crumb, idx) in breadcrumbItems" :key="idx">
<el-breadcrumb-item :to="crumb.path" v-if="crumb.path">
{{ crumb.title }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>
{{ crumb.title }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" class="avatar">
{{ userStore.username?.charAt(0).toUpperCase() || 'A' }}
</el-avatar>
<span class="username">{{ userStore.username }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { routes as rawRoutes, menuGroups } from '@/router'
import { ElMessageBox, ElMessage } from 'element-plus'
import {
Fold,
Expand,
ArrowDown,
SwitchButton,
Odometer,
Reading,
Bell,
List,
User,
Monitor
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 侧边栏折叠状态
const collapsed = ref(false)
// 当前激活的菜单
const activeMenu = computed(() => route.path)
const currentRoute = computed(() => route)
// ==================== 图标解析 ====================
/** 图标名称 → Element Plus 组件实例映射 */
const iconMap = {
Odometer,
Reading,
Bell,
List,
User,
Monitor
}
/**
* 根据 meta.icon 字符串解析对应的 Element Plus 图标组件
* @param {string} iconName 图标名称,如 'Odometer'
*/
function resolveIcon(iconName) {
return iconMap[iconName] || Odometer
}
// ==================== 菜单数据 ====================
/**
* 侧边栏菜单列表:
* - 顶层路由(无 meta.parentKey)→ 直接渲染为 el-menu-item
* - 有 meta.parentKey 的路由 → 按 parentKey 分组,渲染为 el-sub-menu
*
* 排序规则:menuGroups 的 sort 优先,未分组路由按原始顺序追加
*/
const menuList = computed(() => {
const root = rawRoutes.find(r => r.path === '/')
if (!root?.children) return []
const allChildren = root.children.filter(item => !item.meta?.hidden)
// 1. 分离顶层路由和有父级的路由
const topLevel = []
const groupedMap = new Map() // parentKey -> routes[]
for (const route of allChildren) {
const parentKey = route.meta?.parentKey
if (parentKey && menuGroups[parentKey]) {
if (!groupedMap.has(parentKey)) groupedMap.set(parentKey, [])
groupedMap.get(parentKey).push(route)
} else {
topLevel.push(route)
}
}
// 2. 构造 sub-menu 项(来自 menuGroups 的 sort 排序)
const groupItems = Object.keys(menuGroups)
.filter(key => groupedMap.has(key))
.sort((a, b) => (menuGroups[a].sort || 99) - (menuGroups[b].sort || 99))
.map(key => ({
groupKey: key,
meta: {
title: menuGroups[key].title,
icon: menuGroups[key].icon,
isSubMenu: true
},
children: groupedMap.get(key).map(r => ({
path: r.path.startsWith('/') ? r.path : '/' + r.path,
meta: r.meta
}))
}))
// 3. 合并输出:先 topLevel,再 groupItems
// 这里保持原有的 el-menu-item 字段结构(path + meta),
// sub-menu 项额外带 children 字段
const result = []
// 给顶层路由 path 补全前导斜杠
for (const r of topLevel) {
result.push({
path: r.path.startsWith('/') ? r.path : '/' + r.path,
meta: r.meta
})
}
for (const g of groupItems) {
result.push(g)
}
return result
})
/**
* 当前路由所属的 sub-menu groupKey 集合,用于默认展开
*/
const openedSubMenus = computed(() => {
const parentKey = route.meta?.parentKey
return parentKey && menuGroups[parentKey] ? [parentKey] : []
})
// ==================== 面包屑 ====================
/**
* 根据当前路由 meta.parentKey 动态组装面包屑:
* - 若路由有 parentKey → "首页 / 分组标题 / 当前标题"
* - 否则 → "首页 / 当前标题"
*/
const breadcrumbItems = computed(() => {
const items = []
const parentKey = route.meta?.parentKey
if (parentKey && menuGroups[parentKey]) {
items.push({ title: menuGroups[parentKey].title, path: null })
}
if (route.meta?.title && route.meta.title !== '登录') {
items.push({ title: route.meta.title, path: null })
}
return items
})
// ==================== 下拉菜单 ====================
/**
* 下拉菜单命令处理
*/
function handleCommand(command) {
if (command === 'logout') {
ElMessageBox.confirm('确认退出登录吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
userStore.logout()
ElMessage.success('已退出登录')
router.replace('/login')
})
.catch(() => {})
}
}
</script>
<style lang="scss" scoped>
.main-layout {
height: 100vh;
}
// ==================== 侧边栏 ====================
.sidebar {
background-color: #1f2937;
transition: width 0.28s ease;
overflow: hidden;
.logo-area {
height: 72px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
overflow: hidden;
}
.logo-img {
height: 52px;
width: auto;
object-fit: contain;
}
.logo-text {
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: 2px;
white-space: nowrap;
}
.logo-text-mini {
font-size: 16px;
font-weight: 700;
}
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
margin: 4px 8px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
&.is-active {
background-color: #5e46f6 !important;
color: #fff !important;
}
}
:deep(.el-sub-menu) {
margin: 4px 8px;
border-radius: 6px;
.el-sub-menu__title {
height: 50px;
line-height: 50px;
border-radius: 6px;
margin: 0;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
}
.el-menu-item {
margin: 2px 8px 2px 24px;
padding-left: 20px;
height: 44px;
line-height: 44px;
}
}
.sub-title-tag {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
color: #a5a9b3;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
// ==================== 顶栏 ====================
.header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #606266;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
.username {
font-size: 14px;
color: #303133;
}
}
}
// ==================== 内容区 ====================
.main-content {
padding: 20px;
background-color: #f0f2f5;
overflow-y: auto;
}
// ==================== 页面切换动画 ====================
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

24
src/main.js Normal file
View File

@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/global.scss'
const app = createApp(App)
// 注册 Element Plus 所有图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
// 中文语言包:日期选择面板、分页、消息框等组件文案统一为中文
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

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

@ -0,0 +1,127 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
// 白名单路由:无需鉴权即可访问
const WHITE_LIST = ['/login']
/** 路由表(同时导出给 MainLayout 侧边栏菜单使用) */
export const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', hidden: true }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'Odometer' }
},
{
path: 'course',
name: 'Course',
component: () => import('@/views/course/index.vue'),
meta: { title: '研学课程', icon: 'Reading' }
},
{
path: 'exam',
name: 'Exam',
component: () => import('@/views/exam/index.vue'),
meta: { title: '培训考试', icon: 'School' }
},
{
path: 'announcement',
name: 'Announcement',
component: () => import('@/views/announcement/index.vue'),
meta: { title: '通知公告', icon: 'Bell' }
},
{
path: 'task',
name: 'Task',
component: () => import('@/views/task/index.vue'),
meta: { title: '任务管理', icon: 'List' }
},
{
path: 'wx-message',
name: 'WxMessage',
component: () => import('@/views/wxMessage/index.vue'),
meta: { title: '消息推送日志', icon: 'Message' }
},
{
path: 'user',
name: 'User',
component: () => import('@/views/user/index.vue'),
meta: { title: '用户管理', icon: 'User' }
},
// ========== 小程序管理(通过 meta.parentKey 分组渲染为二级菜单) ==========
{
path: 'mini/home-banner',
name: 'MiniHomeBanner',
component: () => import('@/views/mini/banner/index.vue'),
meta: { title: '首页轮播图配置', parentKey: 'mini' }
},
{
path: 'mini/feature-config',
name: 'MiniFeatureConfig',
component: () => import('@/views/mini/config/index.vue'),
meta: { title: '功能配置', parentKey: 'mini' }
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { hidden: true }
}
]
/**
* 一级菜单分组配置(侧边栏 sub-menu 渲染)。
* 路由通过 meta.parentKey 关联到此分组。
*/
export const menuGroups = {
mini: {
title: '小程序管理',
icon: 'Monitor',
sort: 6
}
}
const router = createRouter({
history: createWebHashHistory(),
routes
})
/**
* 全局前置守卫
* 未登录用户访问非白名单路由时重定向到登录页
*/
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const hasToken = !!userStore.token
if (hasToken) {
if (to.path === '/login') {
// 已登录访问登录页,直接跳转首页
next({ path: '/' })
} else {
next()
}
} else {
if (WHITE_LIST.includes(to.path)) {
next()
} else {
// 未登录,重定向到登录页并携带 redirect 参数
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
}
})
export default router

60
src/stores/user.js Normal file
View File

@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 登录凭据(硬编码,仅用于当前阶段)
const MOCK_USERNAME = 'haisen'
const MOCK_PASSWORD = 'haisen2026'
/**
* 用户状态 Store
* 管理登录态、token、用户信息
*
* token 说明:后端当前采用"token 即 userId"的阶段性简化约定(AuthInterceptor 直接
* Long.parseLong 解析),管理端尚无账号体系,故用纯数字时间戳作为模拟 token,
* 保证文件上传等需要登录态的接口可用
*/
export const useUserStore = defineStore('user', () => {
// token:localStorage 持久化,刷新页面不丢失
const token = ref(localStorage.getItem('laop_token') || '')
// 用户名
const username = ref(localStorage.getItem('laop_username') || '')
// 兼容清理:历史版本 token 携带非数字前缀,后端无法解析,直接清除强制重新登录
if (token.value && !/^\d+$/.test(token.value)) {
localStorage.removeItem('laop_token')
localStorage.removeItem('laop_username')
token.value = ''
username.value = ''
}
/**
* 登录校验
* @param {string} inputUsername 用户名
* @param {string} inputPassword 密码
* @returns {boolean} 是否登录成功
*/
function login(inputUsername, inputPassword) {
if (inputUsername !== MOCK_USERNAME || inputPassword !== MOCK_PASSWORD) {
return false
}
// 生成纯数字模拟 token 并持久化(满足后端 token=userId 的解析约定)
const mockToken = String(Date.now())
token.value = mockToken
username.value = inputUsername
localStorage.setItem('laop_token', mockToken)
localStorage.setItem('laop_username', inputUsername)
return true
}
/**
* 登出:清除本地存储
*/
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('laop_token')
localStorage.removeItem('laop_username')
}
return { token, username, login, logout }
})

65
src/styles/global.scss Normal file
View File

@ -0,0 +1,65 @@
/**
* 全局基础样式
* 重置浏览器默认样式,定义项目通用样式
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
font-size: 14px;
color: #303133;
background-color: #f0f2f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 品牌主题色:与侧边栏激活色/小程序端主题保持一致(翼云Hub 品牌紫)
html {
--el-color-primary: #5e46f6;
--el-color-primary-light-3: #7c6af8;
--el-color-primary-light-5: #9d8ffa;
--el-color-primary-light-7: #beb4fc;
--el-color-primary-light-8: #d5cffd;
--el-color-primary-light-9: #eceafd;
--el-color-primary-dark-2: #4b38c5;
}
a {
text-decoration: none;
color: inherit;
}
ul,
ol {
list-style: none;
}
// 滚动条样式优化
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
&:hover {
background: #909399;
}
}

View File

@ -0,0 +1,48 @@
<template>
<div class="placeholder-page">
<el-empty :description="`${title} 功能开发中...`">
<template #image>
<el-icon :size="80" color="#c0c4cc"><component :is="icon" /></el-icon>
</template>
</el-empty>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import {
Odometer,
Reading,
Bell,
List,
User,
Monitor,
QuestionFilled
} from '@element-plus/icons-vue'
const route = useRoute()
// 图标映射
const iconMap = {
Dashboard: Odometer,
Course: Reading,
Announcement: Bell,
Task: List,
User: User,
Mini: Monitor
}
const title = computed(() => route.meta.title || '页面')
const icon = computed(() => iconMap[route.name] || QuestionFilled)
</script>
<style lang="scss" scoped>
.placeholder-page {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
}
</style>

View File

@ -0,0 +1,461 @@
<template>
<div class="announcement-page">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-input
v-model="query.title"
placeholder="公告标题"
clearable
class="filter-input"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.status"
placeholder="状态"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option label="显示" :value="STATUS.VISIBLE" />
<el-option label="隐藏" :value="STATUS.HIDDEN" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
<div class="filter-spacer" />
<el-button type="primary" :icon="Plus" @click="openCreate">新增公告</el-button>
</div>
</el-card>
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="list" row-key="id" stripe>
<el-table-column label="公告标题" prop="title" min-width="200" show-overflow-tooltip />
<el-table-column label="内容预览" min-width="300">
<template #default="{ row }">
<span class="content-preview">{{ truncateContent(row.content) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).tag" effect="light">
{{ statusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="170" />
<el-table-column label="更新时间" prop="updatedAt" width="170" />
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
link
:type="row.status === STATUS.VISIBLE ? 'warning' : 'success'"
@click="handleToggleStatus(row)"
>
{{ row.status === STATUS.VISIBLE ? '隐藏' : '显示' }}
</el-button>
<el-button link type="danger" @click="handleRemove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="fetchList"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
width="640px"
top="8vh"
:close-on-click-modal="false"
@open="handleDialogOpen"
>
<template #header>
<div class="dialog-title">
{{ editingId ? '编辑公告' : '新增公告' }}
<span class="dialog-subtitle">通知公告信息维护</span>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入公告标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="公告内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:autosize="{ minRows: 6, maxRows: 14 }"
placeholder="请输入公告内容,点击公告条后进入详情页展示"
maxlength="5000"
show-word-limit
resize="vertical"
/>
</el-form-item>
<el-form-item label="展示状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="STATUS.VISIBLE">显示</el-radio>
<el-radio :value="STATUS.HIDDEN">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保 存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import {
pageAnnouncement,
createAnnouncement,
updateAnnouncement,
removeAnnouncement,
updateAnnouncementStatus
} from '@/api/announcement'
// ==================== 业务常量(与后端 AnnouncementConstants 对齐) ====================
/** 公告状态编码 */
const STATUS = {
HIDDEN: 0,
VISIBLE: 1
}
/** 状态 → 标签文案/颜色映射 */
const STATUS_META_MAP = {
[STATUS.HIDDEN]: { label: '隐藏', tag: 'info' },
[STATUS.VISIBLE]: { label: '显示', tag: 'success' }
}
/** 内容预览截断长度 */
const CONTENT_PREVIEW_LENGTH = 50
/** 表单默认值,新增/编辑弹窗复用 */
const DEFAULT_FORM = {
title: '',
content: '',
status: STATUS.VISIBLE
}
// ==================== 列表状态 ====================
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({
title: '',
status: null,
page: 1,
pageSize: 10
})
/**
* 拉取公告分页列表
*/
async function fetchList() {
loading.value = true
try {
const res = await pageAnnouncement({
title: query.title || undefined,
status: query.status ?? undefined,
pageNo: query.page - 1,
pageSize: query.pageSize
})
list.value = res.data.list || []
total.value = Number(res.data.totalElements || 0)
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/** 查询:重置到第 1 页再拉取 */
function handleSearch() {
query.page = 1
fetchList()
}
/** 重置筛选条件 */
function handleReset() {
query.title = ''
query.status = null
query.page = 1
fetchList()
}
/** 页大小变化:回到第 1 页 */
function handleSizeChange() {
query.page = 1
fetchList()
}
// ==================== 列表展示辅助 ====================
/**
* 状态标签元信息
*/
function statusMeta(status) {
return STATUS_META_MAP[status] || { label: '未知', tag: 'info' }
}
/**
* 内容预览截断:超出 50 字符以省略号结尾
*/
function truncateContent(content) {
if (!content) {
return '-'
}
return content.length > CONTENT_PREVIEW_LENGTH
? content.slice(0, CONTENT_PREVIEW_LENGTH) + '...'
: content
}
// ==================== 新增/编辑弹窗 ====================
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({ ...DEFAULT_FORM })
/** 表单校验规则 */
const rules = {
title: [
{ required: true, message: '请输入公告标题', trigger: 'blur' },
{ max: 100, message: '公告标题不能超过100个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入公告内容', trigger: 'blur' },
{ max: 5000, message: '公告内容不能超过5000个字符', trigger: 'blur' }
],
status: [{ required: true, message: '请选择展示状态', trigger: 'change' }]
}
/**
* 新增公告:重置表单为默认值
*/
function openCreate() {
editingId.value = null
Object.assign(form, DEFAULT_FORM)
dialogVisible.value = true
}
/**
* 编辑公告:用行数据回填表单(列表 VO 已含全部字段,无需二次请求)
*/
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
title: row.title,
content: row.content || '',
status: row.status
})
dialogVisible.value = true
}
/** 弹窗打开后清除残留校验状态 */
function handleDialogOpen() {
formRef.value?.clearValidate()
}
/**
* 保存公告(新增/编辑共用)
*/
async function handleSave() {
try {
await formRef.value.validate()
} catch {
// 校验失败,错误提示已由表单展示
return
}
saving.value = true
try {
const payload = {
title: form.title,
content: form.content,
status: form.status
}
if (editingId.value) {
await updateAnnouncement(editingId.value, payload)
ElMessage.success('编辑成功')
} else {
await createAnnouncement(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
saving.value = false
}
}
// ==================== 状态切换与删除 ====================
/**
* 显示/隐藏切换
*/
async function handleToggleStatus(row) {
const target = row.status === STATUS.VISIBLE ? STATUS.HIDDEN : STATUS.VISIBLE
try {
await updateAnnouncementStatus(row.id, target)
ElMessage.success(target === STATUS.VISIBLE ? '已显示' : '已隐藏')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
/**
* 删除公告(软删除),删除前二次确认
*/
async function handleRemove(row) {
try {
await ElMessageBox.confirm(
`确定删除公告「${row.title}」吗?删除后小程序端将不可见。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
// 用户取消删除
return
}
try {
await removeAnnouncement(row.id)
ElMessage.success('删除成功')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
// 初始拉取列表
fetchList()
</script>
<style lang="scss" scoped>
.announcement-page {
display: flex;
flex-direction: column;
gap: 16px;
}
// ==================== 筛选栏 ====================
.filter-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
.filter-input {
width: 220px;
}
.filter-select {
width: 130px;
}
.filter-spacer {
flex: 1;
}
}
// ==================== 列表 ====================
.content-preview {
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
// ==================== 表单弹窗 ====================
:deep(.el-dialog) {
border-radius: 14px;
box-shadow: 0 12px 40px rgba(30, 27, 75, 0.16);
}
:deep(.el-dialog__header) {
padding: 20px 24px 16px;
margin-right: 0;
border-bottom: 1px solid #f0f1f5;
}
:deep(.el-dialog__body) {
padding: 8px 24px 4px;
max-height: calc(94vh - 170px);
overflow-y: auto;
}
:deep(.el-dialog__footer) {
padding: 14px 24px;
border-top: 1px solid #f0f1f5;
background-color: #fafbfd;
border-radius: 0 0 14px 14px;
}
.dialog-title {
display: flex;
align-items: center;
font-size: 17px;
font-weight: 600;
color: #1d2129;
&::before {
content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: linear-gradient(180deg, #7c6af8, #5e46f6);
margin-right: 10px;
}
.dialog-subtitle {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: #a2a6ad;
}
}
</style>

1176
src/views/course/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,935 @@
<template>
<div class="dashboard-container" v-loading="loading" element-loading-text="数据加载中...">
<!-- 加载失败状态 -->
<div v-if="!loading && error" class="error-state">
<el-empty description="数据加载失败,请检查后端服务是否启动">
<el-button type="primary" @click="fetchData">
<el-icon style="margin-right: 4px"><Refresh /></el-icon>
重新加载
</el-button>
</el-empty>
</div>
<template v-else>
<!-- 顶部欢迎栏 -->
<div class="welcome-bar">
<div class="welcome-left">
<span class="welcome-greeting">{{ greeting }}</span>
<span class="welcome-subtitle">欢迎来到翼云Hub 数据驾驶舱</span>
</div>
<div class="welcome-right">
<el-icon class="refresh-btn" :class="{ spinning: refreshing }" @click="fetchData">
<Refresh />
</el-icon>
<span class="update-time">数据更新:{{ updateTime }}</span>
</div>
</div>
<!-- 第一行:统计卡片 -->
<div class="stats-row">
<div
v-for="(card, idx) in statCards"
:key="card.label"
class="stat-card"
:class="`stat-card--${idx}`"
>
<div class="stat-card-bg"></div>
<div class="stat-card-content">
<div class="stat-icon">
<el-icon :size="28">
<component :is="card.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">
{{ animatedValues[idx]?.toLocaleString() || 0 }}
</div>
</div>
<div v-if="card.trend !== undefined && card.trend !== null" class="stat-trend" :class="card.trend >= 0 ? 'up' : 'down'">
<el-icon>
<Top v-if="card.trend >= 0" />
<Bottom v-else />
</el-icon>
<span>{{ Math.abs(card.trend) }}%</span>
</div>
</div>
</div>
</div>
<!-- 第二行:月度趋势折线图 -->
<div class="charts-row charts-row--two">
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<span class="title-dot title-dot--purple"></span>
用户月度增长趋势
</div>
<span class="panel-subtitle">近 6 个月</span>
</div>
<div ref="userTrendChartRef" class="chart-body"></div>
</div>
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<span class="title-dot title-dot--cyan"></span>
任务月度增长趋势
</div>
<span class="panel-subtitle">近 6 个月</span>
</div>
<div ref="taskTrendChartRef" class="chart-body"></div>
</div>
</div>
<!-- 第三行:状态环形图 + 类型柱状图 -->
<div class="charts-row charts-row--two">
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<span class="title-dot title-dot--amber"></span>
任务状态分布
</div>
</div>
<div ref="taskStatusChartRef" class="chart-body"></div>
</div>
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<span class="title-dot title-dot--green"></span>
任务类型分布
</div>
</div>
<div ref="taskTypeChartRef" class="chart-body"></div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getDashboardStats } from '@/api/dashboard'
import {
Refresh,
User,
UserFilled,
Avatar,
Document,
CircleCheck,
Clock,
Top,
Bottom
} from '@element-plus/icons-vue'
// ==================== 状态 ====================
const loading = ref(true)
const refreshing = ref(false)
const error = ref(false)
const statsData = ref(null)
const updateTime = ref('')
// 图表 DOM 引用
const userTrendChartRef = ref(null)
const taskTrendChartRef = ref(null)
const taskStatusChartRef = ref(null)
const taskTypeChartRef = ref(null)
// 图表实例
let userTrendChart = null
let taskTrendChart = null
let taskStatusChart = null
let taskTypeChart = null
// 动画数字:先显示 0,加载完成后动画递增到实际值
const animatedValues = reactive([0, 0, 0, 0, 0, 0])
// ==================== 计算属性 ====================
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 12) return '早上好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
const statCards = computed(() => {
const d = statsData.value
if (!d) return []
return [
{ label: '小程序用户', icon: User, value: d.userTotalCount },
{ label: '已认证商家', icon: UserFilled, value: d.merchantCount },
{ label: '已认证飞手', icon: Avatar, value: d.pilotCount },
{ label: '任务总数', icon: Document, value: d.taskTotalCount },
{ label: '已完成任务', icon: CircleCheck, value: d.taskCompletedCount },
{ label: '进行中任务', icon: Clock, value: d.taskInProgressCount }
]
})
// ==================== 生命周期 ====================
onMounted(async () => {
await fetchData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
disposeCharts()
})
// ==================== 数据加载 ====================
async function fetchData() {
refreshing.value = true
error.value = false
try {
const res = await getDashboardStats()
statsData.value = res.data
updateTime.value = formatNow()
await nextTick()
animateValues()
initCharts()
} catch (e) {
console.error('Dashboard stats fetch failed:', e)
error.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
function formatNow() {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// ==================== 数字动画 ====================
function animateValues() {
const values = statCards.value.map((c) => c.value)
const duration = 1000
const startTime = performance.now()
const startValues = [...animatedValues]
function tick(now) {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
for (let i = 0; i < values.length; i++) {
animatedValues[i] = Math.round(startValues[i] + (values[i] - startValues[i]) * eased)
}
if (progress < 1) {
requestAnimationFrame(tick)
}
}
requestAnimationFrame(tick)
}
// ==================== 图表初始化 ====================
function initCharts() {
if (!statsData.value) return
disposeCharts()
userTrendChart = initLineChart(userTrendChartRef.value, 'user')
taskTrendChart = initLineChart(taskTrendChartRef.value, 'task')
taskStatusChart = initDoughnutChart(taskStatusChartRef.value)
taskTypeChart = initBarChart(taskTypeChartRef.value)
}
function disposeCharts() {
userTrendChart?.dispose()
taskTrendChart?.dispose()
taskStatusChart?.dispose()
taskTypeChart?.dispose()
userTrendChart = null
taskTrendChart = null
taskStatusChart = null
taskTypeChart = null
}
function handleResize() {
userTrendChart?.resize()
taskTrendChart?.resize()
taskStatusChart?.resize()
taskTypeChart?.resize()
}
// ==================== 折线图 ====================
function initLineChart(el, type) {
if (!el || !statsData.value) return null
const trend = type === 'user' ? statsData.value.userMonthlyTrend : statsData.value.taskMonthlyTrend
const isUser = type === 'user'
const chart = echarts.init(el)
const option = {
grid: {
top: 20,
left: 50,
right: 24,
bottom: 36,
containLabel: false
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: { color: '#303133', fontSize: 13 },
axisPointer: {
type: 'line',
lineStyle: {
color: isUser ? 'rgba(94, 70, 246, 0.4)' : 'rgba(0, 195, 255, 0.4)',
width: 1,
type: 'dashed'
}
},
formatter: (params) => {
const p = params[0]
return `<div style="padding:4px 8px">
<div style="margin-bottom:6px;font-size:12px;color:#909399">${p.axisValue}</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${isUser ? '#5E46F6' : '#00C3FF'}"></span>
<span>${p.seriesName}:<b style="font-size:14px;color:#303133">${p.value}</b></span>
</div>
</div>`
}
},
xAxis: {
type: 'category',
data: trend.months,
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: {
color: '#909399',
fontSize: 12,
formatter: (v) => v.slice(5) + '月'
},
axisTick: { show: false }
},
yAxis: {
type: 'value',
name: '新增数量',
nameTextStyle: { color: '#909399', fontSize: 12, padding: [0, 0, 0, -32] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 12 },
splitLine: {
lineStyle: { color: '#f0f2f5', type: 'dashed' }
}
},
series: [
{
name: isUser ? '新增用户' : '新增任务',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
showSymbol: false,
lineStyle: {
width: 3,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: isUser ? '#7C6AF8' : '#00C3FF' },
{ offset: 1, color: isUser ? '#5E46F6' : '#0099CC' }
]),
shadowColor: isUser ? 'rgba(94, 70, 246, 0.4)' : 'rgba(0, 195, 255, 0.4)',
shadowBlur: 10
},
itemStyle: {
color: isUser ? '#5E46F6' : '#00C3FF',
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isUser ? 'rgba(94, 70, 246, 0.25)' : 'rgba(0, 195, 255, 0.2)' },
{ offset: 1, color: isUser ? 'rgba(94, 70, 246, 0.02)' : 'rgba(0, 195, 255, 0.02)' }
])
},
emphasis: {
focus: 'series',
showSymbol: true,
itemStyle: {
color: isUser ? '#5E46F6' : '#00C3FF',
borderWidth: 3,
shadowBlur: 15,
shadowColor: isUser ? 'rgba(94, 70, 246, 0.8)' : 'rgba(0, 195, 255, 0.8)'
}
},
data: trend.values,
animationDuration: 1200,
animationEasing: 'cubicOut'
}
]
}
chart.setOption(option)
return chart
}
// ==================== 环形饼图 ====================
function initDoughnutChart(el) {
if (!el || !statsData.value) return null
const data = statsData.value.taskStatusDistribution
// 过滤草稿状态
const filteredData = data.filter((d) => d.statusCode !== 0)
const total = filteredData.reduce((s, d) => s + d.count, 0)
const colorMap = {
1: '#5E46F6', // 待接单 - 主题紫
2: '#00C3FF', // 已接单 - 青色
3: '#F5A623', // 执行中 - 琥珀黄
4: '#22C55E', // 已完成 - 绿色
5: '#8B8FA6' // 已取消 - 灰色
}
const chart = echarts.init(el)
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: { color: '#303133', fontSize: 13 },
formatter: (p) => {
const percent = total > 0 ? ((p.value / total) * 100).toFixed(1) : 0
return `<div style="padding:4px 8px">
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color}"></span>
<span>${p.name}</span>
</div>
<div style="margin-top:4px;font-size:12px;color:#909399">数量:<b style="color:#303133">${p.value}</b>(${percent}%)</div>
</div>`
}
},
legend: {
orient: 'vertical',
right: 16,
top: 'center',
itemWidth: 10,
itemHeight: 10,
itemGap: 14,
textStyle: { color: '#606266', fontSize: 13 },
icon: 'circle',
formatter: (name) => {
const item = filteredData.find((d) => d.statusName === name)
return item ? `${name} ${item.count}` : name
}
},
series: [
{
type: 'pie',
radius: ['55%', '80%'],
center: ['38%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: { show: false },
labelLine: { show: false },
emphasis: {
scale: true,
scaleSize: 6,
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(0, 0, 0, 0.15)'
}
},
data: filteredData.map((d) => ({
name: d.statusName,
value: d.count,
itemStyle: { color: colorMap[d.statusCode] || '#8B8FA6' }
})),
animationType: 'scale',
animationDuration: 1000,
animationEasing: 'cubicOut'
}
],
graphic: [
{
type: 'text',
left: '38%',
top: '45%',
style: {
text: String(total),
textAlign: 'center',
fill: '#303133',
fontSize: 28,
fontWeight: 700
}
},
{
type: 'text',
left: '38%',
top: '55%',
style: {
text: '任务总数',
textAlign: 'center',
fill: '#909399',
fontSize: 12
}
}
]
}
chart.setOption(option)
return chart
}
// ==================== 横向柱状图 ====================
function initBarChart(el) {
if (!el || !statsData.value) return null
const data = statsData.value.taskTypeDistribution
// 横向柱状图:按 count 升序(少的在上面)
const sorted = [...data].sort((a, b) => a.count - b.count)
const chart = echarts.init(el)
const option = {
grid: {
top: 10,
left: 80,
right: 30,
bottom: 10,
containLabel: false
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: { color: '#303133', fontSize: 13 },
formatter: (params) => {
const p = params[0]
return `<div style="padding:4px 8px">
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color}"></span>
<span>${p.name}:<b style="font-size:14px;color:#303133">${p.value}</b> 个任务</span>
</div>
</div>`
}
},
xAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 12 },
splitLine: {
lineStyle: { color: '#f0f2f5', type: 'dashed' }
}
},
yAxis: {
type: 'category',
data: sorted.map((d) => d.typeName),
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
axisLabel: {
color: '#606266',
fontSize: 13,
fontWeight: 500
}
},
series: [
{
type: 'bar',
data: sorted.map((d, i) => ({
value: d.count,
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: typeColors[i % typeColors.length].start },
{ offset: 1, color: typeColors[i % typeColors.length].end }
])
}
})),
barWidth: 18,
label: {
show: true,
position: 'right',
color: '#303133',
fontSize: 13,
fontWeight: 600,
formatter: '{c}'
},
emphasis: {
itemStyle: {
shadowBlur: 15,
shadowColor: 'rgba(94, 70, 246, 0.3)'
}
},
animationDuration: 1000,
animationEasing: 'cubicOut'
}
]
}
chart.setOption(option)
return chart
}
// 任务类型渐变配色(循环使用)
const typeColors = [
{ start: '#7C6AF8', end: '#5E46F6' }, // 紫
{ start: '#00D4FF', end: '#0099CC' }, // 青
{ start: '#F5B123', end: '#D98500' }, // 琥珀
{ start: '#4ADE80', end: '#22C55E' }, // 绿
{ start: '#F87171', end: '#DC2626' }, // 红
{ start: '#A78BFA', end: '#7C3AED' }, // 浅紫
{ start: '#60A5FA', end: '#3B82F6' } // 蓝
]
</script>
<style lang="scss" scoped>
.dashboard-container {
min-height: 100%;
background: transparent;
animation: fadeSlideIn 0.4s ease-out;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ==================== 错误状态 ====================
.error-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
// ==================== 顶部欢迎栏 ====================
.welcome-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 18px;
.welcome-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.welcome-greeting {
font-size: 22px;
font-weight: 700;
color: #1a1c33;
}
.welcome-subtitle {
font-size: 13px;
color: #8b8fa6;
}
.welcome-right {
display: flex;
align-items: center;
gap: 14px;
}
.refresh-btn {
font-size: 18px;
color: #5e46f6;
cursor: pointer;
padding: 6px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(94, 70, 246, 0.08);
}
&.spinning {
animation: spin 0.8s linear infinite;
}
}
.update-time {
font-size: 12px;
color: #a0a4bd;
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// ==================== 统计卡片行 ====================
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-bottom: 18px;
}
.stat-card {
position: relative;
overflow: hidden;
border-radius: 14px;
background: #fff;
transition: transform 0.25s ease, box-shadow 0.25s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.1);
.stat-card-bg {
opacity: 1;
}
.stat-icon {
transform: scale(1.08) rotate(-2deg);
}
}
.stat-card-bg {
position: absolute;
top: 0;
right: 0;
width: 160px;
height: 160px;
border-radius: 50%;
opacity: 0.6;
filter: blur(40px);
transition: opacity 0.3s ease;
}
.stat-card-content {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 20px 22px;
}
.stat-icon {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: transform 0.3s ease;
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: 13px;
color: #8b8fa6;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 30px;
font-weight: 700;
color: #1a1c33;
font-family: 'SF Mono', 'Monaco', 'Menlo', -apple-system, sans-serif;
line-height: 1.1;
letter-spacing: -0.5px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
&.up {
color: #22c55e;
background-color: rgba(34, 197, 94, 0.1);
}
&.down {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
}
}
// 卡片 1:用户总数 - 主色紫渐变
.stat-card--0 {
.stat-icon {
background: linear-gradient(135deg, #7c6af8, #5e46f6);
box-shadow: 0 6px 20px rgba(94, 70, 246, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(94, 70, 246, 0.25), transparent 70%);
}
}
// 卡片 2:商家数 - 青色渐变
.stat-card--1 {
.stat-icon {
background: linear-gradient(135deg, #00d4ff, #0099cc);
box-shadow: 0 6px 20px rgba(0, 153, 204, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(0, 195, 255, 0.2), transparent 70%);
}
}
// 卡片 3:飞手数 - 琥珀渐变
.stat-card--2 {
.stat-icon {
background: linear-gradient(135deg, #f5b123, #d98500);
box-shadow: 0 6px 20px rgba(217, 133, 0, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(245, 177, 35, 0.2), transparent 70%);
}
}
// 卡片 4:任务总数 - 青绿渐变
.stat-card--3 {
.stat-icon {
background: linear-gradient(135deg, #4ade80, #22c55e);
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(34, 197, 94, 0.2), transparent 70%);
}
}
// 卡片 5:已完成 - 宝蓝渐变
.stat-card--4 {
.stat-icon {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 70%);
}
}
// 卡片 6:进行中 - 橙红渐变
.stat-card--5 {
.stat-icon {
background: linear-gradient(135deg, #fb923c, #f97316);
box-shadow: 0 6px 20px rgba(249, 115, 22, 0.4);
}
.stat-card-bg {
background: radial-gradient(circle, rgba(249, 115, 22, 0.2), transparent 70%);
}
}
// ==================== 图表行 ====================
.charts-row {
margin-bottom: 18px;
&--two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
}
.chart-panel {
background: #fff;
border-radius: 14px;
padding: 20px 22px 18px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.25s ease;
&:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #1a1c33;
}
.title-dot {
width: 10px;
height: 10px;
border-radius: 50%;
&--purple {
background: linear-gradient(135deg, #7c6af8, #5e46f6);
box-shadow: 0 2px 6px rgba(94, 70, 246, 0.5);
}
&--cyan {
background: linear-gradient(135deg, #00d4ff, #0099cc);
box-shadow: 0 2px 6px rgba(0, 153, 204, 0.5);
}
&--amber {
background: linear-gradient(135deg, #f5b123, #d98500);
box-shadow: 0 2px 6px rgba(217, 133, 0, 0.5);
}
&--green {
background: linear-gradient(135deg, #4ade80, #22c55e);
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.5);
}
}
.panel-subtitle {
font-size: 12px;
color: #a0a4bd;
}
.chart-body {
width: 100%;
height: 280px;
}
</style>

49
src/views/error/404.vue Normal file
View File

@ -0,0 +1,49 @@
<template>
<div class="error-page">
<div class="error-content">
<h1 class="error-code">404</h1>
<p class="error-desc">抱歉,您访问的页面不存在</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
/** 返回首页 */
function goHome() {
router.replace('/')
}
</script>
<style lang="scss" scoped>
.error-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background-color: #f0f2f5;
}
.error-content {
text-align: center;
}
.error-code {
font-size: 120px;
font-weight: 700;
color: #5e46f6;
margin-bottom: 16px;
letter-spacing: 8px;
line-height: 1;
}
.error-desc {
font-size: 16px;
color: #909399;
margin-bottom: 32px;
}
</style>

1111
src/views/exam/index.vue Normal file

File diff suppressed because it is too large Load Diff

241
src/views/login/index.vue Normal file
View File

@ -0,0 +1,241 @@
<template>
<div class="login-container">
<!-- 左侧品牌区 -->
<div class="login-left">
<div class="brand-content">
<h1 class="brand-title">翼云Hub</h1>
<p class="brand-subtitle">无人机信息开放平台</p>
<p class="brand-desc">一站式无人机运营管理解决方案</p>
</div>
</div>
<!-- 右侧登录表单区 -->
<div class="login-right">
<div class="login-form-wrapper">
<h2 class="form-title">欢迎登录</h2>
<p class="form-subtitle">翼云Hub 管理后台</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
size="large"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="请输入账号"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
登 录
</el-button>
</el-form-item>
</el-form>
<p class="form-tip">默认账号:haisen / haisen2026</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
// 表单数据
const form = reactive({
username: '',
password: ''
})
// 表单校验规则
const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
/**
* 登录处理
* 校验通过后调用 store.login,成功后跳转到 redirect 或首页
*/
async function handleLogin() {
// Element Plus validate 校验失败时 reject,成功时 resolve 不传参
// 用 try/catch 区分"校验失败"和"校验通过"
try {
await formRef.value?.validate()
} catch {
return
}
loading.value = true
try {
const success = userStore.login(form.username.trim(), form.password)
if (!success) {
ElMessage.error('账号或密码错误')
return
}
ElMessage.success('登录成功')
// 跳转到登录前的页面或首页
const redirect = route.query.redirect || '/'
router.replace(redirect)
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.login-container {
display: flex;
width: 100%;
height: 100vh;
background-color: #fff;
}
// 左侧品牌区:背景图 + 品牌文案
.login-left {
flex: 1;
position: relative;
background-image: url('@/assets/login-bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(94, 70, 246, 0.55) 0%,
rgba(62, 122, 255, 0.35) 100%
);
}
}
.brand-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding-left: 80px;
color: #fff;
}
.brand-title {
font-size: 52px;
font-weight: 700;
letter-spacing: 4px;
margin-bottom: 16px;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.brand-subtitle {
font-size: 22px;
font-weight: 500;
margin-bottom: 12px;
letter-spacing: 2px;
}
.brand-desc {
font-size: 16px;
opacity: 0.85;
letter-spacing: 1px;
}
// 右侧登录表单区
.login-right {
width: 480px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 60px;
background: #fff;
}
.login-form-wrapper {
width: 100%;
}
.form-title {
font-size: 28px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.form-subtitle {
font-size: 14px;
color: #909399;
margin-bottom: 40px;
}
.login-form {
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #dcdfe6 inset;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 0 0 1px #5e46f6 inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #5e46f6 inset;
}
}
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
letter-spacing: 4px;
border-radius: 8px;
background: linear-gradient(135deg, #7c6af8, #5e46f6);
border: none;
&:hover {
background: linear-gradient(135deg, #8d7cfa, #6f58f8);
}
}
.form-tip {
text-align: center;
margin-top: 16px;
font-size: 12px;
color: #c0c4cc;
}
</style>

View File

@ -0,0 +1,661 @@
<template>
<div class="banner-page">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-select
v-model="query.status"
placeholder="状态"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option label="显示" :value="STATUS.VISIBLE" />
<el-option label="隐藏" :value="STATUS.HIDDEN" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
<div class="filter-spacer" />
<el-button type="primary" :icon="Plus" @click="openCreate">新增轮播图</el-button>
</div>
</el-card>
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="list" row-key="id" stripe border style="width: 100%">
<el-table-column label="图片" width="180" align="center">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-src-list="[row.imageUrl]"
preview-teleported
fit="cover"
class="banner-thumb"
/>
<span v-else class="no-image">暂无图片</span>
</template>
</el-table-column>
<el-table-column label="跳转类型" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.linkType" :type="linkTypeTag(row.linkType).tag" effect="light">
{{ linkTypeTag(row.linkType).label }}
</el-tag>
<span v-else class="muted-text">未设置</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).tag" effect="light">
{{ statusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="170" align="center" />
<el-table-column label="排序" prop="sortOrder" width="130" align="center">
<template #default="{ row }">
<div class="sort-cell">
<el-input-number
v-model="row.sortOrder"
:min="0"
size="small"
@change="() => handleSortChange(row)"
/>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="230" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
link
:type="row.status === STATUS.VISIBLE ? 'warning' : 'success'"
@click="handleToggleStatus(row)"
>
{{ row.status === STATUS.VISIBLE ? '隐藏' : '显示' }}
</el-button>
<el-button link type="danger" @click="handleRemove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="fetchList"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
width="640px"
top="8vh"
:close-on-click-modal="false"
@open="handleDialogOpen"
>
<template #header>
<div class="dialog-title">
{{ editingId ? '编辑轮播图' : '新增轮播图' }}
<span class="dialog-subtitle">首页轮播 Banner 配置</span>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="轮播图图片" prop="imageFileId">
<el-upload
:show-file-list="false"
:before-upload="beforeImageUpload"
:http-request="handleImageUpload"
accept="image/*"
class="banner-uploader"
>
<div class="upload-box" v-if="!form.imageUrl">
<el-icon class="upload-icon"><Plus /></el-icon>
<span class="upload-text">点击上传轮播图</span>
<span class="upload-tip">支持 JPG/PNG,不超过 5MB</span>
</div>
<el-image
v-else
:src="form.imageUrl"
:preview-src-list="[form.imageUrl]"
preview-teleported
fit="cover"
class="upload-preview"
/>
</el-upload>
<el-button v-if="form.imageUrl" link type="danger" @click="clearImage">移除图片</el-button>
</el-form-item>
<el-form-item label="跳转类型" prop="linkType">
<el-select v-model="form.linkType" placeholder="请选择跳转类型" style="width: 100%">
<el-option label="CAAC报名" value="caac" />
<el-option label="无人机表演" value="performance" />
<el-option label="实力商家" value="merchant" />
<el-option label="本地飞手" value="pilot" />
<el-option label="研学课程" value="course" />
<el-option label="飞手推广" value="promote" />
<el-option label="自定义链接" value="custom" />
</el-select>
</el-form-item>
<el-form-item
v-if="form.linkType === 'custom'"
label="自定义链接"
prop="linkUrl"
>
<el-input
v-model="form.linkUrl"
placeholder="请输入自定义跳转 URL"
maxlength="500"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-input-number
v-model="form.sortOrder"
:min="0"
:max="9999"
controls-position="right"
/>
<span class="form-tip">值越小越靠前</span>
</el-form-item>
<el-form-item label="展示状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="STATUS.VISIBLE">显示</el-radio>
<el-radio :value="STATUS.HIDDEN">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保 存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import {
pageBanner,
createBanner,
updateBanner,
removeBanner,
updateBannerStatus,
updateBannerSort
} from '@/api/banner'
import request from '@/api/request'
// ==================== 业务常量(与后端 AppBannerConstants 对齐) ====================
/** 轮播图状态编码 */
const STATUS = {
HIDDEN: 0,
VISIBLE: 1
}
/** 状态 → 标签文案/颜色映射 */
const STATUS_META_MAP = {
[STATUS.HIDDEN]: { label: '隐藏', tag: 'info' },
[STATUS.VISIBLE]: { label: '显示', tag: 'success' }
}
/** 跳转类型 → 展示元信息 */
const LINK_TYPE_MAP = {
caac: { label: 'CAAC报名', tag: '' },
performance: { label: '无人机表演', tag: 'warning' },
merchant: { label: '实力商家', tag: 'success' },
pilot: { label: '本地飞手', tag: 'danger' },
course: { label: '研学课程', tag: 'warning' },
promote: { label: '飞手推广', tag: 'success' },
custom: { label: '自定义链接', tag: 'info' }
}
/** 默认表单值 */
const DEFAULT_FORM = {
imageFileId: null,
imageUrl: '',
linkType: 'caac',
linkUrl: '',
sortOrder: 0,
status: STATUS.VISIBLE
}
// ==================== 列表状态 ====================
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({
status: null,
page: 1,
pageSize: 10
})
/**
* 拉取轮播图分页列表
*/
async function fetchList() {
loading.value = true
try {
const res = await pageBanner({
status: query.status ?? undefined,
pageNo: query.page - 1,
pageSize: query.pageSize
})
list.value = res.data.list || []
total.value = Number(res.data.totalElements || 0)
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/** 查询:重置到第 1 页再拉取 */
function handleSearch() {
query.page = 1
fetchList()
}
/** 重置筛选条件 */
function handleReset() {
query.status = null
query.page = 1
fetchList()
}
/** 页大小变化:回到第 1 页 */
function handleSizeChange() {
query.page = 1
fetchList()
}
// ==================== 列表展示辅助 ====================
/** 状态标签元信息 */
function statusMeta(status) {
return STATUS_META_MAP[status] || { label: '未知', tag: 'info' }
}
/** 跳转类型标签元信息 */
function linkTypeTag(linkType) {
return LINK_TYPE_MAP[linkType] || { label: linkType, tag: 'info' }
}
// ==================== 图片上传 ====================
/** 上传前校验 */
function beforeImageUpload(file) {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
/** 自定义上传:使用 FormData 提交到 /api/file/upload */
async function handleImageUpload({ file }) {
try {
const formData = new FormData()
formData.append('file', file)
formData.append('fileType', 'BANNER_IMAGE')
const res = await request.post('/file/upload', formData)
if (res.code === 200 && res.data) {
form.imageFileId = res.data.fileId
form.imageUrl = res.data.fileUrl
ElMessage.success('图片上传成功')
} else {
ElMessage.error('上传失败')
}
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
/** 清空已上传图片 */
function clearImage() {
form.imageFileId = null
form.imageUrl = ''
}
// ==================== 新增/编辑弹窗 ====================
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({ ...DEFAULT_FORM })
/** 表单校验规则 */
const rules = {
imageFileId: [{ required: true, message: '请上传轮播图图片', trigger: 'change' }],
linkType: [{ required: true, message: '请选择跳转类型', trigger: 'change' }],
linkUrl: [
{
validator: (rule, value, callback) => {
if (form.linkType === 'custom' && !value) {
callback(new Error('自定义链接时 URL 不能为空'))
} else {
callback()
}
},
trigger: 'blur'
}
],
sortOrder: [{ required: true, message: '请输入排序值', trigger: 'blur' }],
status: [{ required: true, message: '请选择展示状态', trigger: 'change' }]
}
/** 新增轮播图:重置表单为默认值 */
function openCreate() {
editingId.value = null
Object.assign(form, { ...DEFAULT_FORM })
dialogVisible.value = true
}
/** 编辑轮播图:用行数据回填表单 */
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
imageFileId: row.imageFileId,
imageUrl: row.imageUrl || '',
linkType: row.linkType || '',
linkUrl: row.linkUrl || '',
sortOrder: row.sortOrder,
status: row.status
})
dialogVisible.value = true
}
/** 弹窗打开后清除残留校验状态 */
function handleDialogOpen() {
formRef.value?.clearValidate()
}
/** 保存轮播图(新增/编辑共用) */
async function handleSave() {
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const payload = {
imageFileId: form.imageFileId,
linkType: form.linkType || null,
linkUrl: form.linkUrl?.trim() || null,
sortOrder: form.sortOrder,
status: form.status
}
if (editingId.value) {
await updateBanner(editingId.value, payload)
ElMessage.success('编辑成功')
} else {
await createBanner(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
saving.value = false
}
}
// ==================== 状态切换与删除 ====================
/** 显示/隐藏切换 */
async function handleToggleStatus(row) {
const target = row.status === STATUS.VISIBLE ? STATUS.HIDDEN : STATUS.VISIBLE
try {
await updateBannerStatus(row.id, target)
ElMessage.success(target === STATUS.VISIBLE ? '已显示' : '已隐藏')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
/** 排序值调整(失焦触发) */
async function handleSortChange(row) {
if (row.sortOrder === undefined || row.sortOrder === null) return
try {
await updateBannerSort(row.id, row.sortOrder)
ElMessage.success('排序已更新')
} catch {
// 失败提示已由 axios 拦截器统一处理
fetchList()
}
}
/** 删除轮播图(软删除) */
async function handleRemove(row) {
try {
await ElMessageBox.confirm(
`确定删除这条轮播图(#${row.id})吗?删除后小程序端将不可见。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await removeBanner(row.id)
ElMessage.success('删除成功')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
// 初始拉取列表
fetchList()
</script>
<style lang="scss" scoped>
.banner-page {
display: flex;
flex-direction: column;
gap: 16px;
}
// ==================== 筛选栏 ====================
.filter-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
.filter-input {
width: 220px;
}
.filter-select {
width: 130px;
}
.filter-spacer {
flex: 1;
}
}
// ==================== 列表 ====================
.banner-thumb {
width: 120px;
height: 60px;
border-radius: 6px;
cursor: zoom-in;
object-fit: cover;
}
.no-image {
font-size: 12px;
color: #c0c4cc;
}
.subtitle-text {
color: #606266;
font-size: 13px;
}
.muted-text {
color: #c0c4cc;
font-size: 13px;
}
.sort-cell {
display: flex;
justify-content: center;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
// ==================== 上传组件 ====================
.banner-uploader {
:deep(.el-upload) {
border: none;
padding: 0;
display: inline-block;
}
:deep(.el-upload--picture-card) {
width: auto;
height: auto;
border: none;
line-height: normal;
background: transparent;
}
}
.upload-box {
width: 200px;
height: 200px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
box-sizing: border-box;
&:hover {
border-color: #5e46f6;
}
.upload-icon {
font-size: 32px;
color: #c0c4cc;
margin-bottom: 8px;
}
.upload-text {
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.upload-tip {
font-size: 11px;
color: #c0c4cc;
}
}
.upload-preview {
width: 200px;
height: 200px;
border-radius: 8px;
object-fit: cover;
cursor: zoom-in;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
// ==================== 表单弹窗 ====================
:deep(.el-dialog) {
border-radius: 14px;
box-shadow: 0 12px 40px rgba(30, 27, 75, 0.16);
}
:deep(.el-dialog__header) {
padding: 20px 24px 16px;
margin-right: 0;
border-bottom: 1px solid #f0f1f5;
}
:deep(.el-dialog__body) {
padding: 8px 24px 4px;
max-height: calc(94vh - 170px);
overflow-y: auto;
}
:deep(.el-dialog__footer) {
padding: 14px 24px;
border-top: 1px solid #f0f1f5;
background-color: #fafbfd;
border-radius: 0 0 14px 14px;
}
.dialog-title {
display: flex;
align-items: center;
font-size: 17px;
font-weight: 600;
color: #1d2129;
&::before {
content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: linear-gradient(180deg, #7c6af8, #5e46f6);
margin-right: 10px;
}
.dialog-subtitle {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: #a2a6ad;
}
}
</style>

View File

@ -0,0 +1,397 @@
<template>
<div class="config-page">
<!-- 消息推送卡片 -->
<el-card shadow="never" class="feature-card" v-loading="loading">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="card-icon" :size="20"><Bell /></el-icon>
<span class="card-title">消息推送</span>
</div>
<el-tag v-if="mpPushEnabled" type="success" effect="light" size="small">已开启</el-tag>
<el-tag v-else type="info" effect="light" size="small">已关闭</el-tag>
</div>
</template>
<div class="card-body">
<div class="feature-info">
<div class="info-title">微信公众号模板消息推送</div>
<div class="info-desc">
开启后,平台将通过微信公众号向已关注的用户推送任务提醒等模板消息。关闭则暂停全部模板消息发送。
</div>
</div>
<div class="feature-action">
<el-switch
v-model="mpPushEnabled"
:loading="saving"
active-text="开启推送"
inactive-text="关闭推送"
inline-prompt
@change="handleMpPushToggle"
/>
</div>
</div>
</el-card>
<!-- 积分规则卡片 -->
<el-card shadow="never" class="feature-card" v-loading="loading">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="card-icon" :size="20"><Coin /></el-icon>
<span class="card-title">积分规则</span>
</div>
<el-tag type="warning" effect="light" size="small">发布任务</el-tag>
</div>
</template>
<div class="points-rule-list">
<div class="points-rule-item">
<div class="feature-info">
<div class="info-title">发布任务获取积分数量</div>
<div class="info-desc">
用户每发布 1 个普通任务可获得的积分;设为 0 表示发布任务不奖励积分。
</div>
</div>
<div class="points-control">
<el-input-number
v-model="pointsForm.publishAward"
:min="POINTS_MIN"
:max="POINTS_MAX"
:step="1"
:step-strictly="true"
controls-position="right"
class="points-input"
/>
<span class="points-unit">积分 / 个</span>
</div>
</div>
<div class="points-rule-divider" />
<div class="points-rule-item">
<div class="feature-info">
<div class="info-title">发布任务扣减积分数量</div>
<div class="info-desc">
用户发布优质任务时需消耗的积分,余额不足将无法发布优质任务;设为 0 表示不扣减。
</div>
</div>
<div class="points-control">
<el-input-number
v-model="pointsForm.premiumDeduct"
:min="POINTS_MIN"
:max="POINTS_MAX"
:step="1"
:step-strictly="true"
controls-position="right"
class="points-input"
/>
<span class="points-unit">积分 / 个</span>
</div>
</div>
<div class="points-save-bar">
<el-button
type="primary"
:loading="savingPoints"
:disabled="!pointsDirty"
@click="handleSavePoints"
>
保存设置
</el-button>
<span v-if="!pointsDirty" class="save-hint">设置未发生变化</span>
</div>
</div>
</el-card>
<!-- 占位提示:未来更多功能 -->
<el-card shadow="never" class="feature-card placeholder-card">
<div class="placeholder-content">
<el-icon :size="32" class="placeholder-icon"><Plus /></el-icon>
<div class="placeholder-text">更多功能配置即将上线</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, Plus, Coin } from '@element-plus/icons-vue'
import { getMiniConfigList, updateMiniConfig } from '@/api/miniConfig'
// ==================== 业务常量(与后端 PointsConstants 对齐) ====================
/** 积分规则配置键(app_config.config_key)。 */
const CONFIG_KEY_POINTS_PUBLISH_AWARD = 'points_publish_award'
const CONFIG_KEY_POINTS_PREMIUM_DEDUCT = 'points_premium_deduct'
/** 积分数量输入边界(与后端校验区间一致)。 */
const POINTS_MIN = 0
const POINTS_MAX = 99999
const loading = ref(false)
const saving = ref(false)
const mpPushEnabled = ref(false) // false 关闭 / true 开启
// 积分规则表单与初始快照(快照用于脏检查,无变化时禁用保存)
const pointsForm = reactive({ publishAward: 0, premiumDeduct: 0 })
const pointsSnapshot = reactive({ publishAward: 0, premiumDeduct: 0 })
const savingPoints = ref(false)
const pointsDirty = computed(() =>
pointsForm.publishAward !== pointsSnapshot.publishAward
|| pointsForm.premiumDeduct !== pointsSnapshot.premiumDeduct
)
/**
* 拉取功能配置列表(消息推送开关 + 积分规则参数)
*/
async function fetchConfig() {
loading.value = true
try {
const res = await getMiniConfigList()
const list = res.data || []
const mpPush = list.find(c => c.configKey === 'mp_push_enabled')
if (mpPush) {
mpPushEnabled.value = mpPush.configValue === '1'
}
const award = list.find(c => c.configKey === CONFIG_KEY_POINTS_PUBLISH_AWARD)
const deduct = list.find(c => c.configKey === CONFIG_KEY_POINTS_PREMIUM_DEDUCT)
// 配置缺失或为非法值时回退 0,由保存动作写回合法值(正常情况下迁移脚本已插入默认值)
pointsForm.publishAward = parsePoints(award?.configValue)
pointsForm.premiumDeduct = parsePoints(deduct?.configValue)
pointsSnapshot.publishAward = pointsForm.publishAward
pointsSnapshot.premiumDeduct = pointsForm.premiumDeduct
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/**
* 配置值转非负整数,非法值兜底 0
*/
function parsePoints(value) {
const num = Number.parseInt(value, 10)
return Number.isFinite(num) && num >= POINTS_MIN ? num : 0
}
/**
* 消息推送开关切换:先二次确认再写库
*/
async function handleMpPushToggle(val) {
const actionText = val ? '开启' : '关闭'
try {
await ElMessageBox.confirm(
`确认${actionText}微信公众号模板消息推送吗?${val ? '开启后将向已关注用户发送模板消息。' : '关闭后平台将暂停所有模板消息发送。'}`,
`${actionText}消息推送`,
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
// 用户取消切换,恢复开关状态
mpPushEnabled.value = !val
return
}
saving.value = true
try {
await updateMiniConfig('mp_push_enabled', val ? '1' : '0')
ElMessage.success(`已${actionText}消息推送`)
await fetchConfig()
} catch {
mpPushEnabled.value = !val
} finally {
saving.value = false
}
}
/**
* 保存积分规则:仅提交发生变化的配置项,全部成功后刷新快照
*/
async function handleSavePoints() {
if (!pointsDirty.value) {
ElMessage.info('设置未发生变化')
return
}
const tasks = []
if (pointsForm.publishAward !== pointsSnapshot.publishAward) {
tasks.push(updateMiniConfig(
CONFIG_KEY_POINTS_PUBLISH_AWARD, String(pointsForm.publishAward)))
}
if (pointsForm.premiumDeduct !== pointsSnapshot.premiumDeduct) {
tasks.push(updateMiniConfig(
CONFIG_KEY_POINTS_PREMIUM_DEDUCT, String(pointsForm.premiumDeduct)))
}
savingPoints.value = true
try {
await Promise.all(tasks)
ElMessage.success('积分规则已保存')
await fetchConfig()
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
savingPoints.value = false
}
}
onMounted(fetchConfig)
</script>
<style lang="scss" scoped>
.config-page {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1100px;
}
.feature-card {
border-radius: 12px;
border: 1px solid #eef0f5;
:deep(.el-card__header) {
padding: 16px 24px;
border-bottom: 1px solid #f0f1f5;
}
:deep(.el-card__body) {
padding: 24px;
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
color: #5e46f6;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
}
.card-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.feature-info {
flex: 1;
.info-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 8px;
}
.info-desc {
font-size: 13px;
color: #606266;
line-height: 1.6;
}
}
.feature-action {
flex-shrink: 0;
}
// ==================== 积分规则卡片 ====================
.points-rule-list {
display: flex;
flex-direction: column;
}
.points-rule-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 4px 0;
}
.points-rule-divider {
height: 1px;
background-color: #f0f1f5;
margin: 20px 0;
}
.points-control {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.points-input {
width: 140px;
}
.points-unit {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
}
.points-save-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px dashed #e4e7ed;
.save-hint {
font-size: 12px;
color: #a2a6ad;
}
}
// ==================== 占位卡片 ====================
.placeholder-card {
border-style: dashed;
background: #fafbfd;
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 0;
color: #c0c4cc;
gap: 12px;
}
.placeholder-icon {
color: #dcdfe6;
}
.placeholder-text {
font-size: 13px;
}
}
</style>

7
src/views/mini/index.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<PlaceholderPage />
</template>
<script setup>
import PlaceholderPage from '@/views/PlaceholderPage.vue'
</script>

793
src/views/task/index.vue Normal file
View File

@ -0,0 +1,793 @@
<template>
<div class="task-page">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-input
v-model="query.keyword"
placeholder="任务编号 / 名称"
clearable
class="filter-input"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.type"
placeholder="任务类型"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option
v-for="item in TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="query.isPremium"
placeholder="任务类别"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option
v-for="item in PREMIUM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="query.status"
placeholder="任务状态"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</el-card>
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="list" row-key="id" stripe>
<el-table-column label="任务编号" prop="taskNo" width="150" show-overflow-tooltip />
<el-table-column label="任务名称" prop="name" min-width="180" show-overflow-tooltip />
<el-table-column label="任务类型" width="90" align="center">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.typeName }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务类别" width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="row.isPremium === IS_PREMIUM_YES"
type="warning"
effect="dark"
size="small"
>
优质任务
</el-tag>
<el-tag v-else effect="plain" size="small">普通任务</el-tag>
</template>
</el-table-column>
<el-table-column label="报酬" width="120" align="right">
<template #default="{ row }">
<span class="price">¥{{ formatPrice(row.reward) }}</span>
</template>
</el-table-column>
<el-table-column label="区域" prop="regionText" width="140" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).tag" effect="light">
{{ statusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布商家" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.merchant ? row.merchant.name : '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="170" />
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canCancel(row.status)"
link
type="danger"
@click="handleCancel(row)"
>
强制取消
</el-button>
<el-button
v-if="canDelete(row.status)"
link
type="danger"
@click="handleRemove(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="fetchList"
/>
</div>
</el-card>
<!-- 详情弹窗 -->
<el-dialog
v-model="detailVisible"
width="880px"
top="6vh"
:close-on-click-modal="true"
>
<template #header>
<div class="dialog-title">
任务详情
<span class="dialog-subtitle">全量任务信息与发布商家信息</span>
</div>
</template>
<div v-loading="detailLoading" class="detail-body">
<template v-if="detail">
<!-- 分组一:基本信息 -->
<div class="form-section">基本信息</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务名称" :span="2">
<span class="detail-text-bold">{{ detail.name }}</span>
</el-descriptions-item>
<el-descriptions-item label="任务编号">{{ detail.taskNo }}</el-descriptions-item>
<el-descriptions-item label="任务类型">{{ detail.typeName }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="statusMeta(detail.status).tag" effect="light" size="small">
{{ statusMeta(detail.status).label }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="任务类别">
<el-tag
v-if="detail.isPremium === IS_PREMIUM_YES"
type="warning"
effect="dark"
size="small"
>
优质任务
</el-tag>
<el-tag v-else effect="plain" size="small">普通任务</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 分组二:报酬与时间 -->
<div class="form-section">报酬与时间</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务报酬">
<span class="price">¥{{ formatPrice(detail.reward) }}</span>
</el-descriptions-item>
<el-descriptions-item label="需求人数">{{ detail.requiredCount }} 人</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatDateTime(detail.taskStartTime) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ formatDateTime(detail.taskEndTime) }}</el-descriptions-item>
<el-descriptions-item label="保险要求" :span="2">
<template v-if="detail.insuranceNames && detail.insuranceNames.length">
<el-tag
v-for="name in detail.insuranceNames"
:key="name"
size="small"
effect="plain"
class="insurance-tag"
>
{{ name }}
</el-tag>
</template>
<span v-else class="text-muted">无保险要求</span>
</el-descriptions-item>
</el-descriptions>
<!-- 分组三:位置信息 -->
<div class="form-section">位置信息</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="所在区域">{{ detail.regionText || '-' }}</el-descriptions-item>
<el-descriptions-item label="详细地址">{{ detail.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="纬度">{{ detail.latitude || '-' }}</el-descriptions-item>
<el-descriptions-item label="经度">{{ detail.longitude || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 分组四:联系方式 -->
<div class="form-section">联系方式</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="联系人">{{ detail.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.contactPhone || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 分组五:任务场景图 -->
<div class="form-section">任务场景图</div>
<div v-if="detail.sceneImageUrls && detail.sceneImageUrls.length" class="scene-gallery">
<el-image
v-for="(url, index) in detail.sceneImageUrls"
:key="index"
:src="url"
:preview-src-list="detail.sceneImageUrls"
:initial-index="index"
fit="cover"
preview-teleported
hide-on-click-modal
class="scene-image"
>
<template #error>
<div class="scene-image-error">加载失败</div>
</template>
</el-image>
</div>
<div v-else class="text-muted scene-empty">暂无场景图片</div>
<!-- 分组六:发布商家信息 -->
<div class="form-section">发布商家信息</div>
<template v-if="detail.merchant">
<el-descriptions :column="2" border>
<el-descriptions-item label="商家名称">
<span class="detail-text-bold">{{ detail.merchant.name }}</span>
</el-descriptions-item>
<el-descriptions-item label="认证状态">
<el-tag :type="certStatusMeta(detail.merchant.certStatus).tag" effect="light" size="small">
{{ detail.merchant.certStatusName }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="联系人">{{ detail.merchant.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.merchant.contactPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="商家地址" :span="2">{{ detail.merchant.address || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="door-image-wrap">
<div v-if="detail.merchant.doorImageUrl" class="door-image-row">
<span class="door-image-label">门头照片:</span>
<el-image
:src="detail.merchant.doorImageUrl"
:preview-src-list="[detail.merchant.doorImageUrl]"
fit="cover"
preview-teleported
hide-on-click-modal
class="door-image"
/>
</div>
<div v-if="detail.merchant.licenseImageUrl" class="door-image-row">
<span class="door-image-label">营业执照:</span>
<el-image
:src="detail.merchant.licenseImageUrl"
:preview-src-list="[detail.merchant.licenseImageUrl]"
fit="cover"
preview-teleported
hide-on-click-modal
class="door-image"
/>
</div>
</div>
</template>
<div v-else class="text-muted scene-empty">未绑定商家身份</div>
<!-- 分组七:备注 -->
<div v-if="detail.remark" class="form-section">备注</div>
<div v-if="detail.remark" class="remark-box">{{ detail.remark }}</div>
</template>
</div>
<template #footer>
<el-button
v-if="detail && canCancel(detail.status)"
type="danger"
@click="handleCancel(detail); detailVisible = false"
>
强制取消
</el-button>
<el-button @click="detailVisible = false">关 闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { pageTask, getTaskDetail, cancelTask, removeTask } from '@/api/task'
const route = useRoute()
// ==================== 业务常量(与后端 TaskConstants / TaskStatusEnum 对齐) ====================
/** 任务状态编码 */
const STATUS = {
DRAFT: 0,
PENDING: 1,
ACCEPTED: 2,
EXECUTING: 3,
DONE: 4,
CANCELLED: 5
}
/** 是否优质 */
const IS_PREMIUM_YES = 1
/** 任务状态 → 标签文案/颜色映射 */
const STATUS_META_MAP = {
[STATUS.DRAFT]: { label: '草稿', tag: 'info' },
[STATUS.PENDING]: { label: '待接单', tag: 'success' },
[STATUS.ACCEPTED]: { label: '已接单', tag: 'primary' },
[STATUS.EXECUTING]: { label: '执行中', tag: 'warning' },
[STATUS.DONE]: { label: '已完成', tag: 'info' },
[STATUS.CANCELLED]: { label: '已取消', tag: 'danger' }
}
/** 任务类型选项(与后端 TaskTypeEnum 对齐) */
const TYPE_OPTIONS = [
{ value: 1, label: '植保' },
{ value: 2, label: '吊运' },
{ value: 3, label: '航拍' },
{ value: 4, label: '巡检' },
{ value: 5, label: '清洗' },
{ value: 6, label: '表演' },
{ value: 7, label: '测绘' }
]
/** 任务类别选项(普通任务 / 优质任务,对应 isPremium 字段) */
const PREMIUM_OPTIONS = [
{ value: 0, label: '普通任务' },
{ value: 1, label: '优质任务' }
]
/** 任务状态选项 */
const STATUS_OPTIONS = [
{ value: STATUS.DRAFT, label: '草稿' },
{ value: STATUS.PENDING, label: '待接单' },
{ value: STATUS.ACCEPTED, label: '已接单' },
{ value: STATUS.EXECUTING, label: '执行中' },
{ value: STATUS.DONE, label: '已完成' },
{ value: STATUS.CANCELLED, label: '已取消' }
]
/** 认证状态 → 标签映射 */
const CERT_STATUS_META_MAP = {
0: { label: '未认证', tag: 'info' },
1: { label: '认证中', tag: 'warning' },
2: { label: '已认证', tag: 'success' },
3: { label: '认证失败', tag: 'danger' }
}
// ==================== 列表状态 ====================
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({
keyword: '',
type: null,
isPremium: null,
status: null,
page: 1,
pageSize: 10
})
/**
* 拉取任务分页列表
*/
async function fetchList() {
loading.value = true
try {
const res = await pageTask({
keyword: query.keyword || undefined,
type: query.type ?? undefined,
isPremium: query.isPremium ?? undefined,
status: query.status ?? undefined,
pageNo: query.page - 1,
pageSize: query.pageSize
})
list.value = res.data.list || []
total.value = Number(res.data.totalElements || 0)
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/** 查询:重置到第 1 页再拉取 */
function handleSearch() {
query.page = 1
fetchList()
}
/** 重置筛选条件 */
function handleReset() {
query.keyword = ''
query.type = null
query.isPremium = null
query.status = null
query.page = 1
fetchList()
}
/** 页大小变化:回到第 1 页 */
function handleSizeChange() {
query.page = 1
fetchList()
}
// ==================== 列表展示辅助 ====================
/**
* 状态标签元信息
*/
function statusMeta(status) {
return STATUS_META_MAP[status] || { label: '未知', tag: 'info' }
}
/**
* 认证状态标签元信息
*/
function certStatusMeta(certStatus) {
return CERT_STATUS_META_MAP[certStatus] || { label: '未知', tag: 'info' }
}
/**
* 报酬格式化:1388 → "1,388.00"
*/
function formatPrice(price) {
if (price === null || price === undefined || price === '') {
return '-'
}
return Number(price).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
/**
* 时间格式化:ISO 字串 → yyyy-MM-dd HH:mm
*/
function formatDateTime(dateTime) {
if (!dateTime) {
return '-'
}
const str = String(dateTime).replace('T', ' ')
return str.length > 16 ? str.slice(0, 16) : str
}
/**
* 是否可取消:仅待接单/已接单/执行中
*/
function canCancel(status) {
return status === STATUS.PENDING
|| status === STATUS.ACCEPTED
|| status === STATUS.EXECUTING
}
/**
* 是否可删除:仅草稿/已完成/已取消
*/
function canDelete(status) {
return status === STATUS.DRAFT
|| status === STATUS.DONE
|| status === STATUS.CANCELLED
}
// ==================== 详情弹窗 ====================
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
/**
* 打开详情弹窗,拉取详情数据
*/
async function openDetail(row) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
try {
const res = await getTaskDetail(row.id)
detail.value = res.data
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
detailLoading.value = false
}
}
// ==================== 取消与删除 ====================
/**
* 取消任务,取消前二次确认
*/
async function handleCancel(row) {
try {
await ElMessageBox.confirm(
`是否强制取消任务「${row.name}」?取消后飞手将无法再接单,该操作不可撤销。`,
'强制取消确认',
{ type: 'warning', confirmButtonText: '强制取消', cancelButtonText: '返回' }
)
} catch {
// 用户取消操作
return
}
try {
await cancelTask(row.id)
ElMessage.success('任务已取消')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
/**
* 软删除任务,删除前二次确认
*/
async function handleRemove(row) {
try {
await ElMessageBox.confirm(
`确定删除任务「${row.name}」吗?删除后不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
// 用户取消删除
return
}
try {
await removeTask(row.id)
ElMessage.success('删除成功')
fetchList()
} catch {
// 失败提示已由 axios 拦截器统一处理
}
}
// 初始拉取列表 + 路由 query.id 自动打开详情
onMounted(() => {
fetchList()
const taskId = route.query.id
if (taskId) {
// 构造一个最小 row 对象复用 openDetail 的详情加载逻辑
openDetail({ id: Number(taskId) })
}
})
</script>
<style lang="scss" scoped>
.task-page {
display: flex;
flex-direction: column;
gap: 16px;
}
// ==================== 筛选栏 ====================
.filter-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
.filter-input {
width: 240px;
}
.filter-select {
width: 130px;
}
}
// ==================== 列表 ====================
.price {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
color: #c0c4cc;
font-size: 13px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
// ==================== 详情弹窗 ====================
:deep(.el-dialog) {
border-radius: 14px;
box-shadow: 0 12px 40px rgba(30, 27, 75, 0.16);
}
:deep(.el-dialog__header) {
padding: 20px 24px 16px;
margin-right: 0;
border-bottom: 1px solid #f0f1f5;
}
:deep(.el-dialog__body) {
padding: 8px 24px 4px;
max-height: calc(88vh - 160px);
overflow-y: auto;
}
:deep(.el-dialog__footer) {
padding: 14px 24px;
border-top: 1px solid #f0f1f5;
background-color: #fafbfd;
border-radius: 0 0 14px 14px;
}
.dialog-title {
display: flex;
align-items: center;
font-size: 17px;
font-weight: 600;
color: #1d2129;
&::before {
content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: linear-gradient(180deg, #7c6af8, #5e46f6);
margin-right: 10px;
}
.dialog-subtitle {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: #a2a6ad;
}
}
// 分组标题:与弹窗标题同源的视觉语言
.form-section {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #303133;
padding-bottom: 10px;
margin: 8px 0 16px;
border-bottom: 1px dashed #e4e7ed;
&::before {
content: '';
width: 3px;
height: 13px;
border-radius: 2px;
background: linear-gradient(180deg, #7c6af8, #5e46f6);
margin-right: 8px;
}
&:not(:first-of-type) {
margin-top: 28px;
}
}
.detail-body {
min-height: 200px;
}
.detail-text-bold {
font-weight: 600;
color: #1d2129;
}
.insurance-tag {
margin-right: 6px;
margin-bottom: 4px;
}
// 场景图画廊
.scene-gallery {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.scene-image {
width: 140px;
height: 105px;
border-radius: 6px;
cursor: pointer;
border: 1px solid #e4e7ed;
}
.scene-image-error {
width: 140px;
height: 105px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
background-color: #f5f7fa;
}
.scene-empty {
padding: 12px 0;
}
// 商家门头照与营业执照(并排一行展示,文字与图片垂直居中)
.door-image-wrap {
display: flex;
align-items: flex-start;
gap: 32px;
margin-top: 16px;
.door-image-row {
display: flex;
align-items: center;
gap: 8px;
}
.door-image-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
.door-image {
width: 160px;
height: 100px;
border-radius: 6px;
cursor: pointer;
border: 1px solid #e4e7ed;
flex-shrink: 0;
}
}
// 备注文本框
.remark-box {
padding: 12px 16px;
background-color: #f5f7fa;
border-radius: 6px;
font-size: 14px;
line-height: 1.7;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
}
// descriptions 样式微调
:deep(.el-descriptions__label) {
width: 100px;
font-weight: 500;
color: #606266;
}
:deep(.el-descriptions__content) {
color: #303133;
}
</style>

1569
src/views/user/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,350 @@
<template>
<div class="wx-message-page">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-input
v-model="query.userId"
placeholder="接收人用户 ID"
clearable
class="filter-input"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="query.bizType"
placeholder="业务类型"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option
v-for="item in BIZ_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="query.sendStatus"
placeholder="发送状态"
clearable
class="filter-select"
@change="handleSearch"
>
<el-option
v-for="item in SEND_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="query.openId"
placeholder="接收人 openid"
clearable
class="filter-input filter-input-openid"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</el-card>
<!-- 列表 -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="list" row-key="id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-body">
<div class="expand-item">
<span class="expand-label">模板 ID:</span>
<span>{{ row.templateId }}</span>
</div>
<div v-if="row.skipReason" class="expand-item">
<span class="expand-label">跳过原因:</span>
<span>{{ row.skipReason }}</span>
</div>
<div v-if="row.errCode || row.errMsg" class="expand-item">
<span class="expand-label">错误信息:</span>
<span>{{ row.errCode || '-' }} {{ row.errMsg || '' }}</span>
</div>
<div v-if="row.resultStatus" class="expand-item">
<span class="expand-label">微信送达结果:</span>
<span>{{ row.resultStatus }}</span>
</div>
<div class="expand-item">
<span class="expand-label">模板参数:</span>
<pre class="expand-content">{{ formatContent(row.content) }}</pre>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="接收用户" prop="userId" width="100" align="center" />
<el-table-column label="业务类型" width="120" align="center">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.bizTypeName }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务单据" prop="bizId" width="100" align="center" />
<el-table-column label="发送状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="sendStatusMeta(row.sendStatus).tag" effect="light" size="small">
{{ row.sendStatusName }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="接收 openid" prop="openId" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ row.openId || '-' }}
</template>
</el-table-column>
<el-table-column label="微信 MsgID" prop="msgId" width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.msgId ?? '-' }}
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.sendTime) }}
</template>
</el-table-column>
<el-table-column label="送达回调时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.resultTime) }}
</template>
</el-table-column>
<el-table-column label="入库时间" prop="createdAt" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { pageWxMessageLog } from '@/api/wxMessage'
// ==================== 业务常量(与后端 WxMessageConstants 对齐) ====================
/** 发送状态编码 */
const SEND_STATUS = {
SKIPPED: 0,
SUBMITTED: 1,
DELIVERED: 2,
FAILED: 3
}
/** 业务类型选项 */
const BIZ_TYPE_OPTIONS = [
{ value: 1, label: '任务申请通知' }
]
/** 发送状态选项 */
const SEND_STATUS_OPTIONS = [
{ value: SEND_STATUS.SKIPPED, label: '已跳过' },
{ value: SEND_STATUS.SUBMITTED, label: '已提交' },
{ value: SEND_STATUS.DELIVERED, label: '送达成功' },
{ value: SEND_STATUS.FAILED, label: '送达失败' }
]
/** 发送状态 → 标签映射(成功绿 / 失败红 / 等待蓝 / 跳过灰) */
const SEND_STATUS_META_MAP = {
[SEND_STATUS.SKIPPED]: { label: '已跳过', tag: 'info' },
[SEND_STATUS.SUBMITTED]: { label: '已提交', tag: 'primary' },
[SEND_STATUS.DELIVERED]: { label: '送达成功', tag: 'success' },
[SEND_STATUS.FAILED]: { label: '送达失败', tag: 'danger' }
}
// ==================== 列表状态 ====================
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({
userId: '',
bizType: null,
sendStatus: null,
openId: '',
page: 1,
pageSize: 10
})
/**
* 拉取消息日志分页列表
*/
async function fetchList() {
loading.value = true
try {
const res = await pageWxMessageLog({
userId: query.userId || undefined,
bizType: query.bizType ?? undefined,
sendStatus: query.sendStatus ?? undefined,
openId: query.openId || undefined,
pageNo: query.page - 1,
pageSize: query.pageSize
})
list.value = res.data.list || []
total.value = Number(res.data.totalElements || 0)
} catch {
// 失败提示已由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/** 查询:重置到第 1 页再拉取 */
function handleSearch() {
query.page = 1
fetchList()
}
/** 重置筛选条件 */
function handleReset() {
query.userId = ''
query.bizType = null
query.sendStatus = null
query.openId = ''
query.page = 1
fetchList()
}
/** 页大小变化:回到第 1 页 */
function handleSizeChange() {
query.page = 1
fetchList()
}
// ==================== 展示辅助 ====================
/**
* 发送状态标签元信息
*/
function sendStatusMeta(sendStatus) {
return SEND_STATUS_META_MAP[sendStatus] || { label: '未知', tag: 'info' }
}
/**
* 时间格式化:ISO 字串 → yyyy-MM-dd HH:mm
*/
function formatDateTime(dateTime) {
if (!dateTime) {
return '-'
}
const str = String(dateTime).replace('T', ' ')
return str.length > 16 ? str.slice(0, 16) : str
}
/**
* 模板参数 JSON 美化输出,非法 JSON 原样返回
*/
function formatContent(content) {
if (!content) {
return '-'
}
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}
// 初始拉取列表
onMounted(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
.wx-message-page {
display: flex;
flex-direction: column;
gap: 16px;
}
// ==================== 筛选栏 ====================
.filter-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
.filter-input {
width: 170px;
}
.filter-input-openid {
width: 240px;
}
.filter-select {
width: 130px;
}
}
// ==================== 列表 ====================
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
// ==================== 行展开详情 ====================
.expand-body {
padding: 4px 16px 12px 56px;
}
.expand-item {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
font-size: 13px;
color: #303133;
line-height: 1.6;
}
.expand-label {
flex-shrink: 0;
color: #909399;
}
.expand-content {
margin: 0;
padding: 10px 14px;
background-color: #f5f7fa;
border-radius: 6px;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
}
</style>

39
vite.config.js Normal file
View File

@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
// Vite 构建配置
// docs: https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
// 路径别名 @ -> src
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 5173,
// 开发代理:前端请求转发到后端 Spring Boot(port 8081, context-path /laop)
proxy: {
// 后端静态资源(图片、文件):前端浏览器直接访问 /static/xxx,代理到后端 /laop/static/xxx
'/static': {
target: 'http://localhost:8081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/static/, '/laop/static')
},
// 文件上传接口:保留 /api 前缀不变(后端实际是 /api/file/upload)
'/api/file': {
target: 'http://localhost:8081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/laop/api')
},
// Web 管理端其余接口:/api/* → /laop/*
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/laop')
}
}
}
})