初始化代码
This commit is contained in:
commit
5f047b085c
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
4
.env.development
Normal 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
6
.env.production
Normal 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
33
.gitignore
vendored
Normal 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
60
Dockerfile
Normal 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
|
||||
29
docker-entrypoint.d/30-init-static.sh
Normal file
29
docker-entrypoint.d/30-init-static.sh
Normal 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
12
index.html
Normal 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
109
nginx.conf
Normal 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
2796
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
10
src/App.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 根组件
|
||||
* 仅作为路由出口,不承载业务逻辑
|
||||
*/
|
||||
</script>
|
||||
60
src/api/announcement.js
Normal file
60
src/api/announcement.js
Normal 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
69
src/api/banner.js
Normal 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
105
src/api/course.js
Normal 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
25
src/api/dashboard.js
Normal 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
87
src/api/exam.js
Normal 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
23
src/api/miniConfig.js
Normal 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
54
src/api/request.js
Normal 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
41
src/api/task.js
Normal 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
74
src/api/user.js
Normal 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
16
src/api/wxMessage.js
Normal 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
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
BIN
src/assets/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
src/assets/logo.png
Normal file
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
10
src/assets/logo.svg
Normal 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
439
src/layouts/MainLayout.vue
Normal 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
24
src/main.js
Normal 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
127
src/router/index.js
Normal 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
60
src/stores/user.js
Normal 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
65
src/styles/global.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/views/PlaceholderPage.vue
Normal file
48
src/views/PlaceholderPage.vue
Normal 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>
|
||||
461
src/views/announcement/index.vue
Normal file
461
src/views/announcement/index.vue
Normal 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
1176
src/views/course/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
935
src/views/dashboard/index.vue
Normal file
935
src/views/dashboard/index.vue
Normal 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
49
src/views/error/404.vue
Normal 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
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
241
src/views/login/index.vue
Normal 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>
|
||||
661
src/views/mini/banner/index.vue
Normal file
661
src/views/mini/banner/index.vue
Normal 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>
|
||||
397
src/views/mini/config/index.vue
Normal file
397
src/views/mini/config/index.vue
Normal 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
7
src/views/mini/index.vue
Normal 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
793
src/views/task/index.vue
Normal 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
1569
src/views/user/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
350
src/views/wxMessage/index.vue
Normal file
350
src/views/wxMessage/index.vue
Normal 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
39
vite.config.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user