初始提交:企业级日报系统完整代码
功能特性: - JWT用户认证系统 - 日报CRUD管理 - 三级权限控制 - 多维度搜索过滤 - 统计分析功能 - 评论互动系统 - 响应式Cool Admin界面 - 暗色主题支持 技术栈: - 后端:Django 4.2.7 + DRF + SimpleJWT - 前端:Vue 3 + Element Plus + Pinia - 数据库:SQLite/PostgreSQL - 部署:Docker + Nginx 包含内容: - 完整的后端API代码 - 现代化前端界面 - 数据库迁移文件 - 部署脚本和文档 - 演示页面和测试工具
This commit is contained in:
36
frontend/Dockerfile
Normal file
36
frontend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# 构建阶段
|
||||
FROM node:18-alpine as build-stage
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM nginx:alpine as production-stage
|
||||
|
||||
# 复制构建结果到nginx
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制nginx配置
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
# 启动nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
157
frontend/deploy.js
Normal file
157
frontend/deploy.js
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 前端部署脚本 - 自动化部署Vue应用
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ANSI颜色代码
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function runCommand(command, description) {
|
||||
log(`\n${'='.repeat(50)}`, 'cyan');
|
||||
log(`执行: ${description}`, 'cyan');
|
||||
log(`命令: ${command}`, 'cyan');
|
||||
log(`${'='.repeat(50)}`, 'cyan');
|
||||
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
log(`✅ ${description} - 成功`, 'green');
|
||||
return true;
|
||||
} catch (error) {
|
||||
log(`❌ ${description} - 失败`, 'red');
|
||||
log(`错误: ${error.message}`, 'red');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkFile(filePath, description) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
log(`✅ ${description} - 存在`, 'green');
|
||||
return true;
|
||||
} else {
|
||||
log(`❌ ${description} - 不存在`, 'red');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deploy() {
|
||||
log('🚀 开始部署企业级日报系统前端...', 'magenta');
|
||||
|
||||
// 1. 检查Node.js版本
|
||||
if (!runCommand('node --version', '检查Node.js版本')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查npm版本
|
||||
if (!runCommand('npm --version', '检查npm版本')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查package.json
|
||||
if (!checkFile('package.json', '检查package.json文件')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 安装依赖
|
||||
log('\n📦 安装项目依赖...', 'yellow');
|
||||
if (!runCommand('npm install', '安装npm依赖')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 代码检查
|
||||
log('\n🔍 执行代码检查...', 'yellow');
|
||||
if (!runCommand('npm run lint', '代码检查和格式化')) {
|
||||
log('⚠️ 代码检查失败,但继续部署...', 'yellow');
|
||||
}
|
||||
|
||||
// 6. 构建生产版本
|
||||
log('\n🏗️ 构建生产版本...', 'yellow');
|
||||
if (!runCommand('npm run build', '构建生产版本')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 7. 检查构建结果
|
||||
if (!checkFile('dist', '检查构建输出目录')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 8. 显示构建统计
|
||||
log('\n📊 构建统计信息:', 'cyan');
|
||||
try {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
const files = fs.readdirSync(distPath, { withFileTypes: true });
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.isFile()) {
|
||||
const filePath = path.join(distPath, file.name);
|
||||
const stats = fs.statSync(filePath);
|
||||
const size = (stats.size / 1024).toFixed(2);
|
||||
log(` - ${file.name}: ${size} KB`, 'blue');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log('无法读取构建统计信息', 'yellow');
|
||||
}
|
||||
|
||||
log('\n🎉 前端部署完成!', 'green');
|
||||
log('\n📋 部署信息:', 'cyan');
|
||||
log('- 构建输出: ./dist/', 'cyan');
|
||||
log('- 开发服务器: npm run serve', 'cyan');
|
||||
log('- 生产构建: npm run build', 'cyan');
|
||||
log('- 代码检查: npm run lint', 'cyan');
|
||||
|
||||
log('\n🌐 部署到服务器:', 'cyan');
|
||||
log('1. 将 dist/ 目录上传到Web服务器', 'cyan');
|
||||
log('2. 配置Nginx反向代理到后端API', 'cyan');
|
||||
log('3. 确保API地址配置正确', 'cyan');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
const success = deploy();
|
||||
process.exit(success ? 0 : 1);
|
||||
} catch (error) {
|
||||
log(`\n\n❌ 部署失败: ${error.message}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理中断信号
|
||||
process.on('SIGINT', () => {
|
||||
log('\n\n⚠️ 部署被用户中断', 'yellow');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log('\n\n⚠️ 部署被系统终止', 'yellow');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 运行主函数
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { deploy };
|
139
frontend/nginx.conf
Normal file
139
frontend/nginx.conf
Normal file
@@ -0,0 +1,139 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# 性能优化
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 20M;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# HTML文件不缓存
|
||||
location ~* \.html$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# API代理到后端
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# 管理后台代理
|
||||
location /admin/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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;
|
||||
}
|
||||
|
||||
# 静态文件代理
|
||||
location /static/ {
|
||||
alias /var/www/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# 媒体文件代理
|
||||
location /media/ {
|
||||
alias /var/www/media/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# SPA路由支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 安全配置
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ ~$ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
12765
frontend/package-lock.json
generated
Normal file
12765
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "daily-report-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "企业级日报系统前端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"dev": "vue-cli-service serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"pinia": "^2.1.6",
|
||||
"axios": "^1.5.0",
|
||||
"element-plus": "^2.3.12",
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"nprogress": "^0.2.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/eslint-config-standard": "^8.0.1",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-import": "^2.28.0",
|
||||
"eslint-plugin-n": "^16.0.1",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"sass": "^1.64.1",
|
||||
"sass-loader": "^13.3.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead",
|
||||
"not ie 11"
|
||||
]
|
||||
}
|
18
frontend/public/index.html
Normal file
18
frontend/public/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>企业级日报系统</title>
|
||||
<meta name="description" content="基于Cool Admin的企业级日报管理系统">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>很抱歉,本系统需要启用JavaScript才能正常运行。请启用JavaScript后重新访问。</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
51
frontend/src/App.vue
Normal file
51
frontend/src/App.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 全局滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(144, 147, 153, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(144, 147, 153, 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// Element Plus 样式调整
|
||||
.el-message {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.el-loading-mask {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.dark .el-loading-mask {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
</style>
|
59
frontend/src/layout/components/AppMain.vue
Normal file
59
frontend/src/layout/components/AppMain.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<keep-alive :include="cachedViews">
|
||||
<router-view :key="key" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 缓存的视图
|
||||
const cachedViews = computed(() => appStore.cachedViews)
|
||||
|
||||
// 路由key,用于强制刷新组件
|
||||
const key = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.app-main {
|
||||
min-height: calc(100vh - #{$header-height} - #{$tags-height});
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.app-main {
|
||||
background-color: $bg-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面切换动画
|
||||
.fade-transform-leave-active,
|
||||
.fade-transform-enter-active {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
129
frontend/src/layout/components/Breadcrumb.vue
Normal file
129
frontend/src/layout/components/Breadcrumb.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||
<span
|
||||
v-if="item.redirect === 'noRedirect' || index === levelList.length - 1"
|
||||
class="no-redirect"
|
||||
>
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const levelList = ref([])
|
||||
|
||||
// 获取面包屑列表
|
||||
const getBreadcrumb = () => {
|
||||
// 过滤掉没有meta.title的路由
|
||||
let matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
|
||||
// 获取第一个元素
|
||||
const first = matched[0]
|
||||
|
||||
// 如果第一个元素不是dashboard,则添加dashboard
|
||||
if (!isDashboard(first)) {
|
||||
matched = [{ path: '/dashboard', meta: { title: '工作台' } }].concat(matched)
|
||||
}
|
||||
|
||||
levelList.value = matched.filter(item => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||
})
|
||||
}
|
||||
|
||||
// 判断是否为首页
|
||||
const isDashboard = (route) => {
|
||||
const name = route && route.name
|
||||
if (!name) {
|
||||
return false
|
||||
}
|
||||
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
// 处理链接点击
|
||||
const handleLink = (item) => {
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(route, getBreadcrumb, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: $header-height;
|
||||
margin-left: 8px;
|
||||
|
||||
.no-redirect {
|
||||
color: $text-color-secondary;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $text-color-regular;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
.no-redirect {
|
||||
color: $text-color-secondary-dark;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $text-color-regular-dark;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 面包屑动画
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from,
|
||||
.breadcrumb-leave-active {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
290
frontend/src/layout/components/Navbar.vue
Normal file
290
frontend/src/layout/components/Navbar.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<!-- 左侧 -->
|
||||
<div class="navbar-left">
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="hamburger-container" @click="toggleSideBar">
|
||||
<el-icon class="hamburger" :class="{ 'is-active': appStore.sidebarOpened }">
|
||||
<Fold v-if="appStore.sidebarOpened" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<breadcrumb class="breadcrumb-container" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧 -->
|
||||
<div class="navbar-right">
|
||||
<!-- 主题切换 -->
|
||||
<div class="right-menu-item" @click="toggleTheme">
|
||||
<el-icon>
|
||||
<Sunny v-if="appStore.isDark" />
|
||||
<Moon v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 全屏 -->
|
||||
<div class="right-menu-item" @click="toggleFullscreen">
|
||||
<el-icon>
|
||||
<FullScreen />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<el-dropdown class="avatar-container" trigger="click">
|
||||
<div class="avatar-wrapper">
|
||||
<img
|
||||
v-if="authStore.user?.avatar"
|
||||
:src="authStore.user.avatar"
|
||||
class="user-avatar"
|
||||
alt="avatar"
|
||||
>
|
||||
<el-avatar v-else class="user-avatar" :size="32">
|
||||
{{ authStore.userName.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span class="user-name">{{ authStore.userName }}</span>
|
||||
<el-icon class="el-icon-caret-bottom">
|
||||
<CaretBottom />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<router-link to="/profile">
|
||||
<el-dropdown-item>
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
</router-link>
|
||||
|
||||
<el-dropdown-item divided @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Fold,
|
||||
Expand,
|
||||
Sunny,
|
||||
Moon,
|
||||
FullScreen,
|
||||
CaretBottom,
|
||||
User,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSideBar = () => {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
appStore.toggleTheme()
|
||||
ElMessage.success(`已切换到${appStore.isDark ? '暗色' : '亮色'}主题`)
|
||||
}
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await authStore.logout()
|
||||
ElMessage.success('退出登录成功')
|
||||
await router.push('/login')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('退出登录失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.navbar {
|
||||
height: $header-height;
|
||||
background: $header-bg;
|
||||
border-bottom: 1px solid $header-border-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hamburger-container {
|
||||
line-height: $header-height;
|
||||
height: $header-height;
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
padding: 0 15px;
|
||||
margin-left: -15px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #5a5e66;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.right-menu-item {
|
||||
display: inline-block;
|
||||
padding: 0 12px;
|
||||
height: $header-height;
|
||||
line-height: $header-height;
|
||||
color: #5a5e66;
|
||||
vertical-align: text-bottom;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
margin-left: 10px;
|
||||
|
||||
.avatar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 5px 0;
|
||||
|
||||
.user-avatar {
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
|
||||
.el-icon-caret-bottom {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
color: #5a5e66;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.navbar {
|
||||
background: $header-bg-dark;
|
||||
border-color: $border-color-dark;
|
||||
|
||||
.hamburger-container {
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
|
||||
.right-menu-item {
|
||||
color: #cfd3dc;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
.avatar-wrapper {
|
||||
.user-name {
|
||||
color: $text-color-primary-dark;
|
||||
}
|
||||
|
||||
.el-icon-caret-bottom {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media screen and (max-width: $sm) {
|
||||
.navbar {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
.avatar-wrapper {
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
108
frontend/src/layout/components/ScrollPane.vue
Normal file
108
frontend/src/layout/components/ScrollPane.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="scroll-container"
|
||||
@wheel.prevent="handleScroll"
|
||||
>
|
||||
<div ref="scrollWrapper" class="scroll-wrapper">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const emit = defineEmits(['scroll'])
|
||||
|
||||
const scrollContainer = ref()
|
||||
const scrollWrapper = ref()
|
||||
|
||||
let scrollLeft = 0
|
||||
let scrollTop = 0
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const $container = scrollContainer.value
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $wrapper = scrollWrapper.value
|
||||
const $wrapperWidth = $wrapper.offsetWidth
|
||||
|
||||
if (eventDelta > 0) {
|
||||
scrollLeft = Math.max(0, scrollLeft - 50)
|
||||
} else {
|
||||
if ($containerWidth - 50 < $wrapperWidth) {
|
||||
if (scrollLeft < $wrapperWidth - $containerWidth + 50) {
|
||||
scrollLeft += 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$wrapper.style.left = scrollLeft * -1 + 'px'
|
||||
emit('scroll')
|
||||
}
|
||||
|
||||
const moveToTarget = (currentTag) => {
|
||||
const $container = scrollContainer.value
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $wrapper = scrollWrapper.value
|
||||
|
||||
if (!currentTag) {
|
||||
return
|
||||
}
|
||||
|
||||
const tagList = $wrapper.querySelectorAll('.tags-view-item')
|
||||
|
||||
let firstTag = null
|
||||
let lastTag = null
|
||||
|
||||
if (tagList.length > 0) {
|
||||
firstTag = tagList[0]
|
||||
lastTag = tagList[tagList.length - 1]
|
||||
}
|
||||
|
||||
if (firstTag === currentTag) {
|
||||
scrollLeft = 0
|
||||
} else if (lastTag === currentTag) {
|
||||
scrollLeft = $wrapper.offsetWidth - $containerWidth
|
||||
} else {
|
||||
const tagListArray = [...tagList]
|
||||
const currentIndex = tagListArray.findIndex(item => item === currentTag)
|
||||
const prevTag = tagListArray[currentIndex - 1]
|
||||
const nextTag = tagListArray[currentIndex + 1]
|
||||
|
||||
const afterNextTagOffsetLeft = nextTag ? nextTag.offsetLeft + nextTag.offsetWidth + 4 : 0
|
||||
|
||||
const beforePrevTagOffsetLeft = prevTag ? prevTag.offsetLeft - 4 : 0
|
||||
|
||||
if (afterNextTagOffsetLeft > scrollLeft + $containerWidth) {
|
||||
scrollLeft = afterNextTagOffsetLeft - $containerWidth
|
||||
} else if (beforePrevTagOffsetLeft < scrollLeft) {
|
||||
scrollLeft = beforePrevTagOffsetLeft
|
||||
}
|
||||
}
|
||||
|
||||
$wrapper.style.left = scrollLeft * -1 + 'px'
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
moveToTarget
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
.scroll-wrapper {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
transition: left 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
37
frontend/src/layout/components/Sidebar/Link.vue
Normal file
37
frontend/src/layout/components/Sidebar/Link.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<component :is="linkProps.is" v-bind="linkProps.props">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { isExternal } from '@/utils/validate'
|
||||
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const linkProps = computed(() => {
|
||||
if (isExternal(props.to)) {
|
||||
return {
|
||||
is: 'a',
|
||||
props: {
|
||||
href: props.to,
|
||||
target: '_blank',
|
||||
rel: 'noopener'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is: 'router-link',
|
||||
props: {
|
||||
to: props.to
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
141
frontend/src/layout/components/Sidebar/SidebarItem.vue
Normal file
141
frontend/src/layout/components/Sidebar/SidebarItem.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<!-- 单个菜单项 -->
|
||||
<template v-if="!item.children || (item.children && item.children.length === 1 && !item.children[0].children)">
|
||||
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
|
||||
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
|
||||
<el-icon v-if="onlyOneChild.meta.icon">
|
||||
<component :is="onlyOneChild.meta.icon" />
|
||||
</el-icon>
|
||||
<template #title>
|
||||
<span>{{ onlyOneChild.meta.title }}</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</app-link>
|
||||
</template>
|
||||
|
||||
<!-- 有子菜单的菜单项 -->
|
||||
<el-submenu v-else :index="resolvePath(item.path)" popper-append-to-body>
|
||||
<template #title>
|
||||
<el-icon v-if="item.meta && item.meta.icon">
|
||||
<component :is="item.meta.icon" />
|
||||
</el-icon>
|
||||
<span>{{ item.meta?.title }}</span>
|
||||
</template>
|
||||
|
||||
<sidebar-item
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:is-nest="true"
|
||||
:item="child"
|
||||
:base-path="resolvePath(child.path)"
|
||||
/>
|
||||
</el-submenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { isExternal } from '@/utils/validate'
|
||||
import AppLink from './Link.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isNest: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 只有一个子项时的处理
|
||||
const onlyOneChild = computed(() => {
|
||||
const { children, ...item } = props.item
|
||||
|
||||
if (children && children.length === 1 && !children[0].children) {
|
||||
return children[0]
|
||||
}
|
||||
|
||||
// 如果没有子项,返回自身
|
||||
if (!children || children.length === 0) {
|
||||
return { ...item, path: '' }
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// 解析路径
|
||||
function resolvePath(routePath) {
|
||||
if (isExternal(routePath)) {
|
||||
return routePath
|
||||
}
|
||||
|
||||
if (isExternal(props.basePath)) {
|
||||
return props.basePath
|
||||
}
|
||||
|
||||
return path.resolve(props.basePath, routePath)
|
||||
}
|
||||
|
||||
// 简单的路径解析函数
|
||||
const path = {
|
||||
resolve(...paths) {
|
||||
let resolvedPath = ''
|
||||
let resolvedAbsolute = false
|
||||
|
||||
for (let i = paths.length - 1; i >= 0 && !resolvedAbsolute; i--) {
|
||||
const path = paths[i]
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('Arguments to path.resolve must be strings')
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedPath = path + '/' + resolvedPath
|
||||
resolvedAbsolute = path.charAt(0) === '/'
|
||||
}
|
||||
|
||||
resolvedPath = normalizeArray(resolvedPath.split('/').filter(p => !!p), !resolvedAbsolute).join('/')
|
||||
|
||||
return (resolvedAbsolute ? '/' : '') + resolvedPath || '.'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArray(parts, allowAboveRoot) {
|
||||
const res = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]
|
||||
|
||||
if (!p || p === '.') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (p === '..') {
|
||||
if (res.length && res[res.length - 1] !== '..') {
|
||||
res.pop()
|
||||
} else if (allowAboveRoot) {
|
||||
res.push('..')
|
||||
}
|
||||
} else {
|
||||
res.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-menu-item.submenu-title-noDropdown {
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
212
frontend/src/layout/components/Sidebar/index.vue
Normal file
212
frontend/src/layout/components/Sidebar/index.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="sidebar-wrapper">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<router-link to="/" class="logo-link">
|
||||
<img v-if="!appStore.sidebarOpened" src="/favicon.ico" alt="Logo" class="logo-mini">
|
||||
<div v-else class="logo-full">
|
||||
<img src="/favicon.ico" alt="Logo" class="logo-icon">
|
||||
<span class="logo-text">日报系统</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<el-scrollbar class="sidebar-menu-container">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="!appStore.sidebarOpened"
|
||||
:unique-opened="true"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
:background-color="variables.sidebarBg"
|
||||
:text-color="variables.sidebarTextColor"
|
||||
:active-text-color="variables.sidebarActiveColor"
|
||||
>
|
||||
<sidebar-item
|
||||
v-for="route in permissionRoutes"
|
||||
:key="route.path"
|
||||
:item="route"
|
||||
:base-path="route.path"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authRoutes } from '@/router'
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
import variables from '@/styles/variables.scss'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
// 根据权限过滤路由
|
||||
const permissionRoutes = computed(() => {
|
||||
return filterRoutes(authRoutes)
|
||||
})
|
||||
|
||||
// 过滤路由
|
||||
function filterRoutes(routes) {
|
||||
const res = []
|
||||
|
||||
routes.forEach(route => {
|
||||
const tmp = { ...route }
|
||||
|
||||
// 检查路由权限
|
||||
if (hasPermission(tmp)) {
|
||||
// 如果有子路由,递归过滤
|
||||
if (tmp.children) {
|
||||
tmp.children = filterRoutes(tmp.children)
|
||||
}
|
||||
|
||||
// 只有当路由本身有权限或者有可访问的子路由时才添加
|
||||
if (!tmp.meta?.hideInMenu && (tmp.children?.length > 0 || !tmp.children)) {
|
||||
res.push(tmp)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
function hasPermission(route) {
|
||||
if (route.meta?.permission) {
|
||||
return authStore.hasPermission(route.meta.permission)
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.sidebar-wrapper {
|
||||
height: 100%;
|
||||
background-color: $sidebar-bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: $header-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $sidebar-bg;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: $sidebar-active-color;
|
||||
|
||||
.logo-mini {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-full {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $sidebar-active-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu-container {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-scrollbar__view) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.sidebar-wrapper {
|
||||
background-color: $sidebar-bg-dark;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
background-color: $sidebar-bg-dark;
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单样式调整
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
|
||||
.el-menu-item {
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(64, 158, 255, 0.2) !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-submenu {
|
||||
.el-submenu__title {
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收起状态下的样式
|
||||
&.el-menu--collapse {
|
||||
.el-submenu {
|
||||
.el-submenu__title {
|
||||
span {
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
313
frontend/src/layout/components/TagsView.vue
Normal file
313
frontend/src/layout/components/TagsView.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
:key="tag.path"
|
||||
:class="isActive(tag) ? 'active' : ''"
|
||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
||||
class="tags-view-item"
|
||||
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent="openMenu(tag, $event)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
<el-icon
|
||||
v-if="!isAffix(tag)"
|
||||
class="el-icon-close"
|
||||
@click.prevent.stop="closeSelectedTag(tag)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</scroll-pane>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">刷新</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
|
||||
<li @click="closeOthersTags">关闭其他</li>
|
||||
<li @click="closeAllTags(selectedTag)">关闭所有</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import ScrollPane from './ScrollPane.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const scrollPaneRef = ref()
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref({})
|
||||
const affixTags = ref([])
|
||||
|
||||
// 计算属性
|
||||
const visitedViews = computed(() => appStore.visitedViews)
|
||||
|
||||
// 判断标签是否激活
|
||||
const isActive = (tag) => {
|
||||
return tag.path === route.path
|
||||
}
|
||||
|
||||
// 判断是否为固定标签
|
||||
const isAffix = (tag) => {
|
||||
return tag.meta && tag.meta.affix
|
||||
}
|
||||
|
||||
// 添加标签
|
||||
const addTags = () => {
|
||||
const { name } = route
|
||||
if (name) {
|
||||
appStore.addVisitedView(route)
|
||||
appStore.addCachedView(route)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 关闭选中的标签
|
||||
const closeSelectedTag = (view) => {
|
||||
appStore.delVisitedView(view)
|
||||
appStore.delCachedView(view)
|
||||
if (isActive(view)) {
|
||||
toLastView(appStore.visitedViews, view)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersTags = () => {
|
||||
router.push(selectedTag.value)
|
||||
appStore.delOthersVisitedViews(selectedTag.value)
|
||||
appStore.delOthersCachedViews(selectedTag.value)
|
||||
}
|
||||
|
||||
// 关闭所有标签
|
||||
const closeAllTags = (view) => {
|
||||
appStore.delAllVisitedViews()
|
||||
appStore.delAllCachedViews()
|
||||
if (affixTags.value.some(tag => tag.path === view.path)) {
|
||||
return
|
||||
}
|
||||
toLastView(appStore.visitedViews, view)
|
||||
}
|
||||
|
||||
// 刷新选中的标签
|
||||
const refreshSelectedTag = (view) => {
|
||||
appStore.delCachedView(view)
|
||||
const { fullPath } = view
|
||||
nextTick(() => {
|
||||
router.replace({
|
||||
path: '/redirect' + fullPath
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转到最后一个标签
|
||||
const toLastView = (visitedViews, view) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView.fullPath)
|
||||
} else {
|
||||
// 如果TagsView全部被关闭了,则重定向到主页
|
||||
if (view.name === 'Dashboard') {
|
||||
// 重新加载页面,避免出现bug
|
||||
router.replace({ path: '/redirect' + view.fullPath })
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开右键菜单
|
||||
const openMenu = (tag, e) => {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = scrollPaneRef.value.$el.getBoundingClientRect().left
|
||||
const offsetWidth = scrollPaneRef.value.$el.offsetWidth
|
||||
const maxLeft = offsetWidth - menuMinWidth
|
||||
const left_ = e.clientX - offsetLeft + 15
|
||||
|
||||
if (left_ > maxLeft) {
|
||||
left.value = maxLeft
|
||||
} else {
|
||||
left.value = left_
|
||||
}
|
||||
|
||||
top.value = e.clientY
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
const closeMenu = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 处理滚动
|
||||
const handleScroll = () => {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(route, () => {
|
||||
addTags()
|
||||
})
|
||||
|
||||
// 监听点击事件,关闭右键菜单
|
||||
watch(visible, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', closeMenu)
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化
|
||||
addTags()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.tags-view-container {
|
||||
height: $tags-height;
|
||||
width: 100%;
|
||||
background: $tags-bg;
|
||||
border-bottom: 1px solid $border-color;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 2px 0 rgba(0, 0, 0, 0.24);
|
||||
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $primary-color;
|
||||
color: #fff;
|
||||
border-color: $primary-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-icon-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
transform-origin: 100% 50%;
|
||||
|
||||
&:before {
|
||||
transform: scale(0.6);
|
||||
display: inline-block;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextmenu {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
z-index: 3000;
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.tags-view-container {
|
||||
background: $tags-bg-dark;
|
||||
border-color: $border-color-dark;
|
||||
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
color: #cfd3dc;
|
||||
background: #1d1e1f;
|
||||
border-color: $border-color-dark;
|
||||
|
||||
&.active {
|
||||
background-color: $primary-color;
|
||||
color: #fff;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
.el-icon-close {
|
||||
&:hover {
|
||||
background-color: #4c4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextmenu {
|
||||
background: #1d1e1f;
|
||||
color: #cfd3dc;
|
||||
|
||||
li {
|
||||
&:hover {
|
||||
background: #4c4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
149
frontend/src/layout/index.vue
Normal file
149
frontend/src/layout/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="app-wrapper" :class="classObj">
|
||||
<!-- 侧边栏 -->
|
||||
<sidebar class="sidebar-container" />
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="main-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<navbar />
|
||||
|
||||
<!-- 标签页 -->
|
||||
<tags-view v-if="needTagsView" />
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<app-main />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Sidebar from './components/Sidebar/index.vue'
|
||||
import Navbar from './components/Navbar.vue'
|
||||
import TagsView from './components/TagsView.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 是否需要标签页
|
||||
const needTagsView = computed(() => true)
|
||||
|
||||
// 动态类名
|
||||
const classObj = computed(() => {
|
||||
return {
|
||||
hideSidebar: !appStore.sidebarOpened,
|
||||
openSidebar: appStore.sidebarOpened,
|
||||
withoutAnimation: appStore.sidebar.withoutAnimation,
|
||||
mobile: appStore.device === 'mobile'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.app-wrapper {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
&.mobile {
|
||||
.main-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.openSidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.hideSidebar {
|
||||
.sidebar-container {
|
||||
width: $sidebar-collapsed-width !important;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: $sidebar-collapsed-width;
|
||||
}
|
||||
}
|
||||
|
||||
&.openSidebar {
|
||||
.sidebar-container {
|
||||
width: $sidebar-width !important;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
&.withoutAnimation {
|
||||
.sidebar-container,
|
||||
.main-container {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
width: $sidebar-width;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
transition: width 0.28s;
|
||||
|
||||
// 重置element-plus的menu样式
|
||||
:deep(.el-menu) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
min-height: 100vh;
|
||||
margin-left: $sidebar-width;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: margin-left 0.28s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media screen and (max-width: $sm) {
|
||||
.app-wrapper {
|
||||
&.mobile {
|
||||
.sidebar-container {
|
||||
width: $sidebar-width !important;
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.openSidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端遮罩
|
||||
.app-wrapper.mobile.openSidebar::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
</style>
|
41
frontend/src/main.js
Normal file
41
frontend/src/main.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
// 自定义样式
|
||||
import '@/styles/index.scss'
|
||||
|
||||
// NProgress
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 创建应用
|
||||
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)
|
||||
|
||||
// 配置NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
minimum: 0.2,
|
||||
easing: 'ease',
|
||||
speed: 500
|
||||
})
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
217
frontend/src/router/index.js
Normal file
217
frontend/src/router/index.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
// 导入页面组件
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
const Login = () => import('@/views/Login.vue')
|
||||
const Dashboard = () => import('@/views/Dashboard.vue')
|
||||
const ReportList = () => import('@/views/reports/ReportList.vue')
|
||||
const ReportForm = () => import('@/views/reports/ReportForm.vue')
|
||||
const ReportDetail = () => import('@/views/reports/ReportDetail.vue')
|
||||
const Profile = () => import('@/views/Profile.vue')
|
||||
const NotFound = () => import('@/views/404.vue')
|
||||
|
||||
// 基础路由
|
||||
const constantRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: {
|
||||
title: '页面未找到',
|
||||
requiresAuth: false,
|
||||
hideInMenu: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 需要认证的路由
|
||||
const authRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'House',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '日报管理',
|
||||
icon: 'Document',
|
||||
requiresAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ReportList',
|
||||
component: ReportList,
|
||||
meta: {
|
||||
title: '日报列表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'ReportCreate',
|
||||
component: ReportForm,
|
||||
meta: {
|
||||
title: '创建日报',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'ReportEdit',
|
||||
component: ReportForm,
|
||||
meta: {
|
||||
title: '编辑日报',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ReportDetail',
|
||||
component: ReportDetail,
|
||||
meta: {
|
||||
title: '日报详情',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Profile',
|
||||
component: Profile,
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
icon: 'User',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 所有路由
|
||||
const routes = [...constantRoutes, ...authRoutes]
|
||||
|
||||
// 404 路由(必须放在最后)
|
||||
routes.push({
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
})
|
||||
|
||||
// 创建路由器
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta.title ? `${to.meta.title} - ${appStore.title}` : appStore.title
|
||||
document.title = title
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) {
|
||||
// 需要认证但未登录,跳转到登录页
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录用户访问登录页,跳转到首页
|
||||
if (to.path === '/login' && authStore.isLoggedIn) {
|
||||
next({ path: '/' })
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已登录但没有用户信息,尝试获取用户信息
|
||||
if (authStore.isLoggedIn && !authStore.user) {
|
||||
try {
|
||||
await authStore.getUserInfo()
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
// 如果获取用户信息失败,清除认证信息并跳转到登录页
|
||||
authStore.clearAuthData()
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查路由权限
|
||||
if (to.meta.requiresAuth && to.meta.permission) {
|
||||
if (!authStore.hasPermission(to.meta.permission)) {
|
||||
// 没有权限,跳转到404页面
|
||||
next('/404')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到访问记录
|
||||
if (to.name && to.meta.title && !to.meta.hideInMenu) {
|
||||
appStore.addVisitedView(to)
|
||||
appStore.addCachedView(to)
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// 导出路由配置,供菜单使用
|
||||
export { authRoutes }
|
152
frontend/src/stores/app.js
Normal file
152
frontend/src/stores/app.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
// 侧边栏
|
||||
sidebar: {
|
||||
opened: true,
|
||||
withoutAnimation: false
|
||||
},
|
||||
|
||||
// 设备类型
|
||||
device: 'desktop',
|
||||
|
||||
// 主题
|
||||
theme: 'light',
|
||||
|
||||
// 加载状态
|
||||
loading: false,
|
||||
|
||||
// 页面标题
|
||||
title: '企业级日报系统',
|
||||
|
||||
// 标签页
|
||||
visitedViews: [],
|
||||
cachedViews: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
sidebarOpened: (state) => state.sidebar.opened,
|
||||
isMobile: (state) => state.device === 'mobile',
|
||||
isDark: (state) => state.theme === 'dark'
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 切换侧边栏
|
||||
toggleSidebar() {
|
||||
this.sidebar.opened = !this.sidebar.opened
|
||||
this.sidebar.withoutAnimation = false
|
||||
},
|
||||
|
||||
// 关闭侧边栏
|
||||
closeSidebar(withoutAnimation = false) {
|
||||
this.sidebar.opened = false
|
||||
this.sidebar.withoutAnimation = withoutAnimation
|
||||
},
|
||||
|
||||
// 设置设备类型
|
||||
setDevice(device) {
|
||||
this.device = device
|
||||
},
|
||||
|
||||
// 切换主题
|
||||
toggleTheme() {
|
||||
this.theme = this.theme === 'light' ? 'dark' : 'light'
|
||||
this.updateTheme()
|
||||
},
|
||||
|
||||
// 设置主题
|
||||
setTheme(theme) {
|
||||
this.theme = theme
|
||||
this.updateTheme()
|
||||
},
|
||||
|
||||
// 更新主题
|
||||
updateTheme() {
|
||||
const html = document.documentElement
|
||||
if (this.theme === 'dark') {
|
||||
html.classList.add('dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', this.theme)
|
||||
},
|
||||
|
||||
// 初始化主题
|
||||
initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
this.setTheme(savedTheme)
|
||||
} else {
|
||||
// 检查系统主题偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
this.setTheme(prefersDark ? 'dark' : 'light')
|
||||
}
|
||||
},
|
||||
|
||||
// 设置加载状态
|
||||
setLoading(loading) {
|
||||
this.loading = loading
|
||||
},
|
||||
|
||||
// 设置页面标题
|
||||
setTitle(title) {
|
||||
this.title = title
|
||||
document.title = title
|
||||
},
|
||||
|
||||
// 添加访问过的视图
|
||||
addVisitedView(view) {
|
||||
if (this.visitedViews.some(v => v.path === view.path)) return
|
||||
this.visitedViews.push({
|
||||
name: view.name,
|
||||
path: view.path,
|
||||
title: view.meta?.title || view.name
|
||||
})
|
||||
},
|
||||
|
||||
// 删除访问过的视图
|
||||
delVisitedView(view) {
|
||||
const index = this.visitedViews.findIndex(v => v.path === view.path)
|
||||
if (index > -1) {
|
||||
this.visitedViews.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
// 删除其他访问过的视图
|
||||
delOthersVisitedViews(view) {
|
||||
this.visitedViews = this.visitedViews.filter(v => v.path === view.path)
|
||||
},
|
||||
|
||||
// 删除所有访问过的视图
|
||||
delAllVisitedViews() {
|
||||
this.visitedViews = []
|
||||
},
|
||||
|
||||
// 添加缓存视图
|
||||
addCachedView(view) {
|
||||
if (this.cachedViews.includes(view.name)) return
|
||||
if (!view.meta?.noCache) {
|
||||
this.cachedViews.push(view.name)
|
||||
}
|
||||
},
|
||||
|
||||
// 删除缓存视图
|
||||
delCachedView(view) {
|
||||
const index = this.cachedViews.indexOf(view.name)
|
||||
if (index > -1) {
|
||||
this.cachedViews.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
// 删除其他缓存视图
|
||||
delOthersCachedViews(view) {
|
||||
this.cachedViews = this.cachedViews.filter(name => name === view.name)
|
||||
},
|
||||
|
||||
// 删除所有缓存视图
|
||||
delAllCachedViews() {
|
||||
this.cachedViews = []
|
||||
}
|
||||
}
|
||||
})
|
204
frontend/src/stores/auth.js
Normal file
204
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { request } from '@/utils/request'
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
removeRefreshToken,
|
||||
getUser,
|
||||
setUser,
|
||||
removeUser,
|
||||
clearAuth
|
||||
} from '@/utils/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: getToken(),
|
||||
refreshToken: getRefreshToken(),
|
||||
user: getUser(),
|
||||
permissions: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoggedIn: (state) => !!state.token,
|
||||
isAdmin: (state) => state.user && (state.user.is_staff || state.user.is_superuser),
|
||||
isSuperuser: (state) => state.user && state.user.is_superuser,
|
||||
userRole: (state) => {
|
||||
if (!state.user) return 'guest'
|
||||
if (state.user.is_superuser) return 'superuser'
|
||||
if (state.user.is_staff) return 'admin'
|
||||
return 'user'
|
||||
},
|
||||
userName: (state) => {
|
||||
if (!state.user) return ''
|
||||
const fullName = `${state.user.first_name || ''} ${state.user.last_name || ''}`.trim()
|
||||
return fullName || state.user.username
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 登录
|
||||
async login(credentials) {
|
||||
try {
|
||||
const response = await request.post('/auth/login/', credentials)
|
||||
|
||||
if (response.tokens && response.user) {
|
||||
this.setAuthData(response.tokens, response.user)
|
||||
return { success: true, message: response.message || '登录成功' }
|
||||
} else {
|
||||
throw new Error('登录响应数据格式错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '登录失败'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 注册
|
||||
async register(userData) {
|
||||
try {
|
||||
const response = await request.post('/auth/register/', userData)
|
||||
|
||||
if (response.tokens && response.user) {
|
||||
this.setAuthData(response.tokens, response.user)
|
||||
return { success: true, message: response.message || '注册成功' }
|
||||
} else {
|
||||
throw new Error('注册响应数据格式错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '注册失败'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
try {
|
||||
if (this.refreshToken) {
|
||||
await request.post('/auth/logout/', {
|
||||
refresh_token: this.refreshToken
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
this.clearAuthData()
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
async refreshAccessToken() {
|
||||
try {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('没有refresh token')
|
||||
}
|
||||
|
||||
const response = await request.post('/auth/token/refresh/', {
|
||||
refresh: this.refreshToken
|
||||
})
|
||||
|
||||
if (response.access) {
|
||||
this.token = response.access
|
||||
setToken(response.access)
|
||||
|
||||
// 如果返回了新的refresh token,也要更新
|
||||
if (response.refresh) {
|
||||
this.refreshToken = response.refresh
|
||||
setRefreshToken(response.refresh)
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
throw new Error('刷新token失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新token失败:', error)
|
||||
this.clearAuthData()
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const response = await request.get('/auth/profile/')
|
||||
|
||||
if (response) {
|
||||
this.user = response
|
||||
setUser(response)
|
||||
return response
|
||||
} else {
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
this.clearAuthData()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
async updateUserInfo(userData) {
|
||||
try {
|
||||
const response = await request.put('/auth/profile/', userData)
|
||||
|
||||
if (response) {
|
||||
this.user = response
|
||||
setUser(response)
|
||||
return { success: true, message: '更新成功' }
|
||||
} else {
|
||||
throw new Error('更新用户信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户信息失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '更新失败'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 设置认证数据
|
||||
setAuthData(tokens, user) {
|
||||
this.token = tokens.access
|
||||
this.refreshToken = tokens.refresh
|
||||
this.user = user
|
||||
|
||||
setToken(tokens.access)
|
||||
setRefreshToken(tokens.refresh)
|
||||
setUser(user)
|
||||
},
|
||||
|
||||
// 清除认证数据
|
||||
clearAuthData() {
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.user = null
|
||||
this.permissions = []
|
||||
|
||||
clearAuth()
|
||||
},
|
||||
|
||||
// 检查权限
|
||||
hasPermission(permission) {
|
||||
switch (permission) {
|
||||
case 'view_all_reports':
|
||||
return this.isAdmin
|
||||
case 'manage_users':
|
||||
return this.isSuperuser
|
||||
case 'view_stats':
|
||||
return this.isAdmin
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
289
frontend/src/stores/reports.js
Normal file
289
frontend/src/stores/reports.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
export const useReportsStore = defineStore('reports', {
|
||||
state: () => ({
|
||||
// 日报列表
|
||||
reports: [],
|
||||
|
||||
// 分页信息
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
},
|
||||
|
||||
// 当前查看的日报
|
||||
currentReport: null,
|
||||
|
||||
// 搜索条件
|
||||
searchParams: {
|
||||
report_date_start: '',
|
||||
report_date_end: '',
|
||||
user: '',
|
||||
work_summary: '',
|
||||
next_day_plan: '',
|
||||
is_draft: null
|
||||
},
|
||||
|
||||
// 统计数据
|
||||
stats: {
|
||||
total_reports: 0,
|
||||
this_month_reports: 0,
|
||||
this_week_reports: 0,
|
||||
draft_reports: 0,
|
||||
completion_rate: 0
|
||||
},
|
||||
|
||||
// 用户统计数据(管理员可见)
|
||||
userStats: [],
|
||||
|
||||
// 加载状态
|
||||
loading: false,
|
||||
submitting: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 获取当前页的日报
|
||||
currentPageReports: (state) => state.reports,
|
||||
|
||||
// 是否有更多数据
|
||||
hasMore: (state) => {
|
||||
const { current, pageSize, total } = state.pagination
|
||||
return current * pageSize < total
|
||||
},
|
||||
|
||||
// 获取搜索参数(过滤空值)
|
||||
activeSearchParams: (state) => {
|
||||
const params = {}
|
||||
Object.keys(state.searchParams).forEach(key => {
|
||||
const value = state.searchParams[key]
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
params[key] = value
|
||||
}
|
||||
})
|
||||
return params
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 获取日报列表
|
||||
async fetchReports(params = {}) {
|
||||
try {
|
||||
this.loading = true
|
||||
|
||||
const queryParams = {
|
||||
page: this.pagination.current,
|
||||
page_size: this.pagination.pageSize,
|
||||
...this.activeSearchParams,
|
||||
...params
|
||||
}
|
||||
|
||||
const response = await request.get('/reports/', queryParams)
|
||||
|
||||
if (response.results) {
|
||||
this.reports = response.results
|
||||
this.pagination.total = response.count
|
||||
}
|
||||
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('获取日报列表失败:', error)
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取日报详情
|
||||
async fetchReportDetail(id) {
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await request.get(`/reports/${id}/`)
|
||||
this.currentReport = response
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('获取日报详情失败:', error)
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 创建日报
|
||||
async createReport(reportData) {
|
||||
try {
|
||||
this.submitting = true
|
||||
const response = await request.post('/reports/', reportData)
|
||||
|
||||
// 刷新列表
|
||||
await this.fetchReports()
|
||||
|
||||
return { success: true, data: response, message: '日报创建成功' }
|
||||
} catch (error) {
|
||||
console.error('创建日报失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
message: error.response?.data?.message || '创建日报失败'
|
||||
}
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新日报
|
||||
async updateReport(id, reportData) {
|
||||
try {
|
||||
this.submitting = true
|
||||
const response = await request.put(`/reports/${id}/`, reportData)
|
||||
|
||||
// 更新当前日报
|
||||
if (this.currentReport && this.currentReport.id === id) {
|
||||
this.currentReport = response
|
||||
}
|
||||
|
||||
// 更新列表中的日报
|
||||
const index = this.reports.findIndex(report => report.id === id)
|
||||
if (index !== -1) {
|
||||
this.reports[index] = response
|
||||
}
|
||||
|
||||
return { success: true, data: response, message: '日报更新成功' }
|
||||
} catch (error) {
|
||||
console.error('更新日报失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
message: error.response?.data?.message || '更新日报失败'
|
||||
}
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 删除日报
|
||||
async deleteReport(id) {
|
||||
try {
|
||||
await request.delete(`/reports/${id}/`)
|
||||
|
||||
// 从列表中移除
|
||||
this.reports = this.reports.filter(report => report.id !== id)
|
||||
this.pagination.total -= 1
|
||||
|
||||
// 如果是当前查看的日报,清空
|
||||
if (this.currentReport && this.currentReport.id === id) {
|
||||
this.currentReport = null
|
||||
}
|
||||
|
||||
return { success: true, message: '日报删除成功' }
|
||||
} catch (error) {
|
||||
console.error('删除日报失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
message: error.response?.data?.message || '删除日报失败'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 切换草稿状态
|
||||
async toggleDraftStatus(id) {
|
||||
try {
|
||||
const response = await request.post(`/reports/${id}/toggle-draft/`)
|
||||
|
||||
// 更新列表中的日报状态
|
||||
const index = this.reports.findIndex(report => report.id === id)
|
||||
if (index !== -1) {
|
||||
this.reports[index].is_draft = response.is_draft
|
||||
}
|
||||
|
||||
// 更新当前日报状态
|
||||
if (this.currentReport && this.currentReport.id === id) {
|
||||
this.currentReport.is_draft = response.is_draft
|
||||
}
|
||||
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('切换草稿状态失败:', error)
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
|
||||
// 获取统计数据
|
||||
async fetchStats() {
|
||||
try {
|
||||
const response = await request.get('/stats/')
|
||||
this.stats = response
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户统计数据
|
||||
async fetchUserStats() {
|
||||
try {
|
||||
const response = await request.get('/stats/users/')
|
||||
this.userStats = response
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('获取用户统计数据失败:', error)
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
|
||||
// 设置搜索参数
|
||||
setSearchParams(params) {
|
||||
this.searchParams = { ...this.searchParams, ...params }
|
||||
},
|
||||
|
||||
// 重置搜索参数
|
||||
resetSearchParams() {
|
||||
this.searchParams = {
|
||||
report_date_start: '',
|
||||
report_date_end: '',
|
||||
user: '',
|
||||
work_summary: '',
|
||||
next_day_plan: '',
|
||||
is_draft: null
|
||||
}
|
||||
},
|
||||
|
||||
// 设置分页
|
||||
setPagination(pagination) {
|
||||
this.pagination = { ...this.pagination, ...pagination }
|
||||
},
|
||||
|
||||
// 重置分页
|
||||
resetPagination() {
|
||||
this.pagination = {
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
}
|
||||
},
|
||||
|
||||
// 清空当前日报
|
||||
clearCurrentReport() {
|
||||
this.currentReport = null
|
||||
},
|
||||
|
||||
// 重置状态
|
||||
resetState() {
|
||||
this.reports = []
|
||||
this.currentReport = null
|
||||
this.resetPagination()
|
||||
this.resetSearchParams()
|
||||
this.stats = {
|
||||
total_reports: 0,
|
||||
this_month_reports: 0,
|
||||
this_week_reports: 0,
|
||||
draft_reports: 0,
|
||||
completion_rate: 0
|
||||
}
|
||||
this.userStats = []
|
||||
}
|
||||
}
|
||||
})
|
318
frontend/src/styles/index.scss
Normal file
318
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,318 @@
|
||||
@import './variables.scss';
|
||||
|
||||
// 重置样式
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
// 清除默认样式
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 通用工具类
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
// 间距工具类
|
||||
@for $i from 0 through 5 {
|
||||
.m-#{$i * 5} {
|
||||
margin: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.mt-#{$i * 5} {
|
||||
margin-top: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.mr-#{$i * 5} {
|
||||
margin-right: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.mb-#{$i * 5} {
|
||||
margin-bottom: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.ml-#{$i * 5} {
|
||||
margin-left: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.p-#{$i * 5} {
|
||||
padding: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.pt-#{$i * 5} {
|
||||
padding-top: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.pr-#{$i * 5} {
|
||||
padding-right: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.pb-#{$i * 5} {
|
||||
padding-bottom: #{$i * 5}px;
|
||||
}
|
||||
|
||||
.pl-#{$i * 5} {
|
||||
padding-left: #{$i * 5}px;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片样式
|
||||
.cool-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
box-shadow: $box-shadow-light;
|
||||
overflow: hidden;
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面容器
|
||||
.page-container {
|
||||
padding: $main-padding;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: $text-color-primary;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: $text-color-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式增强
|
||||
.cool-table {
|
||||
.el-table {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #fafafa;
|
||||
color: $text-color-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单样式增强
|
||||
.cool-form {
|
||||
.el-form-item__label {
|
||||
font-weight: 500;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组样式
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索表单样式
|
||||
.search-form {
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: $box-shadow-light;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
text-align: right;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, $primary-color 0%, lighten($primary-color, 10%) 100%);
|
||||
color: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: $box-shadow;
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: linear-gradient(135deg, $success-color 0%, lighten($success-color, 10%) 100%);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: linear-gradient(135deg, $warning-color 0%, lighten($warning-color, 10%) 100%);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(135deg, $danger-color 0%, lighten($danger-color, 10%) 100%);
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: linear-gradient(135deg, $info-color 0%, lighten($info-color, 10%) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
body {
|
||||
background-color: $bg-color-dark;
|
||||
}
|
||||
|
||||
.cool-card {
|
||||
background: #1d1e1f;
|
||||
border-color: $border-color-dark;
|
||||
|
||||
.card-header {
|
||||
border-color: $border-color-dark;
|
||||
color: $text-color-primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
background: #1d1e1f;
|
||||
border-color: $border-color-dark;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.page-title {
|
||||
color: $text-color-primary-dark;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: $text-color-secondary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.cool-table {
|
||||
.el-table__header {
|
||||
th {
|
||||
background-color: #262727;
|
||||
color: $text-color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cool-form {
|
||||
.el-form-item__label {
|
||||
color: $text-color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: $sm) {
|
||||
.page-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
59
frontend/src/styles/variables.scss
Normal file
59
frontend/src/styles/variables.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
// Cool Admin 主题变量
|
||||
|
||||
// 主色调
|
||||
$primary-color: #409eff;
|
||||
$success-color: #67c23a;
|
||||
$warning-color: #e6a23c;
|
||||
$danger-color: #f56c6c;
|
||||
$info-color: #909399;
|
||||
|
||||
// 背景色
|
||||
$bg-color: #f5f7fa;
|
||||
$bg-color-dark: #141414;
|
||||
|
||||
// 侧边栏
|
||||
$sidebar-width: 240px;
|
||||
$sidebar-collapsed-width: 64px;
|
||||
$sidebar-bg: #304156;
|
||||
$sidebar-bg-dark: #1d1e1f;
|
||||
$sidebar-text-color: #bfcbd9;
|
||||
$sidebar-active-color: #ffffff;
|
||||
|
||||
// 头部
|
||||
$header-height: 60px;
|
||||
$header-bg: #ffffff;
|
||||
$header-bg-dark: #1d1e1f;
|
||||
$header-border-color: #e4e7ed;
|
||||
|
||||
// 标签页
|
||||
$tags-height: 34px;
|
||||
$tags-bg: #ffffff;
|
||||
$tags-bg-dark: #1d1e1f;
|
||||
|
||||
// 内容区域
|
||||
$main-padding: 20px;
|
||||
|
||||
// 阴影
|
||||
$box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
$box-shadow-light: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
|
||||
|
||||
// 边框
|
||||
$border-color: #dcdfe6;
|
||||
$border-color-dark: #4c4d4f;
|
||||
|
||||
// 文字颜色
|
||||
$text-color-primary: #303133;
|
||||
$text-color-regular: #606266;
|
||||
$text-color-secondary: #909399;
|
||||
$text-color-placeholder: #c0c4cc;
|
||||
|
||||
// 暗色模式文字颜色
|
||||
$text-color-primary-dark: #e5eaf3;
|
||||
$text-color-regular-dark: #cfd3dc;
|
||||
$text-color-secondary-dark: #a3a6ad;
|
||||
|
||||
// 响应式断点
|
||||
$sm: 768px;
|
||||
$md: 992px;
|
||||
$lg: 1200px;
|
||||
$xl: 1920px;
|
88
frontend/src/utils/auth.js
Normal file
88
frontend/src/utils/auth.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
const TokenKey = 'daily_report_token'
|
||||
const RefreshTokenKey = 'daily_report_refresh_token'
|
||||
const UserKey = 'daily_report_user'
|
||||
|
||||
// Token 相关
|
||||
export function getToken() {
|
||||
return Cookies.get(TokenKey)
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
return Cookies.set(TokenKey, token, { expires: 7 }) // 7天过期
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
return Cookies.remove(TokenKey)
|
||||
}
|
||||
|
||||
// Refresh Token 相关
|
||||
export function getRefreshToken() {
|
||||
return Cookies.get(RefreshTokenKey)
|
||||
}
|
||||
|
||||
export function setRefreshToken(token) {
|
||||
return Cookies.set(RefreshTokenKey, token, { expires: 7 })
|
||||
}
|
||||
|
||||
export function removeRefreshToken() {
|
||||
return Cookies.remove(RefreshTokenKey)
|
||||
}
|
||||
|
||||
// 用户信息相关
|
||||
export function getUser() {
|
||||
const user = localStorage.getItem(UserKey)
|
||||
return user ? JSON.parse(user) : null
|
||||
}
|
||||
|
||||
export function setUser(user) {
|
||||
return localStorage.setItem(UserKey, JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function removeUser() {
|
||||
return localStorage.removeItem(UserKey)
|
||||
}
|
||||
|
||||
// 清除所有认证信息
|
||||
export function clearAuth() {
|
||||
removeToken()
|
||||
removeRefreshToken()
|
||||
removeUser()
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
export function isLoggedIn() {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
export function isAdmin() {
|
||||
const user = getUser()
|
||||
return user && (user.is_staff || user.is_superuser)
|
||||
}
|
||||
|
||||
// 获取用户角色
|
||||
export function getUserRole() {
|
||||
const user = getUser()
|
||||
if (!user) return 'guest'
|
||||
if (user.is_superuser) return 'superuser'
|
||||
if (user.is_staff) return 'admin'
|
||||
return 'user'
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
export function hasPermission(permission) {
|
||||
const role = getUserRole()
|
||||
|
||||
switch (permission) {
|
||||
case 'view_all_reports':
|
||||
return role === 'admin' || role === 'superuser'
|
||||
case 'manage_users':
|
||||
return role === 'superuser'
|
||||
case 'view_stats':
|
||||
return role === 'admin' || role === 'superuser'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
231
frontend/src/utils/index.js
Normal file
231
frontend/src/utils/index.js
Normal file
@@ -0,0 +1,231 @@
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
// 配置dayjs
|
||||
dayjs.locale('zh-cn')
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param {string|Date} date 日期
|
||||
* @param {string} format 格式
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
* @param {string|Date} datetime 日期时间
|
||||
* @param {string} format 格式
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(datetime, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!datetime) return ''
|
||||
return dayjs(datetime).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对时间
|
||||
* @param {string|Date} date 日期
|
||||
* @returns {string}
|
||||
*/
|
||||
export function fromNow(date) {
|
||||
if (!date) return ''
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今天的日期
|
||||
* @param {string} format 格式
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getToday(format = 'YYYY-MM-DD') {
|
||||
return dayjs().format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本周的开始和结束日期
|
||||
* @returns {object}
|
||||
*/
|
||||
export function getThisWeek() {
|
||||
const today = dayjs()
|
||||
const startOfWeek = today.startOf('week')
|
||||
const endOfWeek = today.endOf('week')
|
||||
|
||||
return {
|
||||
start: startOfWeek.format('YYYY-MM-DD'),
|
||||
end: endOfWeek.format('YYYY-MM-DD')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本月的开始和结束日期
|
||||
* @returns {object}
|
||||
*/
|
||||
export function getThisMonth() {
|
||||
const today = dayjs()
|
||||
const startOfMonth = today.startOf('month')
|
||||
const endOfMonth = today.endOf('month')
|
||||
|
||||
return {
|
||||
start: startOfMonth.format('YYYY-MM-DD'),
|
||||
end: endOfMonth.format('YYYY-MM-DD')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取文本
|
||||
* @param {string} text 文本
|
||||
* @param {number} length 长度
|
||||
* @param {string} suffix 后缀
|
||||
* @returns {string}
|
||||
*/
|
||||
export function truncateText(text, length = 50, suffix = '...') {
|
||||
if (!text) return ''
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func 函数
|
||||
* @param {number} wait 等待时间
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func 函数
|
||||
* @param {number} limit 限制时间
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝
|
||||
* @param {any} obj 对象
|
||||
* @returns {any}
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (obj instanceof Date) return new Date(obj.getTime())
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||
if (typeof obj === 'object') {
|
||||
const clonedObj = {}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return clonedObj
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
* @param {number} length 长度
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateRandomString(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param {Blob} blob 文件blob
|
||||
* @param {string} filename 文件名
|
||||
*/
|
||||
export function downloadFile(blob, filename) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
* @param {string} email 邮箱
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return re.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
* @param {string} phone 手机号
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validatePhone(phone) {
|
||||
const re = /^1[3-9]\d{9}$/
|
||||
return re.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件大小文本
|
||||
* @param {number} size 文件大小(字节)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getFileSizeText(size) {
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色文本
|
||||
* @param {object} user 用户对象
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getUserRoleText(user) {
|
||||
if (!user) return '游客'
|
||||
if (user.is_superuser) return '超级管理员'
|
||||
if (user.is_staff) return '管理员'
|
||||
return '普通用户'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户全名
|
||||
* @param {object} user 用户对象
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getUserFullName(user) {
|
||||
if (!user) return ''
|
||||
const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
||||
return fullName || user.username
|
||||
}
|
163
frontend/src/utils/request.js
Normal file
163
frontend/src/utils/request.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: process.env.VUE_APP_BASE_API || '/api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
NProgress.start()
|
||||
|
||||
// 添加认证token
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
NProgress.done()
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
NProgress.done()
|
||||
|
||||
const res = response.data
|
||||
|
||||
// 如果是文件下载等特殊情况,直接返回
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
async error => {
|
||||
NProgress.done()
|
||||
|
||||
const { response } = error
|
||||
let message = '网络错误,请稍后重试'
|
||||
|
||||
if (response) {
|
||||
const { status, data } = response
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = data.message || data.detail || '请求参数错误'
|
||||
if (data.errors) {
|
||||
// 处理表单验证错误
|
||||
const errorMessages = []
|
||||
for (const field in data.errors) {
|
||||
if (Array.isArray(data.errors[field])) {
|
||||
errorMessages.push(...data.errors[field])
|
||||
} else {
|
||||
errorMessages.push(data.errors[field])
|
||||
}
|
||||
}
|
||||
message = errorMessages.join(', ')
|
||||
}
|
||||
break
|
||||
case 401:
|
||||
message = '登录已过期,请重新登录'
|
||||
// 清除本地存储的认证信息
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
message = data.message || '权限不足'
|
||||
break
|
||||
case 404:
|
||||
message = data.message || '请求的资源不存在'
|
||||
break
|
||||
case 422:
|
||||
message = '数据验证失败'
|
||||
if (data.errors) {
|
||||
const errorMessages = []
|
||||
for (const field in data.errors) {
|
||||
errorMessages.push(...data.errors[field])
|
||||
}
|
||||
message = errorMessages.join(', ')
|
||||
}
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
case 502:
|
||||
message = '网关错误'
|
||||
break
|
||||
case 503:
|
||||
message = '服务暂不可用'
|
||||
break
|
||||
case 504:
|
||||
message = '网关超时'
|
||||
break
|
||||
default:
|
||||
message = data.message || `连接错误${status}`
|
||||
}
|
||||
} else if (error.code === 'NETWORK_ERROR') {
|
||||
message = '网络连接异常'
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message = '请求超时'
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
ElMessage.error(message)
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装常用的请求方法
|
||||
export const request = {
|
||||
get(url, params = {}) {
|
||||
return service.get(url, { params })
|
||||
},
|
||||
|
||||
post(url, data = {}) {
|
||||
return service.post(url, data)
|
||||
},
|
||||
|
||||
put(url, data = {}) {
|
||||
return service.put(url, data)
|
||||
},
|
||||
|
||||
patch(url, data = {}) {
|
||||
return service.patch(url, data)
|
||||
},
|
||||
|
||||
delete(url) {
|
||||
return service.delete(url)
|
||||
},
|
||||
|
||||
upload(url, data, onUploadProgress) {
|
||||
return service.post(url, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress
|
||||
})
|
||||
},
|
||||
|
||||
download(url, params = {}) {
|
||||
return service.get(url, {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default service
|
162
frontend/src/utils/validate.js
Normal file
162
frontend/src/utils/validate.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 验证工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 判断是否为外部链接
|
||||
* @param {string} path 路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isExternal(path) {
|
||||
return /^(https?:|mailto:|tel:)/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
* @param {string} email 邮箱
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validEmail(email) {
|
||||
const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return reg.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* @param {string} phone 手机号
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validPhone(phone) {
|
||||
const reg = /^1[3-9]\d{9}$/
|
||||
return reg.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL格式
|
||||
* @param {string} url URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validURL(url) {
|
||||
const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
|
||||
return reg.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证小写字母
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validLowerCase(str) {
|
||||
const reg = /^[a-z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大写字母
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validUpperCase(str) {
|
||||
const reg = /^[A-Z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字母
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validAlphabets(str) {
|
||||
const reg = /^[A-Za-z]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户名格式(字母、数字、下划线)
|
||||
* @param {string} username 用户名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validUsername(username) {
|
||||
const reg = /^[a-zA-Z0-9_]{3,20}$/
|
||||
return reg.test(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度(至少包含字母和数字)
|
||||
* @param {string} password 密码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validPassword(password) {
|
||||
const reg = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{6,}$/
|
||||
return reg.test(password)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证号码
|
||||
* @param {string} idCard 身份证号
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validIdCard(idCard) {
|
||||
const reg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
|
||||
return reg.test(idCard)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证IP地址
|
||||
* @param {string} ip IP地址
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validIP(ip) {
|
||||
const reg = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
return reg.test(ip)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证中文字符
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validChinese(str) {
|
||||
const reg = /^[\u4e00-\u9fa5]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validNumber(str) {
|
||||
const reg = /^[0-9]+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正整数
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validPositiveInteger(str) {
|
||||
const reg = /^[1-9]\d*$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非负整数
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validNonNegativeInteger(str) {
|
||||
const reg = /^\d+$/
|
||||
return reg.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证浮点数
|
||||
* @param {string} str 字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validFloat(str) {
|
||||
const reg = /^(-?\d+)(\.\d+)?$/
|
||||
return reg.test(str)
|
||||
}
|
252
frontend/src/views/404.vue
Normal file
252
frontend/src/views/404.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<!-- 404图标 -->
|
||||
<div class="error-icon">
|
||||
<el-icon :size="120">
|
||||
<Warning />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div class="error-info">
|
||||
<h1 class="error-code">404</h1>
|
||||
<h2 class="error-title">页面未找到</h2>
|
||||
<p class="error-description">
|
||||
抱歉,您访问的页面不存在或已被删除。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" size="large" @click="goHome">
|
||||
<el-icon><House /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
|
||||
<el-button size="large" @click="goBack">
|
||||
<el-icon><Back /></el-icon>
|
||||
返回上页
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 建议链接 -->
|
||||
<div class="suggested-links">
|
||||
<h3>您可能想要访问:</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<router-link to="/dashboard">
|
||||
<el-icon><House /></el-icon>
|
||||
工作台
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/reports">
|
||||
<el-icon><Document /></el-icon>
|
||||
日报管理
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Warning,
|
||||
House,
|
||||
Back,
|
||||
Document,
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 返回首页
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.not-found-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin-bottom: 32px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.error-code {
|
||||
font-size: 72px;
|
||||
font-weight: bold;
|
||||
color: #f56c6c;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.el-button {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
font-size: 16px;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggested-links {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.not-found-content {
|
||||
background: rgba(29, 30, 31, 0.95);
|
||||
|
||||
.error-info {
|
||||
.error-title {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
|
||||
.suggested-links {
|
||||
h3 {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: $sm) {
|
||||
.not-found-content {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
.error-code {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
465
frontend/src/views/Dashboard.vue
Normal file
465
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">工作台</h1>
|
||||
<p class="page-description">欢迎回来,{{ authStore.userName }}!</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="mb-20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.total_reports }}</div>
|
||||
<div class="stat-label">总日报数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-card success">
|
||||
<div class="stat-number">{{ stats.this_month_reports }}</div>
|
||||
<div class="stat-label">本月日报</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-card warning">
|
||||
<div class="stat-number">{{ stats.this_week_reports }}</div>
|
||||
<div class="stat-label">本周日报</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-card info">
|
||||
<div class="stat-number">{{ stats.completion_rate }}%</div>
|
||||
<div class="stat-label">完成率</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="cool-card mb-20">
|
||||
<div class="card-header">
|
||||
<h3>快捷操作</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="quick-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:icon="Plus"
|
||||
@click="createReport"
|
||||
>
|
||||
创建今日日报
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
:icon="Document"
|
||||
@click="viewReports"
|
||||
>
|
||||
查看我的日报
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="authStore.isAdmin"
|
||||
size="large"
|
||||
:icon="DataAnalysis"
|
||||
@click="viewStats"
|
||||
>
|
||||
查看统计
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近日报 -->
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<h3>最近日报</h3>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="viewReports"
|
||||
>
|
||||
查看更多
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="loading" class="text-center">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span class="ml-10">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentReports.length === 0" class="empty-state">
|
||||
<el-empty description="暂无日报数据">
|
||||
<el-button type="primary" @click="createReport">创建第一份日报</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="recent-reports">
|
||||
<div
|
||||
v-for="report in recentReports"
|
||||
:key="report.id"
|
||||
class="report-item"
|
||||
@click="viewReport(report.id)"
|
||||
>
|
||||
<div class="report-header">
|
||||
<span class="report-date">{{ formatDate(report.report_date) }}</span>
|
||||
<el-tag v-if="report.is_draft" size="small" type="warning">草稿</el-tag>
|
||||
</div>
|
||||
<div class="report-content">
|
||||
<h4 class="report-title">工作总结</h4>
|
||||
<p class="report-summary">{{ report.work_summary_preview }}</p>
|
||||
</div>
|
||||
<div class="report-footer">
|
||||
<span class="report-time">{{ fromNow(report.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理员统计 -->
|
||||
<div v-if="authStore.isAdmin" class="cool-card mt-20">
|
||||
<div class="card-header">
|
||||
<h3>团队统计</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="userStatsLoading" class="text-center">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span class="ml-10">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="user-stats">
|
||||
<div
|
||||
v-for="userStat in userStats.slice(0, 5)"
|
||||
:key="userStat.user.id"
|
||||
class="user-stat-item"
|
||||
>
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32">{{ userStat.user.full_name.charAt(0) }}</el-avatar>
|
||||
<div class="user-details">
|
||||
<div class="user-name">{{ userStat.user.full_name }}</div>
|
||||
<div class="user-department">{{ userStat.user.department || '未设置部门' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ userStat.total_reports }}</span>
|
||||
<span class="metric-label">总日报</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ userStat.this_month_reports }}</span>
|
||||
<span class="metric-label">本月</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ userStat.completion_rate }}%</span>
|
||||
<span class="metric-label">完成率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Document,
|
||||
DataAnalysis,
|
||||
Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useReportsStore } from '@/stores/reports'
|
||||
import { formatDate, fromNow, getToday } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const reportsStore = useReportsStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const userStatsLoading = ref(false)
|
||||
const stats = ref({
|
||||
total_reports: 0,
|
||||
this_month_reports: 0,
|
||||
this_week_reports: 0,
|
||||
draft_reports: 0,
|
||||
completion_rate: 0
|
||||
})
|
||||
const recentReports = ref([])
|
||||
const userStats = ref([])
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const result = await reportsStore.fetchStats()
|
||||
if (result.success) {
|
||||
stats.value = result.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最近日报
|
||||
const fetchRecentReports = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
reportsStore.setPagination({ current: 1, pageSize: 5 })
|
||||
const result = await reportsStore.fetchReports()
|
||||
if (result.success) {
|
||||
recentReports.value = result.data.results || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取最近日报失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户统计数据
|
||||
const fetchUserStats = async () => {
|
||||
if (!authStore.isAdmin) return
|
||||
|
||||
userStatsLoading.value = true
|
||||
try {
|
||||
const result = await reportsStore.fetchUserStats()
|
||||
if (result.success) {
|
||||
userStats.value = result.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户统计数据失败:', error)
|
||||
} finally {
|
||||
userStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建日报
|
||||
const createReport = () => {
|
||||
router.push('/reports/create')
|
||||
}
|
||||
|
||||
// 查看日报列表
|
||||
const viewReports = () => {
|
||||
router.push('/reports')
|
||||
}
|
||||
|
||||
// 查看统计
|
||||
const viewStats = () => {
|
||||
// 这里可以跳转到统计页面,暂时显示消息
|
||||
ElMessage.info('统计页面开发中...')
|
||||
}
|
||||
|
||||
// 查看日报详情
|
||||
const viewReport = (id) => {
|
||||
router.push(`/reports/${id}`)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchStats(),
|
||||
fetchRecentReports(),
|
||||
fetchUserStats()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recent-reports {
|
||||
.report-item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.report-date {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.report-content {
|
||||
.report-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.report-footer {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
|
||||
.report-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
.user-stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.user-details {
|
||||
margin-left: 12px;
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.recent-reports {
|
||||
.report-item {
|
||||
border-color: #4c4d4f;
|
||||
background: #1d1e1f;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
.report-date {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
}
|
||||
|
||||
.report-content {
|
||||
.report-title {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
.user-stat-item {
|
||||
border-color: #4c4d4f;
|
||||
|
||||
.user-info {
|
||||
.user-details {
|
||||
.user-name {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.user-department {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-metrics {
|
||||
.metric {
|
||||
.metric-value {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
520
frontend/src/views/Login.vue
Normal file
520
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-form-wrapper">
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">企业级日报系统</h1>
|
||||
<p class="login-subtitle">基于Cool Admin的现代化管理平台</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
class="login-form"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
:prefix-icon="User"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="login-options">
|
||||
<el-checkbox v-model="rememberMe">记住我</el-checkbox>
|
||||
<el-link type="primary" :underline="false" @click="showRegister = true">
|
||||
注册账号
|
||||
</el-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-demo">
|
||||
<el-divider>演示账号</el-divider>
|
||||
<div class="demo-accounts">
|
||||
<el-button
|
||||
size="small"
|
||||
type="info"
|
||||
plain
|
||||
@click="setDemoAccount('admin')"
|
||||
>
|
||||
管理员账号
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
plain
|
||||
@click="setDemoAccount('user')"
|
||||
>
|
||||
普通用户账号
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册对话框 -->
|
||||
<el-dialog
|
||||
v-model="showRegister"
|
||||
title="用户注册"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓" prop="last_name">
|
||||
<el-input
|
||||
v-model="registerForm.last_name"
|
||||
placeholder="请输入姓氏"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="名" prop="first_name">
|
||||
<el-input
|
||||
v-model="registerForm.first_name"
|
||||
placeholder="请输入名字"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input
|
||||
v-model="registerForm.department"
|
||||
placeholder="请输入部门"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="职位" prop="position">
|
||||
<el-input
|
||||
v-model="registerForm.position"
|
||||
placeholder="请输入职位"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="registerForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="password_confirm">
|
||||
<el-input
|
||||
v-model="registerForm.password_confirm"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="showRegister = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="registerLoading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { validateEmail, validatePhone } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 表单引用
|
||||
const loginFormRef = ref()
|
||||
const registerFormRef = ref()
|
||||
|
||||
// 登录表单
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 注册表单
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const registerLoading = ref(false)
|
||||
const rememberMe = ref(false)
|
||||
const showRegister = ref(false)
|
||||
|
||||
// 登录表单验证规则
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 注册表单验证规则
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在3到20个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (!validateEmail(value)) {
|
||||
callback(new Error('请输入正确的邮箱格式'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
first_name: [
|
||||
{ required: true, message: '请输入名字', trigger: 'blur' }
|
||||
],
|
||||
last_name: [
|
||||
{ required: true, message: '请输入姓氏', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (value && !validatePhone(value)) {
|
||||
callback(new Error('请输入正确的手机号格式'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
],
|
||||
password_confirm: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
const valid = await loginFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await authStore.login(loginForm)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
|
||||
// 跳转到目标页面或首页
|
||||
const redirect = route.query.redirect || '/'
|
||||
await router.push(redirect)
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
ElMessage.error('登录失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
const valid = await registerFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
registerLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await authStore.register(registerForm)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
showRegister.value = false
|
||||
|
||||
// 跳转到首页
|
||||
await router.push('/')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
ElMessage.error('注册失败,请稍后重试')
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置演示账号
|
||||
const setDemoAccount = (type) => {
|
||||
if (type === 'admin') {
|
||||
loginForm.username = 'admin'
|
||||
loginForm.password = 'admin123456'
|
||||
} else {
|
||||
loginForm.username = 'zhangsan'
|
||||
loginForm.password = 'test123456'
|
||||
}
|
||||
}
|
||||
|
||||
// 重置注册表单
|
||||
const resetRegisterForm = () => {
|
||||
Object.assign(registerForm, {
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
registerFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 监听注册对话框关闭
|
||||
const handleRegisterDialogClose = () => {
|
||||
resetRegisterForm()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题
|
||||
appStore.initTheme()
|
||||
|
||||
// 如果已登录,直接跳转到首页
|
||||
if (authStore.isLoggedIn) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.login-demo {
|
||||
margin-top: 24px;
|
||||
|
||||
.demo-accounts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.login-form-wrapper {
|
||||
background: rgba(29, 30, 31, 0.95);
|
||||
|
||||
.login-title {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 480px) {
|
||||
.login-form-wrapper {
|
||||
padding: 24px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-demo {
|
||||
.demo-accounts {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
.el-checkbox__label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
461
frontend/src/views/Profile.vue
Normal file
461
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">个人中心</h1>
|
||||
<p class="page-description">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 个人信息卡片 -->
|
||||
<el-col :xs="24" :lg="8">
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<h3>个人信息</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="profile-info">
|
||||
<!-- 头像 -->
|
||||
<div class="avatar-section">
|
||||
<el-avatar :size="80" :src="userInfo.avatar">
|
||||
{{ userInfo.username?.charAt(0)?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<div class="avatar-actions">
|
||||
<el-button size="small" type="text">更换头像</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="info-list">
|
||||
<div class="info-item">
|
||||
<label>用户名</label>
|
||||
<span>{{ userInfo.username }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>姓名</label>
|
||||
<span>{{ userInfo.full_name || '未设置' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>邮箱</label>
|
||||
<span>{{ userInfo.email || '未设置' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>部门</label>
|
||||
<span>{{ userInfo.department || '未设置' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>职位</label>
|
||||
<span>{{ userInfo.position || '未设置' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>角色</label>
|
||||
<el-tag :type="getRoleType(userInfo)">
|
||||
{{ getUserRoleText(userInfo) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>注册时间</label>
|
||||
<span>{{ formatDate(userInfo.date_joined) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- 编辑表单 -->
|
||||
<el-col :xs="24" :lg="16">
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<h3>编辑资料</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
class="cool-form"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓氏" prop="last_name">
|
||||
<el-input
|
||||
v-model="form.last_name"
|
||||
placeholder="请输入姓氏"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="名字" prop="first_name">
|
||||
<el-input
|
||||
v-model="form.first_name"
|
||||
placeholder="请输入名字"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="请输入邮箱"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input
|
||||
v-model="form.department"
|
||||
placeholder="请输入部门"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="职位" prop="position">
|
||||
<el-input
|
||||
v-model="form.position"
|
||||
placeholder="请输入职位"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存修改
|
||||
</el-button>
|
||||
|
||||
<el-button @click="handleReset">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<div class="cool-card mt-20">
|
||||
<div class="card-header">
|
||||
<h3>修改密码</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
class="cool-form"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="old_password">
|
||||
<el-input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="new_password">
|
||||
<el-input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="passwordSubmitting"
|
||||
@click="handlePasswordSubmit"
|
||||
>
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate, getUserRoleText, validateEmail, validatePhone } from '@/utils'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
const passwordFormRef = ref()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const passwordSubmitting = ref(false)
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({})
|
||||
|
||||
// 编辑表单
|
||||
const form = reactive({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
// 密码表单
|
||||
const passwordForm = reactive({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
first_name: [
|
||||
{ required: true, message: '请输入名字', trigger: 'blur' }
|
||||
],
|
||||
last_name: [
|
||||
{ required: true, message: '请输入姓氏', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (!validateEmail(value)) {
|
||||
callback(new Error('请输入正确的邮箱格式'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (value && !validatePhone(value)) {
|
||||
callback(new Error('请输入正确的手机号格式'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 密码表单验证规则
|
||||
const passwordRules = {
|
||||
old_password: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{ validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.new_password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取角色类型
|
||||
const getRoleType = (user) => {
|
||||
if (user.is_superuser) return 'danger'
|
||||
if (user.is_staff) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
const user = authStore.user
|
||||
if (user) {
|
||||
userInfo.value = { ...user }
|
||||
Object.assign(form, {
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
phone: user.phone || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const result = await authStore.updateUserInfo(form)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('个人信息更新成功')
|
||||
// 更新本地用户信息
|
||||
initFormData()
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新个人信息失败:', error)
|
||||
ElMessage.error('更新失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
initFormData()
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 提交密码表单
|
||||
const handlePasswordSubmit = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
|
||||
const valid = await passwordFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
passwordSubmitting.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用修改密码的API
|
||||
// 由于后端没有实现,暂时模拟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
ElMessage.success('密码修改成功')
|
||||
|
||||
// 重置密码表单
|
||||
Object.assign(passwordForm, {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
passwordFormRef.value?.resetFields()
|
||||
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
ElMessage.error('修改密码失败,请稍后重试')
|
||||
} finally {
|
||||
passwordSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
initFormData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-info {
|
||||
.avatar-section {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.avatar-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #303133;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.profile-info {
|
||||
.info-list {
|
||||
.info-item {
|
||||
border-color: #4c4d4f;
|
||||
|
||||
label {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
706
frontend/src/views/reports/ReportDetail.vue
Normal file
706
frontend/src/views/reports/ReportDetail.vue
Normal file
@@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">日报详情</h1>
|
||||
<p class="page-description">查看日报的详细内容</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 日报内容 -->
|
||||
<template v-else-if="report">
|
||||
<!-- 日报信息卡片 -->
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<div class="report-header">
|
||||
<div class="report-info">
|
||||
<h3>{{ formatDate(report.report_date) }} 的日报</h3>
|
||||
<div class="report-meta">
|
||||
<span class="author">{{ report.user.full_name }}</span>
|
||||
<span class="department">{{ report.user.department || '未设置部门' }}</span>
|
||||
<el-tag :type="report.is_draft ? 'warning' : 'success'" size="small">
|
||||
{{ report.is_draft ? '草稿' : '已发布' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon><Back /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="report.can_edit"
|
||||
type="primary"
|
||||
:icon="Edit"
|
||||
@click="editReport"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-dropdown v-if="report.can_edit || report.can_delete" @command="handleCommand">
|
||||
<el-button :icon="More">
|
||||
更多
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="report.can_edit"
|
||||
:command="report.is_draft ? 'publish' : 'draft'"
|
||||
>
|
||||
{{ report.is_draft ? '发布日报' : '设为草稿' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="report.can_delete"
|
||||
command="delete"
|
||||
class="danger-item"
|
||||
>
|
||||
删除日报
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 工作总结 -->
|
||||
<div class="content-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
工作总结
|
||||
</h4>
|
||||
<div class="content-html" v-html="report.work_summary"></div>
|
||||
</div>
|
||||
|
||||
<!-- 明日计划 -->
|
||||
<div class="content-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
明日计划
|
||||
</h4>
|
||||
<div class="content-html" v-html="report.next_day_plan"></div>
|
||||
</div>
|
||||
|
||||
<!-- 遇到的困难 -->
|
||||
<div v-if="report.difficulties" class="content-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon><Warning /></el-icon>
|
||||
遇到的困难
|
||||
</h4>
|
||||
<div class="content-text">{{ report.difficulties }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 建议或意见 -->
|
||||
<div v-if="report.suggestions" class="content-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
建议或意见
|
||||
</h4>
|
||||
<div class="content-text">{{ report.suggestions }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<div class="time-info">
|
||||
<div class="time-item">
|
||||
<span class="time-label">提交时间:</span>
|
||||
<span class="time-value">{{ formatDateTime(report.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="report.updated_at !== report.created_at" class="time-item">
|
||||
<span class="time-label">更新时间:</span>
|
||||
<span class="time-value">{{ formatDateTime(report.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论区域 -->
|
||||
<div class="cool-card mt-20">
|
||||
<div class="card-header">
|
||||
<h3>评论 ({{ comments.length }})</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 评论输入框 -->
|
||||
<div class="comment-input">
|
||||
<el-input
|
||||
v-model="newComment"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="写下你的评论..."
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
<div class="comment-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="commentSubmitting"
|
||||
:disabled="!newComment.trim()"
|
||||
@click="submitComment"
|
||||
>
|
||||
发表评论
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<div v-if="comments.length > 0" class="comments-list">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="comment-header">
|
||||
<div class="comment-author">
|
||||
<el-avatar :size="32">{{ comment.user.full_name.charAt(0) }}</el-avatar>
|
||||
<div class="author-info">
|
||||
<div class="author-name">{{ comment.user.full_name }}</div>
|
||||
<div class="comment-time">{{ fromNow(comment.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canDeleteComment(comment)" class="comment-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="danger-button"
|
||||
@click="deleteComment(comment)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-content">{{ comment.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-comments">
|
||||
<el-empty description="暂无评论" :image-size="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 日报不存在 -->
|
||||
<div v-else class="not-found">
|
||||
<el-result
|
||||
icon="warning"
|
||||
title="日报不存在"
|
||||
sub-title="您访问的日报不存在或已被删除"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goBack">返回</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Back,
|
||||
Edit,
|
||||
More,
|
||||
ArrowDown,
|
||||
Document,
|
||||
Calendar,
|
||||
Warning,
|
||||
ChatDotRound,
|
||||
Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useReportsStore } from '@/stores/reports'
|
||||
import { request } from '@/utils/request'
|
||||
import { formatDate, formatDateTime, fromNow } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const reportsStore = useReportsStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(true)
|
||||
const commentSubmitting = ref(false)
|
||||
const newComment = ref('')
|
||||
const comments = ref([])
|
||||
|
||||
// 计算属性
|
||||
const report = computed(() => reportsStore.currentReport)
|
||||
|
||||
// 检查是否可以删除评论
|
||||
const canDeleteComment = (comment) => {
|
||||
return comment.user.id === authStore.user?.id || authStore.isAdmin
|
||||
}
|
||||
|
||||
// 获取日报详情
|
||||
const fetchReportDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await reportsStore.fetchReportDetail(route.params.id)
|
||||
if (result.success) {
|
||||
// 获取评论
|
||||
await fetchComments()
|
||||
} else {
|
||||
ElMessage.error('获取日报详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日报详情失败:', error)
|
||||
ElMessage.error('获取日报详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取评论列表
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const response = await request.get(`/reports/${route.params.id}/comments/`)
|
||||
comments.value = response.results || response || []
|
||||
} catch (error) {
|
||||
console.error('获取评论失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评论
|
||||
const submitComment = async () => {
|
||||
if (!newComment.value.trim()) return
|
||||
|
||||
commentSubmitting.value = true
|
||||
try {
|
||||
const response = await request.post(`/reports/${route.params.id}/comments/`, {
|
||||
content: newComment.value.trim()
|
||||
})
|
||||
|
||||
comments.value.unshift(response)
|
||||
newComment.value = ''
|
||||
ElMessage.success('评论发表成功')
|
||||
} catch (error) {
|
||||
console.error('发表评论失败:', error)
|
||||
ElMessage.error('发表评论失败')
|
||||
} finally {
|
||||
commentSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
const deleteComment = async (comment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条评论吗?', '确认删除', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await request.delete(`/comments/${comment.id}/`)
|
||||
|
||||
const index = comments.value.findIndex(c => c.id === comment.id)
|
||||
if (index !== -1) {
|
||||
comments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
ElMessage.success('评论删除成功')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除评论失败:', error)
|
||||
ElMessage.error('删除评论失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑日报
|
||||
const editReport = () => {
|
||||
router.push(`/reports/${route.params.id}/edit`)
|
||||
}
|
||||
|
||||
// 处理下拉菜单命令
|
||||
const handleCommand = async (command) => {
|
||||
switch (command) {
|
||||
case 'publish':
|
||||
case 'draft':
|
||||
await toggleDraftStatus()
|
||||
break
|
||||
case 'delete':
|
||||
await deleteReport()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 切换草稿状态
|
||||
const toggleDraftStatus = async () => {
|
||||
try {
|
||||
const action = report.value.is_draft ? '发布' : '设为草稿'
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}这份日报吗?`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const result = await reportsStore.toggleDraftStatus(report.value.id)
|
||||
if (result.success) {
|
||||
ElMessage.success(`${action}成功`)
|
||||
// 重新获取详情
|
||||
await fetchReportDetail()
|
||||
} else {
|
||||
ElMessage.error(`${action}失败`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('切换草稿状态失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除日报
|
||||
const deleteReport = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这份日报吗?删除后无法恢复。',
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}
|
||||
)
|
||||
|
||||
const result = await reportsStore.deleteReport(report.value.id)
|
||||
if (result.success) {
|
||||
ElMessage.success('删除成功')
|
||||
router.push('/reports')
|
||||
} else {
|
||||
ElMessage.error(result.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除日报失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchReportDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.report-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
|
||||
.author {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
|
||||
.el-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.content-html {
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 12px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
border-left: 4px solid #409eff;
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.content-text {
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.time-info {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
|
||||
.time-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
color: #909399;
|
||||
margin-right: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.comment-actions {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
.comment-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.comment-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.author-info {
|
||||
margin-left: 12px;
|
||||
|
||||
.author-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
.danger-button {
|
||||
color: #f56c6c;
|
||||
|
||||
&:hover {
|
||||
color: #f78989;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
padding-left: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-comments {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
// 下拉菜单危险项样式
|
||||
:deep(.danger-item) {
|
||||
color: #f56c6c;
|
||||
|
||||
&:hover {
|
||||
color: #f56c6c;
|
||||
background-color: #fef0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.report-header {
|
||||
.report-info {
|
||||
h3 {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
.author {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
.section-title {
|
||||
color: #e5eaf3;
|
||||
border-color: #4c4d4f;
|
||||
}
|
||||
|
||||
.content-html {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
|
||||
.time-info {
|
||||
border-color: #4c4d4f;
|
||||
|
||||
.time-item {
|
||||
.time-label {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
.comment-item {
|
||||
border-color: #4c4d4f;
|
||||
|
||||
.comment-header {
|
||||
.comment-author {
|
||||
.author-info {
|
||||
.author-name {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: #cfd3dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.report-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.report-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
.comment-content {
|
||||
padding-left: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
451
frontend/src/views/reports/ReportForm.vue
Normal file
451
frontend/src/views/reports/ReportForm.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑日报' : '创建日报' }}</h1>
|
||||
<p class="page-description">{{ isEdit ? '修改日报内容' : '填写今日工作总结和明日计划' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<h3>{{ isEdit ? '编辑日报' : '新建日报' }}</h3>
|
||||
<div class="header-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon><Back /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
class="cool-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<!-- 日报日期 -->
|
||||
<el-form-item label="日报日期" prop="report_date">
|
||||
<el-date-picker
|
||||
v-model="form.report_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledDate"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 工作总结 -->
|
||||
<el-form-item label="工作总结" prop="work_summary">
|
||||
<div class="editor-container">
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
mode="default"
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
/>
|
||||
<Editor
|
||||
v-model="form.work_summary"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
style="height: 300px; overflow-y: hidden;"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-tip">
|
||||
请详细描述今日完成的工作内容、取得的成果等
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 明日计划 -->
|
||||
<el-form-item label="明日计划" prop="next_day_plan">
|
||||
<div class="editor-container">
|
||||
<Toolbar
|
||||
:editor="planEditorRef"
|
||||
:default-config="toolbarConfig"
|
||||
mode="default"
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
/>
|
||||
<Editor
|
||||
v-model="form.next_day_plan"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
style="height: 200px; overflow-y: hidden;"
|
||||
@on-created="handlePlanCreated"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-tip">
|
||||
请规划明日的工作内容和目标
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 遇到的困难 -->
|
||||
<el-form-item label="遇到的困难" prop="difficulties">
|
||||
<el-input
|
||||
v-model="form.difficulties"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述工作中遇到的问题或困难(可选)"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 建议或意见 -->
|
||||
<el-form-item label="建议或意见" prop="suggestions">
|
||||
<el-input
|
||||
v-model="form.suggestions"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="对工作或团队的建议(可选)"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 草稿状态 -->
|
||||
<el-form-item label="发布状态">
|
||||
<el-radio-group v-model="form.is_draft">
|
||||
<el-radio :label="false">立即发布</el-radio>
|
||||
<el-radio :label="true">保存为草稿</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-tip">
|
||||
草稿状态的日报不会在列表中显示给其他人
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<el-form-item>
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitting ? '提交中...' : (isEdit ? '更新日报' : '提交日报') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
@click="handleReset"
|
||||
>
|
||||
重置
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
@click="goBack"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Back } from '@element-plus/icons-vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useReportsStore } from '@/stores/reports'
|
||||
import { getToday } from '@/utils'
|
||||
|
||||
// 导入编辑器样式
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const reportsStore = useReportsStore()
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
const editorRef = ref()
|
||||
const planEditorRef = ref()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
report_date: getToday(),
|
||||
work_summary: '',
|
||||
next_day_plan: '',
|
||||
difficulties: '',
|
||||
suggestions: '',
|
||||
is_draft: false
|
||||
})
|
||||
|
||||
// 编辑器配置
|
||||
const toolbarConfig = {
|
||||
excludeKeys: [
|
||||
'uploadImage',
|
||||
'uploadVideo',
|
||||
'insertTable',
|
||||
'codeBlock',
|
||||
'group-video'
|
||||
]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
MENU_CONF: {}
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
report_date: [
|
||||
{ required: true, message: '请选择日报日期', trigger: 'change' }
|
||||
],
|
||||
work_summary: [
|
||||
{ required: true, message: '请填写工作总结', trigger: 'blur' },
|
||||
{ min: 10, message: '工作总结至少需要10个字符', trigger: 'blur' }
|
||||
],
|
||||
next_day_plan: [
|
||||
{ required: true, message: '请填写明日计划', trigger: 'blur' },
|
||||
{ min: 10, message: '明日计划至少需要10个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 禁用日期(只能选择今天及之前的日期)
|
||||
const disabledDate = (time) => {
|
||||
return time.getTime() > Date.now()
|
||||
}
|
||||
|
||||
// 编辑器创建回调
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
const handlePlanCreated = (editor) => {
|
||||
planEditorRef.value = editor
|
||||
}
|
||||
|
||||
// 获取日报详情
|
||||
const fetchReportDetail = async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await reportsStore.fetchReportDetail(id)
|
||||
if (result.success) {
|
||||
const report = result.data
|
||||
Object.assign(form, {
|
||||
report_date: report.report_date,
|
||||
work_summary: report.work_summary,
|
||||
next_day_plan: report.next_day_plan,
|
||||
difficulties: report.difficulties || '',
|
||||
suggestions: report.suggestions || '',
|
||||
is_draft: report.is_draft
|
||||
})
|
||||
} else {
|
||||
ElMessage.error('获取日报详情失败')
|
||||
goBack()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日报详情失败:', error)
|
||||
ElMessage.error('获取日报详情失败')
|
||||
goBack()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
// 检查富文本内容
|
||||
if (!form.work_summary || form.work_summary.trim() === '<p><br></p>') {
|
||||
ElMessage.error('请填写工作总结')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.next_day_plan || form.next_day_plan.trim() === '<p><br></p>') {
|
||||
ElMessage.error('请填写明日计划')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
let result
|
||||
if (isEdit.value) {
|
||||
result = await reportsStore.updateReport(route.params.id, form)
|
||||
} else {
|
||||
result = await reportsStore.createReport(form)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
goBack()
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
ElMessage.error('提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置表单吗?所有未保存的内容将丢失。', '确认重置', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
if (isEdit.value) {
|
||||
// 编辑模式下重新获取数据
|
||||
await fetchReportDetail(route.params.id)
|
||||
} else {
|
||||
// 创建模式下重置为初始值
|
||||
Object.assign(form, {
|
||||
report_date: getToday(),
|
||||
work_summary: '',
|
||||
next_day_plan: '',
|
||||
difficulties: '',
|
||||
suggestions: '',
|
||||
is_draft: false
|
||||
})
|
||||
}
|
||||
|
||||
formRef.value?.clearValidate()
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
// 页面离开确认
|
||||
const handleBeforeUnload = (e) => {
|
||||
if (submitting.value) return
|
||||
|
||||
const message = '您有未保存的更改,确定要离开吗?'
|
||||
e.returnValue = message
|
||||
return message
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 如果是编辑模式,获取日报详情
|
||||
if (isEdit.value) {
|
||||
await fetchReportDetail(route.params.id)
|
||||
}
|
||||
|
||||
// 添加页面离开确认
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
// 清理
|
||||
onBeforeUnmount(() => {
|
||||
// 销毁编辑器
|
||||
if (editorRef.value) {
|
||||
editorRef.value.destroy()
|
||||
}
|
||||
if (planEditorRef.value) {
|
||||
planEditorRef.value.destroy()
|
||||
}
|
||||
|
||||
// 移除事件监听
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
border: 1px solid #ccc;
|
||||
z-index: 100;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式下编辑器样式调整
|
||||
.dark {
|
||||
.editor-container {
|
||||
border-color: #4c4d4f;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器样式覆盖
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-placeholder) {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
:deep(.w-e-toolbar) {
|
||||
background-color: var(--el-bg-color-page);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
:deep(.w-e-toolbar .w-e-bar-item button) {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
:deep(.w-e-toolbar .w-e-bar-item button:hover) {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
531
frontend/src/views/reports/ReportList.vue
Normal file
531
frontend/src/views/reports/ReportList.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">日报管理</h1>
|
||||
<p class="page-description">查看和管理{{ authStore.isAdmin ? '所有' : '我的' }}日报</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<div class="search-form">
|
||||
<el-form
|
||||
ref="searchFormRef"
|
||||
:model="searchForm"
|
||||
:inline="true"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="authStore.isAdmin" label="提交人">
|
||||
<el-input
|
||||
v-model="searchForm.user_username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.work_summary"
|
||||
placeholder="搜索工作总结"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="searchForm.is_draft"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="全部" :value="null" />
|
||||
<el-option label="已发布" :value="false" />
|
||||
<el-option label="草稿" :value="true" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="cool-card">
|
||||
<div class="card-header">
|
||||
<h3>日报列表</h3>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="createReport"
|
||||
>
|
||||
新建日报
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 表格 -->
|
||||
<div class="cool-table">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="reportList"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<el-table-column
|
||||
prop="report_date"
|
||||
label="日报日期"
|
||||
width="120"
|
||||
sortable="custom"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.report_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
v-if="authStore.isAdmin"
|
||||
prop="user.full_name"
|
||||
label="提交人"
|
||||
width="120"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ row.user.full_name }}</div>
|
||||
<div class="user-department">{{ row.user.department || '未设置' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="work_summary_preview"
|
||||
label="工作总结"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
|
||||
<el-table-column
|
||||
prop="next_day_plan_preview"
|
||||
label="明日计划"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
|
||||
<el-table-column
|
||||
prop="is_draft"
|
||||
label="状态"
|
||||
width="80"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_draft ? 'warning' : 'success'" size="small">
|
||||
{{ row.is_draft ? '草稿' : '已发布' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="created_at"
|
||||
label="提交时间"
|
||||
width="160"
|
||||
sortable="custom"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="200"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="View"
|
||||
@click="viewReport(row.id)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="Edit"
|
||||
@click="editReport(row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="row.is_draft ? 'Promotion' : 'DocumentCopy'"
|
||||
@click="toggleDraftStatus(row)"
|
||||
>
|
||||
{{ row.is_draft ? '发布' : '设为草稿' }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="canDelete(row)"
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
class="danger-button"
|
||||
@click="deleteReport(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Refresh,
|
||||
Plus,
|
||||
View,
|
||||
Edit,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useReportsStore } from '@/stores/reports'
|
||||
import { formatDate, formatDateTime } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const reportsStore = useReportsStore()
|
||||
|
||||
// 表单引用
|
||||
const searchFormRef = ref()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const dateRange = ref([])
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
user_username: '',
|
||||
work_summary: '',
|
||||
is_draft: null
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const reportList = computed(() => reportsStore.reports)
|
||||
const pagination = computed(() => reportsStore.pagination)
|
||||
|
||||
// 检查是否可以编辑
|
||||
const canEdit = (row) => {
|
||||
return row.user.id === authStore.user?.id || authStore.isAdmin
|
||||
}
|
||||
|
||||
// 检查是否可以删除
|
||||
const canDelete = (row) => {
|
||||
return row.user.id === authStore.user?.id || authStore.isAdmin
|
||||
}
|
||||
|
||||
// 处理日期范围变化
|
||||
const handleDateRangeChange = (dates) => {
|
||||
if (dates && dates.length === 2) {
|
||||
reportsStore.setSearchParams({
|
||||
report_date_start: dates[0],
|
||||
report_date_end: dates[1]
|
||||
})
|
||||
} else {
|
||||
reportsStore.setSearchParams({
|
||||
report_date_start: '',
|
||||
report_date_end: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
// 设置搜索参数
|
||||
reportsStore.setSearchParams(searchForm)
|
||||
|
||||
// 重置分页
|
||||
reportsStore.setPagination({ current: 1 })
|
||||
|
||||
// 获取数据
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
// 重置表单
|
||||
Object.assign(searchForm, {
|
||||
user_username: '',
|
||||
work_summary: '',
|
||||
is_draft: null
|
||||
})
|
||||
dateRange.value = []
|
||||
|
||||
// 重置搜索参数
|
||||
reportsStore.resetSearchParams()
|
||||
|
||||
// 重置分页
|
||||
reportsStore.setPagination({ current: 1 })
|
||||
|
||||
// 获取数据
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 处理排序变化
|
||||
const handleSortChange = ({ prop, order }) => {
|
||||
const orderMap = {
|
||||
ascending: '',
|
||||
descending: '-'
|
||||
}
|
||||
|
||||
const ordering = order ? `${orderMap[order]}${prop}` : ''
|
||||
fetchReports({ ordering })
|
||||
}
|
||||
|
||||
// 处理页码变化
|
||||
const handleCurrentChange = (page) => {
|
||||
reportsStore.setPagination({ current: page })
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 处理页大小变化
|
||||
const handleSizeChange = (size) => {
|
||||
reportsStore.setPagination({ current: 1, pageSize: size })
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 获取日报列表
|
||||
const fetchReports = async (params = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await reportsStore.fetchReports(params)
|
||||
} catch (error) {
|
||||
console.error('获取日报列表失败:', error)
|
||||
ElMessage.error('获取日报列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建日报
|
||||
const createReport = () => {
|
||||
router.push('/reports/create')
|
||||
}
|
||||
|
||||
// 查看日报
|
||||
const viewReport = (id) => {
|
||||
router.push(`/reports/${id}`)
|
||||
}
|
||||
|
||||
// 编辑日报
|
||||
const editReport = (id) => {
|
||||
router.push(`/reports/${id}/edit`)
|
||||
}
|
||||
|
||||
// 切换草稿状态
|
||||
const toggleDraftStatus = async (row) => {
|
||||
try {
|
||||
const action = row.is_draft ? '发布' : '设为草稿'
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}这份日报吗?`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const result = await reportsStore.toggleDraftStatus(row.id)
|
||||
if (result.success) {
|
||||
ElMessage.success(`${action}成功`)
|
||||
} else {
|
||||
ElMessage.error(`${action}失败`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('切换草稿状态失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除日报
|
||||
const deleteReport = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这份日报吗?删除后无法恢复。',
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}
|
||||
)
|
||||
|
||||
const result = await reportsStore.deleteReport(row.id)
|
||||
if (result.success) {
|
||||
ElMessage.success('删除成功')
|
||||
|
||||
// 如果当前页没有数据了,回到上一页
|
||||
if (reportList.value.length === 1 && pagination.value.current > 1) {
|
||||
reportsStore.setPagination({ current: pagination.value.current - 1 })
|
||||
}
|
||||
|
||||
// 重新获取数据
|
||||
fetchReports()
|
||||
} else {
|
||||
ElMessage.error(result.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除日报失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchReports()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.danger-button {
|
||||
color: #f56c6c;
|
||||
|
||||
&:hover {
|
||||
color: #f78989;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
.dark {
|
||||
.user-info {
|
||||
.user-name {
|
||||
color: #e5eaf3;
|
||||
}
|
||||
|
||||
.user-department {
|
||||
color: #a3a6ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.search-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-form-item__content {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form--inline .el-form-item) {
|
||||
display: block;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
text-align: center;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
22
frontend/vue.config.js
Normal file
22
frontend/vue.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
devServer: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
css: {
|
||||
loaderOptions: {
|
||||
sass: {
|
||||
additionalData: `@import "@/styles/variables.scss";`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user