项目初始化

parents
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=project_management
JWT_SECRET=project_mgmt_secret_key_2024
PORT=3000
UPLOAD_DIR=uploads
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "project-management-backend",
"version": "1.0.0",
"description": "Project Management System Backend",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.5",
"node-cron": "^4.2.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/multer": "^1.4.11",
"@types/node": "^20.10.0",
"@types/node-cron": "^3.0.11",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.2"
}
}
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST || '192.168.0.16',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '123456',
database: process.env.DB_NAME || 'project_management',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: '+08:00',
charset: 'utf8mb4'
});
export default pool;
-- 创建数据库
SET NAMES utf8mb4;
SET sql_mode = '';
CREATE DATABASE IF NOT EXISTS project_management DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE project_management;
-- 角色表
CREATE TABLE IF NOT EXISTS roles (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
description VARCHAR(255) COMMENT '角色描述',
permissions JSON COMMENT '权限列表',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='角色表';
-- 员工表
CREATE TABLE IF NOT EXISTS employees (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL COMMENT '姓名',
password VARCHAR(255) NOT NULL COMMENT '密码',
email VARCHAR(100) UNIQUE COMMENT '邮箱',
phone VARCHAR(20) COMMENT '电话',
role VARCHAR(50) NOT NULL DEFAULT '开发' COMMENT '角色',
department VARCHAR(50) COMMENT '部门',
entry_date DATE COMMENT '入职时间',
status ENUM('在职','离职') NOT NULL DEFAULT '在职' COMMENT '状态',
annual_leave_days DECIMAL(5,1) DEFAULT 3.0 COMMENT '年假天数',
used_leave_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '已用年假天数',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='员工表';
-- 项目表
CREATE TABLE IF NOT EXISTS projects (
id INT PRIMARY KEY AUTO_INCREMENT,
project_no VARCHAR(50) UNIQUE NOT NULL COMMENT '项目编号',
project_name VARCHAR(100) NOT NULL COMMENT '项目名称',
manager_id INT COMMENT '项目经理ID',
project_type VARCHAR(50) COMMENT '项目类型',
client VARCHAR(100) COMMENT '客户',
project_stage ENUM('立项','需求分析','设计','开发','测试','上线','完成','暂停') DEFAULT '立项' COMMENT '项目阶段',
plan_start_date DATE COMMENT '计划开始时间',
plan_end_date DATE COMMENT '计划结束时间',
description TEXT COMMENT '项目描述',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULL
) ENGINE=InnoDB COMMENT='项目表';
-- 文档表
CREATE TABLE IF NOT EXISTS documents (
id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL COMMENT '项目ID',
doc_name VARCHAR(200) NOT NULL COMMENT '文档名称',
doc_type ENUM('需求文档','设计文档','验收文档','技术文档','会议纪要','其他') NOT NULL COMMENT '文档类型',
attachment_url VARCHAR(500) COMMENT '附件路径',
attachment_name VARCHAR(200) COMMENT '附件原始名称',
content TEXT COMMENT '文档内容描述',
uploader_id INT COMMENT '上传人ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (uploader_id) REFERENCES employees(id) ON DELETE SET NULL
) ENGINE=InnoDB COMMENT='文档表';
-- 工时表
CREATE TABLE IF NOT EXISTS workhours (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
project_id INT NOT NULL COMMENT '项目ID',
work_date DATE NOT NULL COMMENT '工作日期',
hours DECIMAL(4,1) NOT NULL COMMENT '工时/h',
description TEXT COMMENT '工作描述',
is_outside TINYINT(1) DEFAULT 0 COMMENT '是否外出',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='工时表';
-- 加班表
CREATE TABLE IF NOT EXISTS overtimes (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
project_id INT NOT NULL COMMENT '项目ID',
overtime_date DATE NOT NULL COMMENT '加班日期',
hours DECIMAL(4,1) NOT NULL COMMENT '加班时间/h',
description TEXT COMMENT '工作描述',
status ENUM('待审批','已批准','已拒绝') DEFAULT '待审批' COMMENT '审批状态',
remaining_hours DECIMAL(4,1) COMMENT '剩余可调休小时',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='加班表';
-- 调休表
CREATE TABLE IF NOT EXISTS compensatory_leaves (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
leave_date DATE NOT NULL COMMENT '调休日期',
hours DECIMAL(4,1) NOT NULL COMMENT '调休时长/h',
description TEXT COMMENT '说明',
status ENUM('待审批','已批准','已拒绝') DEFAULT '待审批' COMMENT '状态',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='调休表';
-- 年假记录表
CREATE TABLE IF NOT EXISTS annual_leave_records (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
leave_date DATE NOT NULL COMMENT '请假日期',
days DECIMAL(4,1) NOT NULL COMMENT '请假天数',
description TEXT COMMENT '说明',
status ENUM('待审批','已批准','已拒绝') DEFAULT '待审批' COMMENT '状态',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='年假记录表';
-- 操作日志表
CREATE TABLE IF NOT EXISTS operation_logs (
id INT PRIMARY KEY AUTO_INCREMENT,
module VARCHAR(50) NOT NULL COMMENT '操作模块',
rx_action VARCHAR(255) NOT NULL COMMENT '操作描述',
operator_id INT COMMENT '操作人ID',
operator_name VARCHAR(50) COMMENT '操作人姓名',
operator_role VARCHAR(50) COMMENT '操作人角色',
ip VARCHAR(50) COMMENT 'IP地址',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
INDEX idx_module (module),
INDEX idx_operator (operator_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (operator_id) REFERENCES employees(id) ON DELETE SET NULL
) ENGINE=InnoDB COMMENT='操作日志表';
-- 插入默认角色
INSERT INTO roles (name, description, permissions) VALUES
('管理员', '系统管理员,拥有所有权限', '["dashboard","projects","documents","employees","workhours","overtimes","compensatory","annual_leave","roles","logs"]'),
('项目经理', '项目管理人员', '["dashboard","projects","documents","employees","workhours","overtimes","compensatory","annual_leave"]'),
('开发', '开发人员', '["dashboard","projects","documents","workhours","overtimes","compensatory","annual_leave"]'),
('设计', '设计人员', '["dashboard","projects","documents","workhours","overtimes","compensatory","annual_leave"]'),
('财务', '财务人员', '["dashboard","projects","documents"]')
ON DUPLICATE KEY UPDATE name=name;
-- 插入默认管理员账号(密码: admin123)
INSERT INTO employees (name, password, email, role, department, entry_date, status) VALUES
('管理员', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin@company.com', '管理员', '管理部', '2020-01-01', '在职')
ON DUPLICATE KEY UPDATE name=name;
import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 计算年假天数
const calcAnnualLeaveDays = (entryDate: string): number => {
const entry = new Date(entryDate);
const now = new Date();
const yearsWorked = now.getFullYear() - entry.getFullYear();
return Math.min(3 + yearsWorked, 15); // 最多15天
};
// 获取年假列表
export const getAnnualLeaves = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, employee_id, status } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND a.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND a.employee_id = ?';
params.push(employee_id);
}
if (status) { where += ' AND a.status = ?'; params.push(status); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM annual_leave_records a ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT a.*, e.name as employee_name, e.annual_leave_days, e.used_leave_days
FROM annual_leave_records a
LEFT JOIN employees e ON a.employee_id = e.id
${where} ORDER BY a.leave_date DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 申请年假
export const createAnnualLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { leave_date, days, description } = req.body;
const employee_id = req.user?.id;
if (!leave_date || !days) {
res.status(400).json({ code: 400, message: '日期和天数不能为空' });
return;
}
// 获取员工年假信息
const [empRows] = await pool.execute(
'SELECT annual_leave_days, used_leave_days FROM employees WHERE id = ?',
[employee_id]
) as any[];
if (!(empRows as any[]).length) {
res.status(404).json({ code: 404, message: '员工不存在' });
return;
}
const emp = (empRows as any[])[0];
const remaining = Number(emp.annual_leave_days) - Number(emp.used_leave_days);
// 也统计待审批的申请
const [pendingRows] = await pool.execute(
`SELECT COALESCE(SUM(days), 0) as pending FROM annual_leave_records
WHERE employee_id = ? AND status = '待审批'`,
[employee_id]
) as any[];
const pendingDays = Number((pendingRows as any[])[0].pending);
const available = remaining - pendingDays;
if (Number(days) > available) {
res.status(400).json({
code: 400,
message: `年假天数不足,当前可用年假:${available}天(含待审批)`
});
return;
}
const [result] = await pool.execute(
`INSERT INTO annual_leave_records (employee_id, leave_date, days, description)
VALUES (?, ?, ?, ?)`,
[employee_id, leave_date, days, description]
) as any[];
// 记录日志
logOperation('年假管理', `提交了${days}天年假申请`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '年假申请已提交', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 审批年假
export const approveAnnualLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['已批准', '已拒绝'].includes(status)) {
res.status(400).json({ code: 400, message: '状态无效' });
return;
}
const [rows] = await pool.execute(
'SELECT employee_id, days, status as cur_status FROM annual_leave_records WHERE id = ?',
[id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
const record = (rows as any[])[0];
// 如果批准,更新员工已用年假
if (status === '已批准' && record.cur_status === '待审批') {
await pool.execute(
'UPDATE employees SET used_leave_days = used_leave_days + ? WHERE id = ?',
[record.days, record.employee_id]
);
}
await pool.execute('UPDATE annual_leave_records SET status = ? WHERE id = ?', [status, id]);
// 记录日志
const statusText = status === '已批准' ? '批准了' : '拒绝了';
logOperation('年假管理', `${statusText}年假申请(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '审批完成' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取年假余额
export const getAnnualLeaveBalance = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const employee_id = req.user?.id;
const [rows] = await pool.execute(
'SELECT annual_leave_days, used_leave_days, entry_date FROM employees WHERE id = ?',
[employee_id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '员工不存在' });
return;
}
const emp = (rows as any[])[0];
// 重新计算(可能跨年)
const currentEntitlement = calcAnnualLeaveDays(emp.entry_date);
res.json({
code: 200,
data: {
total_days: currentEntitlement,
used_days: emp.used_leave_days,
remaining_days: currentEntitlement - Number(emp.used_leave_days)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除年假申请
export const deleteAnnualLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute(
'SELECT employee_id, status FROM annual_leave_records WHERE id = ?', [id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
if ((rows as any[])[0].status !== '待审批') {
res.status(400).json({ code: 400, message: '只能删除待审批的申请' });
return;
}
if (req.user?.role !== '管理员' && (rows as any[])[0].employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权删除他人的年假申请' });
return;
}
await pool.execute('DELETE FROM annual_leave_records WHERE id = ?', [id]);
// 记录日志
logOperation('年假管理', `删除了年假申请(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import pool from '../config/database';
import { logOperation } from '../utils/logger';
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ code: 400, message: '邮箱和密码不能为空' });
return;
}
const [rows] = await pool.execute(
'SELECT * FROM employees WHERE email = ? AND status = "在职"',
[email]
) as any[];
if (!rows.length) {
res.status(401).json({ code: 401, message: '账号不存在或已离职' });
return;
}
const employee = rows[0];
const isMatch = await bcrypt.compare(password, employee.password);
if (!isMatch) {
res.status(401).json({ code: 401, message: '密码错误' });
return;
}
const token = jwt.sign(
{ id: employee.id, role: employee.role, name: employee.name },
process.env.JWT_SECRET || 'secret',
{ expiresIn: '8h' }
);
// 记录登录日志
logOperation('系统登录', `登录了系统`, employee.id, employee.name, employee.role);
res.json({
code: 200,
message: '登录成功',
data: {
token,
user: {
id: employee.id,
name: employee.name,
role: employee.role,
department: employee.department,
email: employee.email
}
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 获取调休列表
export const getCompensatoryLeaves = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, employee_id, status } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND c.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND c.employee_id = ?';
params.push(employee_id);
}
if (status) { where += ' AND c.status = ?'; params.push(status); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM compensatory_leaves c ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT c.*, e.name as employee_name
FROM compensatory_leaves c
LEFT JOIN employees e ON c.employee_id = e.id
${where} ORDER BY c.leave_date DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 申请调休
export const createCompensatoryLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { leave_date, hours, description } = req.body;
const employee_id = req.user?.id;
if (!leave_date || !hours) {
res.status(400).json({ code: 400, message: '日期和调休时长不能为空' });
return;
}
// 检查剩余加班时长(仅已批准的)
const [overtimeRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total FROM overtimes WHERE employee_id = ? AND status = '已批准'`,
[employee_id]
) as any[];
const [usedRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total FROM compensatory_leaves
WHERE employee_id = ? AND status != '已拒绝'`,
[employee_id]
) as any[];
const totalOvertime = Number((overtimeRows as any[])[0].total);
const usedCompensatory = Number((usedRows as any[])[0].total);
const remaining = totalOvertime - usedCompensatory;
if (Number(hours) > remaining) {
res.status(400).json({
code: 400,
message: `调休时长不足,当前可用调休:${remaining}小时`
});
return;
}
const [result] = await pool.execute(
`INSERT INTO compensatory_leaves (employee_id, leave_date, hours, description)
VALUES (?, ?, ?, ?)`,
[employee_id, leave_date, hours, description]
) as any[];
// 记录日志
logOperation('调休管理', `提交了${hours}小时调休申请`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '调休申请已提交', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 审批调休
export const approveCompensatoryLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['已批准', '已拒绝'].includes(status)) {
res.status(400).json({ code: 400, message: '状态无效' });
return;
}
await pool.execute('UPDATE compensatory_leaves SET status = ? WHERE id = ?', [status, id]);
// 记录日志
const statusText = status === '已批准' ? '批准了' : '拒绝了';
logOperation('调休管理', `${statusText}调休申请(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '审批完成' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除调休申请
export const deleteCompensatoryLeave = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT employee_id, status FROM compensatory_leaves WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
if ((rows as any[])[0].status !== '待审批') {
res.status(400).json({ code: 400, message: '只能删除待审批状态的申请' });
return;
}
if (req.user?.role !== '管理员' && (rows as any[])[0].employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权删除他人的调休申请' });
return;
}
await pool.execute('DELETE FROM compensatory_leaves WHERE id = ?', [id]);
// 记录日志
logOperation('调休管理', `删除了调休申请(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
import path from 'path';
import fs from 'fs';
// 获取文档列表
export const getDocuments = async (req: Request, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, project_id, doc_type, doc_name } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (project_id) { where += ' AND d.project_id = ?'; params.push(project_id); }
if (doc_type) { where += ' AND d.doc_type = ?'; params.push(doc_type); }
if (doc_name) { where += ' AND d.doc_name LIKE ?'; params.push(`%${doc_name}%`); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM documents d ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT d.*, p.project_name, e.name as uploader_name
FROM documents d
LEFT JOIN projects p ON d.project_id = p.id
LEFT JOIN employees e ON d.uploader_id = e.id
${where} ORDER BY d.id DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 新增文档
export const createDocument = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { project_id, doc_name, doc_type, content } = req.body;
if (!project_id || !doc_name || !doc_type) {
res.status(400).json({ code: 400, message: '项目、文档名称、文档类型不能为空' });
return;
}
let attachment_url = null;
let attachment_name = null;
if (req.file) {
attachment_url = `/uploads/${req.file.filename}`;
attachment_name = Buffer.from(req.file.originalname, 'latin1').toString('utf8')
}
const [result] = await pool.execute(
`INSERT INTO documents (project_id, doc_name, doc_type, attachment_url, attachment_name, content, uploader_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[project_id, doc_name, doc_type, attachment_url, attachment_name, content, req.user?.id]
) as any[];
// 记录日志
const docTypeName = doc_type || '文档';
logOperation('文档管理', `上传了${docTypeName}"${doc_name}"`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '文档创建成功', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新文档
export const updateDocument = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { doc_name, doc_type, content } = req.body;
const updates: string[] = [];
const params: any[] = [];
if (doc_name) { updates.push('doc_name = ?'); params.push(doc_name); }
if (doc_type) { updates.push('doc_type = ?'); params.push(doc_type); }
if (content !== undefined) { updates.push('content = ?'); params.push(content); }
if (req.file) {
updates.push('attachment_url = ?'); params.push(`/uploads/${req.file.filename}`);
updates.push('attachment_name = ?'); params.push(Buffer.from(req.file.originalname, 'latin1').toString('utf8'));
}
if (updates.length === 0) {
res.status(400).json({ code: 400, message: '没有要更新的字段' });
return;
}
params.push(id);
await pool.execute(`UPDATE documents SET ${updates.join(', ')} WHERE id = ?`, params);
// 记录日志
const docName = doc_name || `ID:${id}`;
logOperation('文档管理', `更新了文档"${docName}"`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除文档
export const deleteDocument = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
// 先查出附件路径和文档名
const [rows] = await pool.execute('SELECT attachment_url, doc_name FROM documents WHERE id = ?', [id]) as any[];
const docName = (rows as any[]).length ? (rows as any[])[0].doc_name : `ID:${id}`;
if ((rows as any[]).length && (rows as any[])[0].attachment_url) {
const filePath = path.join(process.cwd(), (rows as any[])[0].attachment_url);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
await pool.execute('DELETE FROM documents WHERE id = ?', [id]);
// 记录日志
logOperation('文档管理', `删除了文档"${docName}"`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
// 获取员工列表
export const getEmployees = async (req: Request, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, name, role, department, status } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (name) { where += ' AND name LIKE ?'; params.push(`%${name}%`); }
if (role) { where += ' AND role = ?'; params.push(role); }
if (department) { where += ' AND department LIKE ?'; params.push(`%${department}%`); }
if (status) { where += ' AND status = ?'; params.push(status); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM employees ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT id, name, email, phone, role, department, entry_date, status, annual_leave_days, used_leave_days, created_at
FROM employees ${where} ORDER BY id DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: countRows[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 新增员工
export const createEmployee = async (req: Request, res: Response): Promise<void> => {
try {
const { name, password, email, phone, role, department, entry_date, status } = req.body;
if (!name || !password || !email) {
res.status(400).json({ code: 400, message: '姓名、密码、邮箱不能为空' });
return;
}
let entryDate = entry_date || new Date().toISOString().slice(0, 10);
const [existing] = await pool.execute('SELECT id FROM employees WHERE email = ?', [email]) as any[];
if ((existing as any[]).length > 0) {
res.status(400).json({ code: 400, message: '该邮箱已存在' });
return;
}
const hashedPassword = await bcrypt.hash(password, 10);
// 计算初始年假天数
let annualLeaveDays = 3;
if (entryDate) {
const entryYear = new Date(entryDate).getFullYear();
const currentYear = new Date().getFullYear();
const yearsWorked = currentYear - entryYear;
annualLeaveDays = Math.min(3 + yearsWorked, 15);
}
const [result] = await pool.execute(
`INSERT INTO employees (name, password, email, phone, role, department, entry_date, status, annual_leave_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, hashedPassword, email, phone, role || '开发', department, entryDate, status || '在职', annualLeaveDays]
) as any[];
res.json({ code: 200, message: '员工创建成功', data: { id: result.insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新员工
export const updateEmployee = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { name, email, phone, role, department, entry_date, status, password } = req.body;
const updates: string[] = [];
const params: any[] = [];
let entryDate = entry_date || new Date().toISOString().slice(0, 10);
if (name) { updates.push('name = ?'); params.push(name); }
if (email) { updates.push('email = ?'); params.push(email); }
if (phone !== undefined) { updates.push('phone = ?'); params.push(phone); }
if (role) { updates.push('role = ?'); params.push(role); }
if (department !== undefined) { updates.push('department = ?'); params.push(department); }
if (entryDate) {
updates.push('entry_date = ?'); params.push(entryDate);
const entryYear = new Date(entryDate).getFullYear();
const currentYear = new Date().getFullYear();
const annualLeaveDays = Math.min(3 + (currentYear - entryYear), 15);
updates.push('annual_leave_days = ?'); params.push(annualLeaveDays);
}
if (status) { updates.push('status = ?'); params.push(status); }
if (password) {
const hashedPassword = await bcrypt.hash(password, 10);
updates.push('password = ?'); params.push(hashedPassword);
}
if (updates.length === 0) {
res.status(400).json({ code: 400, message: '没有要更新的字段' });
return;
}
params.push(id);
await pool.execute(`UPDATE employees SET ${updates.join(', ')} WHERE id = ?`, params);
res.json({ code: 200, message: '更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除员工
export const deleteEmployee = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
await pool.execute('UPDATE employees SET status = "离职" WHERE id = ?', [id]);
res.json({ code: 200, message: '已标记为离职' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取单个员工
export const getEmployee = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute(
`SELECT id, name, email, phone, role, department, entry_date, status, annual_leave_days, used_leave_days, created_at
FROM employees WHERE id = ?`,
[id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '员工不存在' });
return;
}
res.json({ code: 200, data: (rows as any[])[0] });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取当前用户信息
export const getProfile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user?.id;
const [rows] = await pool.execute(
`SELECT id, name, email, phone, role, department, entry_date, status, annual_leave_days, used_leave_days
FROM employees WHERE id = ?`,
[userId]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '用户不存在' });
return;
}
// 计算加班余额
const [overtimeRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total_overtime FROM overtimes WHERE employee_id = ?`,
[userId]
) as any[];
const [compensatoryRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total_used FROM compensatory_leaves WHERE employee_id = ? AND status = '已批准'`,
[userId]
) as any[];
const totalOvertime = Number((overtimeRows as any[])[0].total_overtime);
const totalUsed = Number((compensatoryRows as any[])[0].total_used);
const remainingCompensatory = totalOvertime - totalUsed;
res.json({
code: 200,
data: {
...(rows as any[])[0],
total_overtime_hours: totalOvertime,
used_compensatory_hours: totalUsed,
remaining_compensatory_hours: remainingCompensatory
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
// 日志模块列表(用于筛选下拉)
const LOG_MODULES = [
'系统登录',
'项目管理',
'文档管理',
'员工管理',
'工时管理',
'加班管理',
'调休管理',
'年假管理',
];
// 获取操作日志列表(分页 + 筛选)
export const getOperationLogs = async (req: Request, res: Response): Promise<void> => {
try {
const { page = 1, limit = 15, module, operator_name, start_date, end_date } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (module) {
where += ' AND module = ?';
params.push(module);
}
if (operator_name) {
where += ' AND operator_name LIKE ?';
params.push(`%${operator_name}%`);
}
if (start_date) {
where += ' AND created_at >= ?';
params.push(start_date);
}
if (end_date) {
where += ' AND created_at <= ?';
params.push(`${end_date} 23:59:59`);
}
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM operation_logs ${where}`,
params
) as any[];
const [rows] = await pool.execute(
`SELECT * FROM operation_logs ${where} ORDER BY created_at DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取日志模块列表
export const getLogModules = async (req: Request, res: Response): Promise<void> => {
res.json({ code: 200, data: LOG_MODULES });
};
// 清空日志
export const clearLogs = async (req: Request, res: Response): Promise<void> => {
try {
const { before_days } = req.body;
let result;
if (before_days) {
// 清除指定天数前的日志
[result] = await pool.execute(
'DELETE FROM operation_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)',
[Number(before_days)]
) as any[];
} else {
// 清空所有日志
[result] = await pool.execute('TRUNCATE TABLE operation_logs') as any[];
}
res.json({
code: 200,
message: `已清除 ${(result as any).affectedRows} 条日志`
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 导出操作日志(CSV格式)
export const exportOperationLogs = async (req: Request, res: Response): Promise<void> => {
try {
const { module, operator_name, start_date, end_date } = req.query;
let where = 'WHERE 1=1';
const params: any[] = [];
if (module) {
where += ' AND module = ?';
params.push(module);
}
if (operator_name) {
where += ' AND operator_name LIKE ?';
params.push(`%${operator_name}%`);
}
if (start_date) {
where += ' AND created_at >= ?';
params.push(start_date);
}
if (end_date) {
where += ' AND created_at <= ?';
params.push(`${end_date} 23:59:59`);
}
const [rows] = await pool.execute(
`SELECT id, module, rx_action, operator_name, operator_role, created_at
FROM operation_logs ${where} ORDER BY created_at DESC LIMIT 10000`,
params
) as any[];
// 生成CSV(带BOM,确保Excel正确识别中文)
const BOM = '\uFEFF';
const headers = ['ID', '操作模块', '操作描述', '操作人', '角色', '操作时间'];
const csvLines = [headers.join(',')];
for (const row of rows as any[]) {
const line = [
row.id,
`"${(row.module || '').replace(/"/g, '""')}"`,
`"${(row.rx_action || '').replace(/"/g, '""')}"`,
`"${(row.operator_name || '').replace(/"/g, '""')}"`,
`"${(row.operator_role || '').replace(/"/g, '""')}"`,
row.created_at ? new Date(row.created_at).toLocaleString('zh-CN') : ''
];
csvLines.push(line.join(','));
}
const csvContent = BOM + csvLines.join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=operation_logs_${Date.now()}.csv`);
res.send(csvContent);
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 获取加班列表
export const getOvertimes = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, employee_id, project_id, start_date, end_date } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND o.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND o.employee_id = ?';
params.push(employee_id);
}
if (project_id) { where += ' AND o.project_id = ?'; params.push(project_id); }
if (start_date) { where += ' AND o.overtime_date >= ?'; params.push(start_date); }
if (end_date) { where += ' AND o.overtime_date <= ?'; params.push(end_date); }
// status 筛选
const status = req.query.status as string;
if (status) { where += ' AND o.status = ?'; params.push(status); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM overtimes o ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT o.*, e.name as employee_name, p.project_name
FROM overtimes o
LEFT JOIN employees e ON o.employee_id = e.id
LEFT JOIN projects p ON o.project_id = p.id
${where} ORDER BY o.overtime_date DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 新增加班
export const createOvertime = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { project_id, overtime_date, hours, description } = req.body;
const employee_id = req.user?.id;
if (!project_id || !overtime_date || !hours) {
res.status(400).json({ code: 400, message: '项目、日期、加班时间不能为空' });
return;
}
const [result] = await pool.execute(
`INSERT INTO overtimes (employee_id, project_id, overtime_date, hours, description, status)
VALUES (?, ?, ?, ?, ?, '待审批')`,
[employee_id, project_id, overtime_date, hours, description]
) as any[];
// 记录日志
logOperation('加班管理', `提交了${hours}小时加班申请`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '加班填报已提交,等待审批', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新加班
export const updateOvertime = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { project_id, overtime_date, hours, description } = req.body;
const [rows] = await pool.execute('SELECT employee_id, status FROM overtimes WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
const record = (rows as any[])[0];
if (req.user?.role !== '管理员' && record.employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权修改他人的加班记录' });
return;
}
if (record.status !== '待审批') {
res.status(400).json({ code: 400, message: '只能编辑待审批状态的记录' });
return;
}
const updates: string[] = [];
const params: any[] = [];
if (project_id) { updates.push('project_id = ?'); params.push(project_id); }
if (overtime_date) { updates.push('overtime_date = ?'); params.push(overtime_date); }
if (hours) { updates.push('hours = ?'); params.push(hours); updates.push('remaining_hours = ?'); params.push(hours); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
params.push(id);
await pool.execute(`UPDATE overtimes SET ${updates.join(', ')} WHERE id = ?`, params);
// 记录日志
logOperation('加班管理', `更新了加班记录(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除加班
export const deleteOvertime = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT employee_id, status FROM overtimes WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
const record = (rows as any[])[0];
if (req.user?.role !== '管理员' && record.employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权删除他人的加班记录' });
return;
}
if (record.status === '已批准') {
res.status(400).json({ code: 400, message: '已批准的记录不可删除' });
return;
}
await pool.execute('DELETE FROM overtimes WHERE id = ?', [id]);
// 记录日志
logOperation('加班管理', `删除了加班记录(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 审批加班
export const approveOvertime = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['已批准', '已拒绝'].includes(status)) {
res.status(400).json({ code: 400, message: '状态无效' });
return;
}
const [rows] = await pool.execute(
'SELECT id, status as cur_status, hours FROM overtimes WHERE id = ?',
[id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
const record = (rows as any[])[0];
if (record.cur_status !== '待审批') {
res.status(400).json({ code: 400, message: '该记录已审批' });
return;
}
// 批准时更新 remaining_hours
if (status === '已批准') {
await pool.execute(
'UPDATE overtimes SET status = ?, remaining_hours = hours WHERE id = ?',
[status, id]
);
} else {
await pool.execute('UPDATE overtimes SET status = ? WHERE id = ?', [status, id]);
}
// 记录日志
const statusText = status === '已批准' ? '批准了' : '拒绝了';
logOperation('加班管理', `${statusText}加班申请(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '审批完成' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取加班余额
export const getOvertimeBalance = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const employeeId = req.user?.id;
const [overtimeRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total FROM overtimes WHERE employee_id = ? AND status = '已批准'`,
[employeeId]
) as any[];
const [usedRows] = await pool.execute(
`SELECT COALESCE(SUM(hours), 0) as total FROM compensatory_leaves
WHERE employee_id = ? AND status = '已批准'`,
[employeeId]
) as any[];
const total = Number((overtimeRows as any[])[0].total);
const used = Number((usedRows as any[])[0].total);
res.json({
code: 200,
data: {
total_overtime_hours: total,
used_compensatory_hours: used,
remaining_hours: total - used
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 获取项目列表
export const getProjects = async (req: Request, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, project_name, project_stage, project_type } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (project_name) { where += ' AND p.project_name LIKE ?'; params.push(`%${project_name}%`); }
if (project_stage) { where += ' AND p.project_stage = ?'; params.push(project_stage); }
if (project_type) { where += ' AND p.project_type LIKE ?'; params.push(`%${project_type}%`); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM projects p ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT p.*, e.name as manager_name
FROM projects p LEFT JOIN employees e ON p.manager_id = e.id
${where} ORDER BY p.id DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取单个项目
export const getProject = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute(
`SELECT p.*, e.name as manager_name
FROM projects p LEFT JOIN employees e ON p.manager_id = e.id
WHERE p.id = ?`,
[id]
) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '项目不存在' });
return;
}
res.json({ code: 200, data: (rows as any[])[0] });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 新增项目
export const createProject = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { project_no, project_name, manager_id, project_type, client, project_stage, plan_start_date, plan_end_date, description } = req.body;
if (!project_no || !project_name) {
res.status(400).json({ code: 400, message: '项目编号和项目名称不能为空' });
return;
}
const [existing] = await pool.execute('SELECT id FROM projects WHERE project_no = ?', [project_no]) as any[];
if ((existing as any[]).length > 0) {
res.status(400).json({ code: 400, message: '项目编号已存在' });
return;
}
const [result] = await pool.execute(
`INSERT INTO projects (project_no, project_name, manager_id, project_type, client, project_stage, plan_start_date, plan_end_date, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[project_no, project_name, manager_id || null, project_type, client, project_stage || '立项', plan_start_date || null, plan_end_date || null, description]
) as any[];
// 记录日志
logOperation('项目管理', `创建了项目"${project_name}"`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '项目创建成功', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新项目
export const updateProject = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { project_no, project_name, manager_id, project_type, client, project_stage, plan_start_date, plan_end_date, description } = req.body;
const updates: string[] = [];
const params: any[] = [];
if (project_no) { updates.push('project_no = ?'); params.push(project_no); }
if (project_name) { updates.push('project_name = ?'); params.push(project_name); }
if (manager_id !== undefined) { updates.push('manager_id = ?'); params.push(manager_id); }
if (project_type !== undefined) { updates.push('project_type = ?'); params.push(project_type); }
if (client !== undefined) { updates.push('client = ?'); params.push(client); }
if (project_stage) { updates.push('project_stage = ?'); params.push(project_stage); }
if (plan_start_date !== undefined) { updates.push('plan_start_date = ?'); params.push(plan_start_date); }
if (plan_end_date !== undefined) { updates.push('plan_end_date = ?'); params.push(plan_end_date); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
if (updates.length === 0) {
res.status(400).json({ code: 400, message: '没有要更新的字段' });
return;
}
params.push(id);
await pool.execute(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`, params);
// 记录日志
const projectName = project_name || `ID:${id}`;
logOperation('项目管理', `更新了项目"${projectName}"的信息`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除项目
export const deleteProject = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
// 查出项目名称用于日志
const [projRows] = await pool.execute('SELECT project_name FROM projects WHERE id = ?', [id]) as any[];
const projectName = (projRows as any[]).length ? (projRows as any[])[0].project_name : `ID:${id}`;
await pool.execute('DELETE FROM projects WHERE id = ?', [id]);
// 记录日志
logOperation('项目管理', `删除了项目"${projectName}"`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取所有项目(用于下拉选择)
export const getAllProjects = async (req: Request, res: Response): Promise<void> => {
try {
const [rows] = await pool.execute(
'SELECT id, project_no, project_name, project_stage FROM projects ORDER BY id DESC'
) as any[];
res.json({ code: 200, data: rows });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
import { Request, Response } from 'express';
import pool from '../config/database';
// 权限模块列表
const PERMISSION_MODULES = [
{ key: 'dashboard', label: '首页' },
{ key: 'projects', label: '项目管理' },
{ key: 'documents', label: '文档管理' },
{ key: 'employees', label: '员工管理' },
{ key: 'workhours', label: '工时管理' },
{ key: 'overtimes', label: '加班管理' },
{ key: 'compensatory', label: '调休管理' },
{ key: 'annual_leave', label: '年假管理' },
{ key: 'roles', label: '角色管理' },
{ key: 'logs', label: '操作日志' },
];
// 获取角色列表(分页)
export const getRoles = async (req: Request, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, name } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
if (name) {
where += ' AND name LIKE ?';
params.push(`%${name}%`);
}
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM roles ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT * FROM roles ${where} ORDER BY id ASC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: (rows as any[]).map(r => ({ ...r, permissions: parsePermissions(r.permissions) })),
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取所有角色(用于下拉选择)
export const getAllRoles = async (req: Request, res: Response): Promise<void> => {
try {
const [rows] = await pool.execute('SELECT id, name FROM roles ORDER BY id ASC') as any[];
res.json({ code: 200, data: rows });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取单个角色
export const getRole = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT * FROM roles WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '角色不存在' });
return;
}
const role = (rows as any[])[0];
role.permissions = parsePermissions(role.permissions);
res.json({ code: 200, data: role });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 创建角色
export const createRole = async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, permissions } = req.body;
if (!name) {
res.status(400).json({ code: 400, message: '角色名称不能为空' });
return;
}
// 检查角色名是否已存在
const [existing] = await pool.execute('SELECT id FROM roles WHERE name = ?', [name]) as any[];
if ((existing as any[]).length > 0) {
res.status(400).json({ code: 400, message: '该角色名称已存在' });
return;
}
const permissionsJson = JSON.stringify(permissions || []);
const [result] = await pool.execute(
'INSERT INTO roles (name, description, permissions) VALUES (?, ?, ?)',
[name, description || '', permissionsJson]
) as any[];
res.json({ code: 200, message: '角色创建成功', data: { id: result.insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新角色
export const updateRole = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { name, description, permissions } = req.body;
// 检查角色是否存在
const [existing] = await pool.execute('SELECT id FROM roles WHERE id = ?', [id]) as any[];
if (!(existing as any[]).length) {
res.status(404).json({ code: 404, message: '角色不存在' });
return;
}
// 检查角色名是否与其他角色冲突
if (name) {
const [dup] = await pool.execute('SELECT id FROM roles WHERE name = ? AND id != ?', [name, id]) as any[];
if ((dup as any[]).length > 0) {
res.status(400).json({ code: 400, message: '该角色名称已存在' });
return;
}
}
const updates: string[] = [];
const params: any[] = [];
if (name) { updates.push('name = ?'); params.push(name); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
if (permissions) { updates.push('permissions = ?'); params.push(JSON.stringify(permissions)); }
if (updates.length === 0) {
res.status(400).json({ code: 400, message: '没有要更新的字段' });
return;
}
params.push(id);
await pool.execute(`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`, params);
res.json({ code: 200, message: '角色更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除角色
export const deleteRole = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
// 检查是否有员工使用此角色
const [existing] = await pool.execute('SELECT id FROM roles WHERE id = ?', [id]) as any[];
if (!(existing as any[]).length) {
res.status(404).json({ code: 404, message: '角色不存在' });
return;
}
const roleName = (existing as any[])[0].name;
const [empRows] = await pool.execute('SELECT COUNT(*) as count FROM employees WHERE role = ?', [roleName]) as any[];
if ((empRows as any[])[0].count > 0) {
res.status(400).json({ code: 400, message: `该角色下有${(empRows as any[])[0].count}名员工,无法删除` });
return;
}
await pool.execute('DELETE FROM roles WHERE id = ?', [id]);
res.json({ code: 200, message: '角色删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 获取权限模块列表
export const getPermissionsList = async (req: Request, res: Response): Promise<void> => {
res.json({ code: 200, data: PERMISSION_MODULES });
};
// 获取某角色的权限
export const getRolePermissions = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT id, name, permissions FROM roles WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '角色不存在' });
return;
}
const role = (rows as any[])[0];
res.json({
code: 200,
data: {
id: role.id,
name: role.name,
permissions: parsePermissions(role.permissions)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 解析权限字段(可能是JSON字符串)
function parsePermissions(permissions: any): string[] {
if (!permissions) return [];
if (Array.isArray(permissions)) return permissions;
if (typeof permissions === 'string') {
try {
return JSON.parse(permissions);
} catch {
return [];
}
}
return [];
}
import { Request, Response } from 'express';
import ExcelJS from 'exceljs';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 获取工时列表
export const getWorkhours = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { page = 1, limit = 10, employee_id, project_id, start_date, end_date } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let where = 'WHERE 1=1';
const params: any[] = [];
// 非管理员只能看自己的
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND w.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND w.employee_id = ?';
params.push(employee_id);
}
if (project_id) { where += ' AND w.project_id = ?'; params.push(project_id); }
if (start_date) { where += ' AND w.work_date >= ?'; params.push(start_date); }
if (end_date) { where += ' AND w.work_date <= ?'; params.push(end_date); }
const [countRows] = await pool.execute(
`SELECT COUNT(*) as total FROM workhours w ${where}`, params
) as any[];
const [rows] = await pool.execute(
`SELECT w.*, e.name as employee_name, p.project_name
FROM workhours w
LEFT JOIN employees e ON w.employee_id = e.id
LEFT JOIN projects p ON w.project_id = p.id
${where} ORDER BY w.work_date DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
params
) as any[];
res.json({
code: 200,
data: {
list: rows,
total: (countRows as any[])[0].total,
page: Number(page),
limit: Number(limit)
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 新增工时
export const createWorkHour = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { project_id, work_date, hours, description, is_outside } = req.body;
const employee_id = req.user?.id;
if (!project_id || !work_date || !hours) {
res.status(400).json({ code: 400, message: '项目、日期、工时不能为空' });
return;
}
if (Number(hours) <= 0 || Number(hours) > 24) {
res.status(400).json({ code: 400, message: '工时必须在0-24之间' });
return;
}
const [result] = await pool.execute(
`INSERT INTO workhours (employee_id, project_id, work_date, hours, description, is_outside)
VALUES (?, ?, ?, ?, ?, ?)`,
[employee_id, project_id, work_date, hours, description, is_outside ? 1 : 0]
) as any[];
// 记录日志
logOperation('工时管理', `填报了${hours}小时工时`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '工时填报成功', data: { id: (result as any).insertId } });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 更新工时
export const updateWorkHour = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { project_id, work_date, hours, description, is_outside } = req.body;
// 验证是否是自己的记录
const [rows] = await pool.execute('SELECT employee_id FROM workhours WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
if (req.user?.role !== '管理员' && (rows as any[])[0].employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权修改他人的工时记录' });
return;
}
const updates: string[] = [];
const params: any[] = [];
if (project_id) { updates.push('project_id = ?'); params.push(project_id); }
if (work_date) { updates.push('work_date = ?'); params.push(work_date); }
if (hours) { updates.push('hours = ?'); params.push(hours); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
if (is_outside !== undefined) { updates.push('is_outside = ?'); params.push(is_outside ? 1 : 0); }
params.push(id);
await pool.execute(`UPDATE workhours SET ${updates.join(', ')} WHERE id = ?`, params);
// 记录日志
logOperation('工时管理', `更新了工时记录(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '更新成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 删除工时
export const deleteWorkHour = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const [rows] = await pool.execute('SELECT employee_id FROM workhours WHERE id = ?', [id]) as any[];
if (!(rows as any[]).length) {
res.status(404).json({ code: 404, message: '记录不存在' });
return;
}
if (req.user?.role !== '管理员' && (rows as any[])[0].employee_id !== req.user?.id) {
res.status(403).json({ code: 403, message: '无权删除他人的工时记录' });
return;
}
await pool.execute('DELETE FROM workhours WHERE id = ?', [id]);
// 记录日志
logOperation('工时管理', `删除了工时记录(ID:${id})`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '删除成功' });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 统计工时
export const getWorkHourStats = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { employee_id, project_id, year, month } = req.query;
let where = 'WHERE 1=1';
const params: any[] = [];
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND w.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND w.employee_id = ?';
params.push(employee_id);
}
if (project_id) { where += ' AND w.project_id = ?'; params.push(project_id); }
if (year) { where += ' AND YEAR(w.work_date) = ?'; params.push(year); }
if (month) { where += ' AND MONTH(w.work_date) = ?'; params.push(month); }
const [rows] = await pool.execute(
`SELECT e.name as employee_name, p.project_name,
SUM(w.hours) as total_hours, COUNT(*) as record_count
FROM workhours w
LEFT JOIN employees e ON w.employee_id = e.id
LEFT JOIN projects p ON w.project_id = p.id
${where}
GROUP BY w.employee_id, w.project_id`,
params
) as any[];
res.json({ code: 200, data: rows });
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
// 导出工时到 Excel
export const exportWorkhours = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { employee_id, project_id, start_date, end_date } = req.query;
let where = 'WHERE 1=1';
const params: any[] = [];
// 权限控制同列表接口
if (req.user?.role !== '管理员' && req.user?.role !== '项目经理') {
where += ' AND w.employee_id = ?';
params.push(req.user?.id);
} else if (employee_id) {
where += ' AND w.employee_id = ?';
params.push(employee_id);
}
if (project_id) { where += ' AND w.project_id = ?'; params.push(project_id); }
if (start_date) { where += ' AND w.work_date >= ?'; params.push(start_date); }
if (end_date) { where += ' AND w.work_date <= ?'; params.push(end_date); }
const [rows] = await pool.execute(
`SELECT w.*, e.name as employee_name, p.project_name
FROM workhours w
LEFT JOIN employees e ON w.employee_id = e.id
LEFT JOIN projects p ON w.project_id = p.id
${where} ORDER BY w.work_date DESC`,
params
) as any[];
// 生成 Excel
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('工时记录');
// 表头
sheet.columns = [
{ header: '员工', key: 'employee_name', width: 14 },
{ header: '项目', key: 'project_name', width: 22 },
{ header: '工作日期', key: 'work_date', width: 14 },
{ header: '工时(h)', key: 'hours', width: 10 },
{ header: '工作描述', key: 'description', width: 40 },
{ header: '是否外出', key: 'is_outside', width: 10 },
];
// 样式:表头加粗居中
const headerRow = sheet.getRow(1);
headerRow.font = { bold: true };
headerRow.alignment = { horizontal: 'center', vertical: 'middle' };
headerRow.height = 22;
// 填充数据
for (const row of rows as any[]) {
sheet.addRow({
employee_name: row.employee_name,
project_name: row.project_name,
work_date: row.work_date ? new Date(row.work_date).toISOString().slice(0, 10) : '',
hours: row.hours,
description: row.description || '',
is_outside: row.is_outside ? '外出' : '在司',
});
}
// 数据行对齐
for (let i = 2; i <= sheet.rowCount; i++) {
sheet.getRow(i).alignment = { vertical: 'middle' };
}
// 设置响应头
const filename = `工时记录_${new Date().toISOString().slice(0, 10)}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}`);
await workbook.xlsx.write(res);
res.end();
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '导出失败' });
}
};
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import authRoutes from './routes/auth.routes';
import employeeRoutes from './routes/employee.routes';
import projectRoutes from './routes/project.routes';
import documentRoutes from './routes/document.routes';
import workHourRoutes from './routes/workhour.routes';
import overtimeRoutes from './routes/overtime.routes';
import compensatoryRoutes from './routes/compensatory.routes';
import annualLeaveRoutes from './routes/annualleave.routes';
import roleRoutes from './routes/role.routes';
import logRoutes from './routes/log.routes';
import { startLogCleanupScheduler } from './utils/scheduler';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 静态文件(上传的附件)
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// 路由
app.use('/api/auth', authRoutes);
app.use('/api/employees', employeeRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/workhours', workHourRoutes);
app.use('/api/overtimes', overtimeRoutes);
app.use('/api/compensatory', compensatoryRoutes);
app.use('/api/annual-leave', annualLeaveRoutes);
app.use('/api/roles', roleRoutes);
app.use('/api/logs', logRoutes);
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ code: 200, message: 'Server is running', timestamp: new Date() });
});
app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
// 启动日志自动清理定时器
startLogCleanupScheduler();
});
export default app;
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import pool from '../config/database';
export interface AuthRequest extends Request {
user?: {
id: number;
role: string;
name: string;
permissions?: string[];
};
}
export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction): void => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ code: 401, message: '未登录,请先登录' });
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret') as {
id: number;
role: string;
name: string;
};
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ code: 401, message: 'Token无效或已过期' });
}
};
export const adminMiddleware = (req: AuthRequest, res: Response, next: NextFunction): void => {
if (req.user?.role !== '项目经理' && req.user?.role !== '管理员') {
res.status(403).json({ code: 403, message: '权限不足' });
return;
}
next();
};
// 权限中间件:检查当前用户角色是否拥有指定模块的权限
export const permissionMiddleware = (moduleKey: string) => {
return async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
try {
const role = req.user?.role;
if (!role) {
res.status(403).json({ code: 403, message: '权限不足' });
return;
}
// 管理员和项目经理拥有所有权限
if (role === '管理员' || role === '项目经理') {
next();
return;
}
// 从数据库查询角色的权限
const [rows] = await pool.execute(
'SELECT permissions FROM roles WHERE name = ?',
[role]
) as any[];
if (!(rows as any[]).length) {
res.status(403).json({ code: 403, message: '权限不足' });
return;
}
const permissions = parsePermissions((rows as any[])[0].permissions);
if (permissions.includes(moduleKey)) {
// 将权限注入到 req.user 中供后续使用
req.user!.permissions = permissions;
next();
} else {
res.status(403).json({ code: 403, message: '权限不足' });
}
} catch (error) {
console.error('权限检查错误:', error);
res.status(500).json({ code: 500, message: '服务器错误' });
}
};
};
// 解析权限字段
function parsePermissions(permissions: any): string[] {
if (!permissions) return [];
if (Array.isArray(permissions)) return permissions;
if (typeof permissions === 'string') {
try {
return JSON.parse(permissions);
} catch {
return [];
}
}
return [];
}
import { Router } from 'express';
import {
getAnnualLeaves, createAnnualLeave,
approveAnnualLeave, getAnnualLeaveBalance, deleteAnnualLeave
} from '../controllers/annualleave.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/balance', authMiddleware, getAnnualLeaveBalance);
router.get('/', authMiddleware, getAnnualLeaves);
router.post('/', authMiddleware, createAnnualLeave);
router.put('/:id/approve', authMiddleware, approveAnnualLeave);
router.delete('/:id', authMiddleware, deleteAnnualLeave);
export default router;
import { Router } from 'express';
import { login } from '../controllers/auth.controller';
const router = Router();
router.post('/login', login);
export default router;
import { Router } from 'express';
import {
getCompensatoryLeaves, createCompensatoryLeave,
approveCompensatoryLeave, deleteCompensatoryLeave
} from '../controllers/compensatory.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/', authMiddleware, getCompensatoryLeaves);
router.post('/', authMiddleware, createCompensatoryLeave);
router.put('/:id/approve', authMiddleware, approveCompensatoryLeave);
router.delete('/:id', authMiddleware, deleteCompensatoryLeave);
export default router;
import { Router } from 'express';
import {
getDocuments, createDocument, updateDocument, deleteDocument
} from '../controllers/document.controller';
import { authMiddleware } from '../middleware/auth';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const uploadDir = path.join(process.cwd(), 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 } // 50MB
});
const router = Router();
router.get('/', authMiddleware, getDocuments);
router.post('/', authMiddleware, upload.single('attachment'), createDocument);
router.put('/:id', authMiddleware, upload.single('attachment'), updateDocument);
router.delete('/:id', authMiddleware, deleteDocument);
export default router;
import { Router } from 'express';
import {
getEmployees, createEmployee, updateEmployee,
deleteEmployee, getEmployee, getProfile
} from '../controllers/employee.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/profile', authMiddleware, getProfile);
router.get('/', authMiddleware, getEmployees);
router.get('/:id', authMiddleware, getEmployee);
router.post('/', authMiddleware, createEmployee);
router.put('/:id', authMiddleware, updateEmployee);
router.delete('/:id', authMiddleware, deleteEmployee);
export default router;
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth';
import { getOperationLogs, getLogModules, clearLogs, exportOperationLogs } from '../controllers/log.controller';
const router = Router();
// 所有路由需要认证
router.use(authMiddleware);
router.get('/modules', getLogModules);
router.get('/export', exportOperationLogs);
router.post('/clear', clearLogs);
router.get('/', getOperationLogs);
export default router;
import { Router } from 'express';
import {
getOvertimes, createOvertime, updateOvertime,
deleteOvertime, approveOvertime, getOvertimeBalance
} from '../controllers/overtime.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/balance', authMiddleware, getOvertimeBalance);
router.get('/', authMiddleware, getOvertimes);
router.post('/', authMiddleware, createOvertime);
router.put('/:id', authMiddleware, updateOvertime);
router.put('/:id/approve', authMiddleware, approveOvertime);
router.delete('/:id', authMiddleware, deleteOvertime);
export default router;
import { Router } from 'express';
import {
getProjects, getProject, createProject,
updateProject, deleteProject, getAllProjects
} from '../controllers/project.controller';
import { authMiddleware, adminMiddleware } from '../middleware/auth';
const router = Router();
router.get('/all', authMiddleware, getAllProjects);
router.get('/', authMiddleware, getProjects);
router.get('/:id', authMiddleware, getProject);
router.post('/', authMiddleware, adminMiddleware, createProject);
router.put('/:id', authMiddleware, adminMiddleware, updateProject);
router.delete('/:id', authMiddleware, adminMiddleware, deleteProject);
export default router;
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth';
import {
getRoles,
getAllRoles,
getRole,
createRole,
updateRole,
deleteRole,
getPermissionsList,
getRolePermissions
} from '../controllers/role.controller';
const router = Router();
// 所有路由需要认证
router.use(authMiddleware);
// 注意:具体路径必须放在参数化路径之前
router.get('/all', getAllRoles);
router.get('/permissions', getPermissionsList);
router.get('/', getRoles);
router.post('/', createRole);
router.get('/:id', getRole);
router.get('/:id/permissions', getRolePermissions);
router.put('/:id', updateRole);
router.delete('/:id', deleteRole);
export default router;
import { Router } from 'express';
import {
getWorkhours, createWorkHour, updateWorkHour,
deleteWorkHour, getWorkHourStats, exportWorkhours
} from '../controllers/workhour.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/stats', authMiddleware, getWorkHourStats);
router.get('/export', authMiddleware, exportWorkhours);
router.get('/', authMiddleware, getWorkhours);
router.post('/', authMiddleware, createWorkHour);
router.put('/:id', authMiddleware, updateWorkHour);
router.delete('/:id', authMiddleware, deleteWorkHour);
export default router;
import pool from '../config/database';
/**
* 记录操作日志
* @param module 操作模块(如:项目管理、员工管理、文档管理等)
* @param rx_action 操作描述
* @param operatorId 操作人ID
* @param operatorName 操作人姓名
* @param operatorRole 操作人角色
*/
export const logOperation = async (
module: string,
rx_action: string,
operatorId: number | null,
operatorName: string,
operatorRole: string
): Promise<void> => {
try {
await pool.execute(
`INSERT INTO operation_logs (module, rx_action, operator_id, operator_name, operator_role)
VALUES (?, ?, ?, ?, ?)`,
[module, rx_action, operatorId, operatorName, operatorRole]
);
} catch (error) {
// 日志记录失败不应影响主业务
console.error('操作日志记录失败:', error);
}
};
import pool from '../config/database';
/**
* 启动日志自动清理定时器
* 默认保留 90 天内的日志,每天凌晨 2 点清理一次
*/
export const startLogCleanupScheduler = (): void => {
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24小时
const RETENTION_DAYS = 90;
const cleanup = async () => {
try {
const [result] = await pool.execute(
`DELETE FROM operation_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[RETENTION_DAYS]
) as any[];
const deletedCount = result.affectedRows || 0;
if (deletedCount > 0) {
console.log(`[日志清理] 已清理 ${deletedCount}${RETENTION_DAYS} 天前的操作日志`);
}
} catch (error) {
console.error('[日志清理] 清理失败:', error);
}
};
// 启动后 1 分钟先执行一次,然后每 24 小时执行
setTimeout(() => {
cleanup();
setInterval(cleanup, CLEANUP_INTERVAL);
}, 60 * 1000);
console.log(`[日志清理] 定时器已启动,保留 ${RETENTION_DAYS} 天内的日志`);
};
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>项目管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "project-management-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.2",
"element-plus": "^2.4.3",
"pinia": "^2.1.7",
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"@element-plus/icons-vue": "^2.3.1",
"dayjs": "^1.11.10"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vue-tsc": "^1.8.22"
}
}
<template>
<router-view />
</template>
import request from './request'
export const login = (data: { email: string; password: string }) =>
request.post('/auth/login', data)
// 员工
export const getEmployees = (params?: any) => request.get('/employees', { params })
export const getAllEmployees = () => request.get('/employees', { params: { limit: 1000 } })
export const getEmployee = (id: number) => request.get(`/employees/${id}`)
export const createEmployee = (data: any) => request.post('/employees', data)
export const updateEmployee = (id: number, data: any) => request.put(`/employees/${id}`, data)
export const deleteEmployee = (id: number) => request.delete(`/employees/${id}`)
export const getProfile = () => request.get('/employees/profile')
// 项目
export const getProjects = (params?: any) => request.get('/projects', { params })
export const getAllProjects = () => request.get('/projects/all')
export const getProject = (id: number) => request.get(`/projects/${id}`)
export const createProject = (data: any) => request.post('/projects', data)
export const updateProject = (id: number, data: any) => request.put(`/projects/${id}`, data)
export const deleteProject = (id: number) => request.delete(`/projects/${id}`)
// 文档
export const getDocuments = (params?: any) => request.get('/documents', { params })
export const createDocument = (data: FormData) =>
request.post('/documents', data, { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateDocument = (id: number, data: FormData) =>
request.put(`/documents/${id}`, data, { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteDocument = (id: number) => request.delete(`/documents/${id}`)
// 工时
export const getWorkhours = (params?: any) => request.get('/workhours', { params })
export const createWorkHour = (data: any) => request.post('/workhours', data)
export const updateWorkHour = (id: number, data: any) => request.put(`/workhours/${id}`, data)
export const deleteWorkHour = (id: number) => request.delete(`/workhours/${id}`)
export const getWorkHourStats = (params?: any) => request.get('/workhours/stats', { params })
export const exportWorkhours = (params?: any) => fetch(`/api/workhours/export?${new URLSearchParams(params).toString()}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
// 加班
export const getOvertimes = (params?: any) => request.get('/overtimes', { params })
export const createOvertime = (data: any) => request.post('/overtimes', data)
export const updateOvertime = (id: number, data: any) => request.put(`/overtimes/${id}`, data)
export const approveOvertime = (id: number, status: string) =>
request.put(`/overtimes/${id}/approve`, { status })
export const deleteOvertime = (id: number) => request.delete(`/overtimes/${id}`)
export const getOvertimeBalance = () => request.get('/overtimes/balance')
// 调休
export const getCompensatoryLeaves = (params?: any) => request.get('/compensatory', { params })
export const createCompensatoryLeave = (data: any) => request.post('/compensatory', data)
export const approveCompensatoryLeave = (id: number, status: string) =>
request.put(`/compensatory/${id}/approve`, { status })
export const deleteCompensatoryLeave = (id: number) => request.delete(`/compensatory/${id}`)
// 年假
export const getAnnualLeaves = (params?: any) => request.get('/annual-leave', { params })
export const createAnnualLeave = (data: any) => request.post('/annual-leave', data)
export const approveAnnualLeave = (id: number, status: string) =>
request.put(`/annual-leave/${id}/approve`, { status })
export const deleteAnnualLeave = (id: number) => request.delete(`/annual-leave/${id}`)
export const getAnnualLeaveBalance = () => request.get('/annual-leave/balance')
// 角色管理
export const getRoles = (params?: any) => request.get('/roles', { params })
export const getAllRoles = () => request.get('/roles/all')
export const getRole = (id: number) => request.get(`/roles/${id}`)
export const createRole = (data: any) => request.post('/roles', data)
export const updateRole = (id: number, data: any) => request.put(`/roles/${id}`, data)
export const deleteRole = (id: number) => request.delete(`/roles/${id}`)
export const getPermissionsList = () => request.get('/roles/permissions')
export const getRolePermissions = (id: number) => request.get(`/roles/${id}/permissions`)
// 操作日志
export const getOperationLogs = (params?: any) => request.get('/logs', { params })
export const getLogModules = () => request.get('/logs/modules')
export const clearLogs = (data?: any) => request.post('/logs/clear', data || {})
export const exportOperationLogs = (params?: any) => fetch(`/api/logs/export?${new URLSearchParams(params).toString()}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api',
timeout: 30000
})
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
request.interceptors.response.use(
response => {
const { data } = response
if (data.code === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
return data
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
const message = error.response?.data?.message || '请求失败'
ElMessage.error(message)
return Promise.reject(error)
}
)
export default request
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="sidebar-logo">
<el-icon size="24" color="#409eff"><Office /></el-icon>
<span v-show="!isCollapse" class="logo-text">项目管理系统</span>
</div>
<el-menu
:default-active="activeMenu"
background-color="#ffffff"
text-color="#606266"
active-text-color="#409eff"
:collapse="isCollapse"
router
>
<el-menu-item index="/dashboard">
<el-icon><House /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item v-if="userStore.isProjecter()" index="/projects">
<el-icon><FolderOpened /></el-icon>
<template #title>项目管理</template>
</el-menu-item>
<el-menu-item v-if="userStore.isProjecter()" index="/documents">
<el-icon><Document /></el-icon>
<template #title>文档管理</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin()" index="/employees">
<el-icon><User /></el-icon>
<template #title>员工管理</template>
</el-menu-item>
<el-menu-item index="/workhours">
<el-icon><Timer /></el-icon>
<template #title>工时管理</template>
</el-menu-item>
<el-menu-item index="/overtimes">
<el-icon><AlarmClock /></el-icon>
<template #title>加班管理</template>
</el-menu-item>
<el-menu-item index="/compensatory">
<el-icon><Calendar /></el-icon>
<template #title>调休管理</template>
</el-menu-item>
<el-menu-item index="/annual-leave">
<el-icon><Sunny /></el-icon>
<template #title>年假管理</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin()" index="/roles">
<el-icon><Key /></el-icon>
<template #title>角色管理</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin()" index="/logs">
<el-icon><Tickets /></el-icon>
<template #title>操作日志</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部导航 -->
<el-header class="header">
<div class="header-left">
<el-button
:icon="isCollapse ? 'Expand' : 'Fold'"
text
size="large"
@click="isCollapse = !isCollapse"
/>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar size="small" :style="{ background: '#409eff' }">
{{ userStore.userInfo?.name?.slice(0, 1) }}
</el-avatar>
<span class="username">{{ userStore.userInfo?.name }}</span>
<el-tag size="small" type="info">{{ userStore.userInfo?.role }}</el-tag>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout" icon="SwitchButton">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => route.path)
const currentTitle = computed(() => route.meta?.title as string || '')
const handleCommand = async (cmd: string) => {
if (cmd === 'logout') {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', { type: 'warning' })
userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
overflow: hidden;
}
.sidebar {
background: #fff;
transition: width 0.3s;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0,0,0,0.06);
}
.sidebar-logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
}
.logo-text {
color: #1a1a2e;
font-weight: 700;
font-size: 15px;
white-space: nowrap;
}
.el-menu {
border-right: none;
flex: 1;
}
:deep(.el-menu-item.is-active) {
background: #ecf5ff !important;
border-right: 3px solid #409eff;
color: #409eff !important;
}
:deep(.el-menu-item:hover) {
background: #f5f7fa !important;
}
.header {
height: 60px;
background: #fff;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 8px;
transition: background 0.2s;
}
.user-info:hover {
background: #f5f7fa;
}
.username {
font-size: 14px;
color: #333;
}
.main-content {
background: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
meta: { requiresAuth: true },
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '首页', icon: 'House' }
},
{
path: 'projects',
name: 'Projects',
component: () => import('@/views/project/ProjectList.vue'),
meta: { title: '项目管理', icon: 'FolderOpened', roles: ['管理员', '项目经理'] }
},
{
path: 'documents',
name: 'Documents',
component: () => import('@/views/document/DocumentList.vue'),
meta: { title: '文档管理', icon: 'Document' }
},
{
path: 'employees',
name: 'Employees',
component: () => import('@/views/employee/EmployeeList.vue'),
meta: { title: '员工管理', icon: 'User', roles: ['管理员', '项目经理'] }
},
{
path: 'workhours',
name: 'Workhours',
component: () => import('@/views/workhour/WorkhourList.vue'),
meta: { title: '工时管理', icon: 'Timer' }
},
{
path: 'overtimes',
name: 'Overtimes',
component: () => import('@/views/overtime/OvertimeList.vue'),
meta: { title: '加班管理', icon: 'AlarmClock' }
},
{
path: 'compensatory',
name: 'Compensatory',
component: () => import('@/views/compensatory/CompensatoryList.vue'),
meta: { title: '调休管理', icon: 'Calendar' }
},
{
path: 'annual-leave',
name: 'AnnualLeave',
component: () => import('@/views/annualleave/AnnualLeaveList.vue'),
meta: { title: '年假管理', icon: 'Sunny' }
},
{
path: 'roles',
name: 'Roles',
component: () => import('@/views/role/RoleList.vue'),
meta: { title: '角色管理', icon: 'Key', roles: ['管理员'] }
},
{
path: 'logs',
name: 'OperationLogs',
component: () => import('@/views/log/OperationLog.vue'),
meta: { title: '操作日志', icon: 'Tickets', roles: ['管理员'] }
}
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth !== false && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
// 角色权限检查
const requiredRoles = to.meta.roles as string[] | undefined
if (requiredRoles && requiredRoles.length > 0) {
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (!requiredRoles.includes(userInfo.role)) {
next('/dashboard')
return
}
}
next()
}
})
export default router
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref<any>(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const setToken = (t: string) => {
token.value = t
localStorage.setItem('token', t)
}
const setUserInfo = (info: any) => {
userInfo.value = info
localStorage.setItem('userInfo', JSON.stringify(info))
}
const logout = () => {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
const isAdmin = () => ['管理员'].includes(userInfo.value?.role)
const isProjecter = () => ['管理员', '项目经理'].includes(userInfo.value?.role)
return { token, userInfo, setToken, setUserInfo, logout, isAdmin, isProjecter }
})
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #303133;
}
.page-container {
padding: 0;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.search-card {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
/* Element Plus 覆写 */
.el-card {
border-radius: 8px;
border-color: #ebeef5;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.el-table {
font-size: 13px;
}
.el-table th.el-table__cell {
background: #fafafa;
color: #606266;
font-weight: 600;
}
.el-button--small {
font-size: 12px;
}
/* 搜索卡片的 el-row 覆写 */
.search-card .el-card__body {
padding: 16px 20px;
}
<template>
<div class="dashboard">
<div class="welcome-banner">
<h2>欢迎回来,{{ userStore.userInfo?.name }} 👋</h2>
<p>{{ currentDate }} · {{ userStore.userInfo?.role }} · {{ userStore.userInfo?.department }}</p>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :span="6" v-for="card in statCards" :key="card.title">
<div class="stat-card" :style="{ borderTop: `3px solid ${card.color}` }">
<div class="stat-icon" :style="{ background: card.color + '20', color: card.color }">
<el-icon size="24"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</div>
</el-col>
</el-row>
<!-- 个人信息卡片 -->
<el-row :gutter="20" class="info-cards">
<el-col :span="12">
<el-card header="我的假期余额" shadow="never" class="info-card">
<div class="leave-balance">
<div class="balance-item">
<div class="balance-label">年假剩余</div>
<div class="balance-value annual">{{ balance.remaining_days ?? '-' }} <span></span></div>
<div class="balance-sub">{{ balance.total_days ?? '-' }} 天 · 已用 {{ balance.used_days ?? '-' }}</div>
</div>
<el-divider direction="vertical" style="height: 80px" />
<div class="balance-item">
<div class="balance-label">调休余额</div>
<div class="balance-value comp">{{ overtimeBalance.remaining_hours ?? '-' }} <span>小时</span></div>
<div class="balance-sub">累计加班 {{ overtimeBalance.total_overtime_hours ?? '-' }} 小时</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card header="本月工时统计" shadow="never" class="info-card">
<div class="workhour-stats">
<div v-for="stat in monthStats" :key="stat.project_name" class="stat-row">
<span class="project-name">{{ stat.project_name }}</span>
<el-progress
:percentage="Math.min(Math.round(stat.total_hours / 180 * 100), 100)"
:stroke-width="8"
:format="() => stat.total_hours + 'h'"
/>
</div>
<el-empty v-if="!monthStats.length" description="本月暂无工时记录" :image-size="60" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getAnnualLeaveBalance, getOvertimeBalance, getWorkHourStats, getProjects } from '@/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
const userStore = useUserStore()
const balance = ref<any>({})
const overtimeBalance = ref<any>({})
const monthStats = ref<any[]>([])
const projectCount = ref(0)
const currentDate = computed(() => dayjs().format('YYYY年MM月DD日 dddd'))
const statCards = computed(() => [
{ title: '项目总数', value: projectCount.value, icon: 'FolderOpened', color: '#409eff' },
{ title: '年假剩余(天)', value: balance.value.remaining_days ?? '-', icon: 'Sunny', color: '#67c23a' },
{ title: '调休余额(小时)', value: overtimeBalance.value.remaining_hours ?? '-', icon: 'Calendar', color: '#e6a23c' },
{ title: '本月工时(小时)', value: monthStats.value.reduce((s: number, r: any) => s + Number(r.total_hours), 0), icon: 'Timer', color: '#f56c6c' }
])
onMounted(async () => {
try {
const [balRes, otRes, statsRes, projRes] = await Promise.allSettled([
getAnnualLeaveBalance(),
getOvertimeBalance(),
getWorkHourStats({ year: dayjs().year(), month: dayjs().month() + 1 }),
getProjects({ limit: 1 })
])
if (balRes.status === 'fulfilled') balance.value = (balRes.value as any).data
if (otRes.status === 'fulfilled') overtimeBalance.value = (otRes.value as any).data
if (statsRes.status === 'fulfilled') monthStats.value = (statsRes.value as any).data?.slice(0, 5) || []
if (projRes.status === 'fulfilled') projectCount.value = (projRes.value as any).data?.total || 0
} catch (_) {}
})
</script>
<style scoped>
.dashboard {
max-width: 1200px;
}
.welcome-banner {
background: linear-gradient(135deg, #409eff, #764ba2);
color: white;
padding: 24px 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.welcome-banner h2 {
margin: 0 0 6px;
font-size: 22px;
}
.welcome-banner p {
margin: 0;
opacity: 0.85;
font-size: 14px;
}
.stat-cards {
margin-bottom: 20px;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
}
.stat-title {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
.info-card {
border-radius: 10px;
}
.leave-balance {
display: flex;
align-items: center;
justify-content: space-around;
padding: 16px 0;
}
.balance-item {
text-align: center;
}
.balance-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.balance-value {
font-size: 36px;
font-weight: 700;
}
.balance-value span {
font-size: 14px;
font-weight: 400;
}
.balance-value.annual { color: #67c23a; }
.balance-value.comp { color: #e6a23c; }
.balance-sub {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
.workhour-stats {
padding: 8px 0;
}
.stat-row {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.project-name {
width: 100px;
flex-shrink: 0;
font-size: 13px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<div class="logo">
<el-icon size="40" color="#409eff"><Office /></el-icon>
</div>
<h2>项目管理系统</h2>
<p>Project Management System</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
size="large"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="email">
<el-input
v-model="form.email"
placeholder="请输入邮箱"
prefix-icon="Message"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
登 录
</el-button>
</el-form-item>
</el-form>
<div class="login-tips">
<p>默认管理员账号:admin@company.com</p>
<p>默认密码:password</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '@/api'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({
email: '',
password: ''
})
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
loading.value = true
try {
const res: any = await login(form)
if (res.code === 200) {
userStore.setToken(res.data.token)
userStore.setUserInfo(res.data.user)
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error(res.message || '登录失败')
}
} finally {
loading.value = false
}
})
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #e8f0fe 0%, #f0f4ff 50%, #eef2fb 100%);
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: white;
border-radius: 16px;
padding: 48px 40px;
width: 420px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.logo {
margin-bottom: 12px;
}
.login-header h2 {
font-size: 24px;
font-weight: 700;
color: #1a1a2e;
margin: 0 0 4px;
}
.login-header p {
color: #999;
font-size: 13px;
margin: 0;
}
.login-form {
margin-top: 24px;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
}
.login-tips {
margin-top: 16px;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
.login-tips p {
margin: 2px 0;
}
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>年假管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">申请年假</el-button>
</div>
<!-- 余额卡片 -->
<el-card shadow="never" style="margin-bottom:16px">
<div class="annual-balance">
<div class="ab-item">
<div class="ab-val" style="color:#409eff">{{ balance.total_days ?? '-' }}</div>
<div class="ab-label">年假总额(天)</div>
</div>
<el-divider direction="vertical" style="height:50px" />
<div class="ab-item">
<div class="ab-val" style="color:#f56c6c">{{ balance.used_days ?? '-' }}</div>
<div class="ab-label">已用(天)</div>
</div>
<el-divider direction="vertical" style="height:50px" />
<div class="ab-item">
<div class="ab-val" style="color:#67c23a">{{ balance.remaining_days ?? '-' }}</div>
<div class="ab-label">可用余额(天)</div>
</div>
<div class="ab-rule">
<el-icon><InfoFilled /></el-icon>
年假规则:入职满1年起计算,初始3天,每满1年+1天,最多15天
</div>
</div>
</el-card>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="5" v-if="isProjecter">
<el-select v-model="query.employee_id" placeholder="选择员工" clearable filterable @change="loadData">
<el-option v-for="e in employees" :key="e.id" :label="e.name" :value="e.id" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="query.status" placeholder="审批状态" clearable @change="loadData">
<el-option label="待审批" value="待审批" />
<el-option label="已批准" value="已批准" />
<el-option label="已拒绝" value="已拒绝" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="employee_name" label="员工" width="90" v-if="isProjecter" />
<el-table-column label="请假日期" width="110">
<template #default="{ row }">{{ row.leave_date?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column prop="days" label="天数" width="80" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="申请时间" width="110">
<template #default="{ row }">{{ row.created_at?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<template v-if="isProjecter && row.status === '待审批'">
<el-button text type="success" size="small" @click="handleApprove(row, '已批准')">批准</el-button>
<el-button text type="danger" size="small" @click="handleApprove(row, '已拒绝')">拒绝</el-button>
</template>
<el-button v-if="row.status === '待审批'" text type="danger" size="small" @click="handleDelete(row)">撤销</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.limit"
:total="total" layout="total, prev, pager, next" @change="loadData" />
</div>
</el-card>
<el-dialog v-model="dialogVisible" title="申请年假" width="480px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="请假日期" prop="leave_date">
<el-date-picker v-model="form.leave_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="请假天数" prop="days">
<el-input-number v-model="form.days" :min="0.5" :max="balance.remaining_days || 0" :step="0.5" style="width:100%" />
<div style="font-size:12px;color:#909399;margin-top:4px">可用余额:{{ balance.remaining_days ?? '-' }} 天</div>
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">提交申请</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAnnualLeaves, createAnnualLeave, approveAnnualLeave,
deleteAnnualLeave, getAnnualLeaveBalance, getAllEmployees
} from '@/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
const userStore = useUserStore()
const isProjecter = computed(() => userStore.isProjecter())
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const list = ref<any[]>([])
const total = ref(0)
const employees = ref<any[]>([])
const balance = ref<any>({})
const formRef = ref()
const query = reactive({ page: 1, limit: 10, employee_id: '', status: '' })
const form = reactive<any>({ leave_date: dayjs().format('YYYY-MM-DD'), days: 1, description: '' })
const rules = {
leave_date: [{ required: true, message: '请选择日期', trigger: 'change' }],
days: [{ required: true, message: '请输入天数', trigger: 'blur' }]
}
const statusType = (s: string) => ({ '待审批': 'warning', '已批准': 'success', '已拒绝': 'danger' }[s] || '')
const loadBalance = async () => {
const res: any = await getAnnualLeaveBalance()
balance.value = res.data || {}
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getAnnualLeaves(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const openDialog = () => {
Object.assign(form, { leave_date: dayjs().format('YYYY-MM-DD'), days: 1, description: '' })
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
await createAnnualLeave(form)
ElMessage.success('申请已提交')
dialogVisible.value = false
loadData()
loadBalance()
} finally {
saving.value = false
}
})
}
const handleApprove = async (row: any, status: string) => {
await approveAnnualLeave(row.id, status)
ElMessage.success(status === '已批准' ? '已批准' : '已拒绝')
loadData()
loadBalance()
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm('确定撤销该年假申请吗?', '提示', { type: 'warning' })
await deleteAnnualLeave(row.id)
ElMessage.success('已撤销')
loadData()
loadBalance()
}
onMounted(async () => {
loadData()
loadBalance()
if (isProjecter.value) {
const res: any = await getAllEmployees()
employees.value = res.data?.list || []
}
})
</script>
<style scoped>
.annual-balance {
display: flex;
align-items: center;
gap: 32px;
padding: 8px 16px;
}
.ab-item {
text-align: center;
}
.ab-val {
font-size: 32px;
font-weight: 700;
}
.ab-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.ab-rule {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
margin-left: auto;
}
.action-btns { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; }
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>调休管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">申请调休</el-button>
</div>
<!-- 余额提示 -->
<el-alert
:title="`可用调休余额:${balance.remaining_hours ?? '-'} 小时(累计加班 ${balance.total_overtime_hours ?? '-'} 小时,已用 ${balance.used_compensatory_hours ?? '-'} 小时)`"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="5" v-if="isProjecter">
<el-select v-model="query.employee_id" placeholder="选择员工" clearable filterable @change="loadData">
<el-option v-for="e in employees" :key="e.id" :label="e.name" :value="e.id" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="query.status" placeholder="审批状态" clearable @change="loadData">
<el-option label="待审批" value="待审批" />
<el-option label="已批准" value="已批准" />
<el-option label="已拒绝" value="已拒绝" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="employee_name" label="员工" width="90" v-if="isProjecter" />
<el-table-column label="调休日期" width="110">
<template #default="{ row }">{{ row.leave_date?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column prop="hours" label="调休时长(h)" width="110" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="申请时间" width="110">
<template #default="{ row }">{{ row.created_at?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<template v-if="isProjecter && row.status === '待审批'">
<el-button text type="success" size="small" @click="handleApprove(row, '已批准')">批准</el-button>
<el-button text type="danger" size="small" @click="handleApprove(row, '已拒绝')">拒绝</el-button>
</template>
<el-button v-if="row.status === '待审批'" text type="danger" size="small" @click="handleDelete(row)">撤销</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.limit"
:total="total" layout="total, prev, pager, next" @change="loadData" />
</div>
</el-card>
<el-dialog v-model="dialogVisible" title="申请调休" width="480px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="调休日期" prop="leave_date">
<el-date-picker v-model="form.leave_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="调休时长(h)" prop="hours">
<el-input-number v-model="form.hours" :min="0.5" :max="balance.remaining_hours || 0.5" :step="0.5" style="width:100%" />
<div style="font-size:12px;color:#909399;margin-top:4px">可用余额:{{ balance.remaining_hours ?? '-' }} 小时</div>
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">提交申请</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getCompensatoryLeaves, createCompensatoryLeave,
approveCompensatoryLeave, deleteCompensatoryLeave, getOvertimeBalance, getAllEmployees
} from '@/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
const userStore = useUserStore()
const isProjecter = computed(() => userStore.isProjecter())
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const list = ref<any[]>([])
const total = ref(0)
const employees = ref<any[]>([])
const balance = ref<any>({})
const formRef = ref()
const query = reactive({ page: 1, limit: 10, employee_id: '', status: '' })
const form = reactive<any>({ leave_date: dayjs().format('YYYY-MM-DD'), hours: 1, description: '' })
const rules = {
leave_date: [{ required: true, message: '请选择日期', trigger: 'change' }],
hours: [{ required: true, message: '请输入调休时长', trigger: 'blur' }]
}
const statusType = (s: string) => ({ '待审批': 'warning', '已批准': 'success', '已拒绝': 'danger' }[s] || '')
const loadBalance = async () => {
const res: any = await getOvertimeBalance()
balance.value = res.data || {}
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getCompensatoryLeaves(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const openDialog = () => {
Object.assign(form, { leave_date: dayjs().format('YYYY-MM-DD'), hours: 1, description: '' })
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
await createCompensatoryLeave(form)
ElMessage.success('申请已提交')
dialogVisible.value = false
loadData()
loadBalance()
} finally {
saving.value = false
}
})
}
const handleApprove = async (row: any, status: string) => {
await approveCompensatoryLeave(row.id, status)
ElMessage.success(status === '已批准' ? '已批准' : '已拒绝')
loadData()
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm('确定撤销该调休申请吗?', '提示', { type: 'warning' })
await deleteCompensatoryLeave(row.id)
ElMessage.success('已撤销')
loadData()
loadBalance()
}
onMounted(async () => {
loadData()
loadBalance()
if (isProjecter.value) {
const res: any = await getAllEmployees()
employees.value = res.data?.list || []
}
})
</script>
<style scoped>
.action-btns { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; }
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>文档管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">上传文档</el-button>
</div>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="5">
<el-select v-model="query.project_id" placeholder="选择项目" clearable filterable @change="loadData">
<el-option v-for="p in projects" :key="p.id" :label="p.project_name" :value="p.id" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="query.doc_type" placeholder="文档类型" clearable @change="loadData">
<el-option v-for="t in docTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-col>
<el-col :span="5">
<el-input v-model="query.doc_name" placeholder="文档名称" clearable @change="loadData" />
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="doc_name" label="文档名称" width="120" show-overflow-tooltip />
<el-table-column prop="project_name" label="所属项目" width="180" />
<el-table-column prop="doc_type" label="文档类型" width="110">
<template #default="{ row }">
<el-tag size="small">{{ row.doc_type }}</el-tag>
</template>
</el-table-column>
<el-table-column label="附件" width="200">
<template #default="{ row }">
<el-link
v-if="row.attachment_url"
type="primary"
:href="row.attachment_url"
target="_blank"
icon="Link"
>
{{ row.attachment_name?.slice(0, 18) || '下载附件' }}
</el-link>
<span v-else class="text-gray">无附件</span>
</template>
</el-table-column>
<el-table-column prop="content" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column prop="uploader_name" label="上传人" width="90" />
<el-table-column label="上传时间" width="110">
<template #default="{ row }">{{ row.created_at?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button text type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.limit"
:total="total"
layout="total, prev, pager, next"
@change="loadData"
/>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="editItem ? '编辑文档' : '上传文档'" width="580px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="所属项目" prop="project_id">
<el-select v-model="form.project_id" filterable style="width:100%">
<el-option v-for="p in projects" :key="p.id" :label="p.project_name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="文档名称" prop="doc_name">
<el-input v-model="form.doc_name" />
</el-form-item>
<el-form-item label="文档类型" prop="doc_type">
<el-select v-model="form.doc_type" style="width:100%">
<el-option v-for="t in docTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="附件上传">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
>
<el-button icon="Upload">选择文件</el-button>
<template #tip>
<div style="font-size:12px;color:#909399">支持各类文件,大小不超过50MB</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="内容描述">
<el-input v-model="form.content" type="textarea" :rows="4" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDocuments, createDocument, updateDocument, deleteDocument, getAllProjects } from '@/api'
const docTypes = ['需求文档', '设计文档', '验收文档', '技术文档', '会议纪要', '其他']
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editItem = ref<any>(null)
const list = ref<any[]>([])
const total = ref(0)
const projects = ref<any[]>([])
const formRef = ref()
const uploadRef = ref()
const selectedFile = ref<File | null>(null)
const query = reactive({ page: 1, limit: 10, project_id: '', doc_type: '', doc_name: '' })
const form = reactive<any>({ project_id: '', doc_name: '', doc_type: '需求文档', content: '' })
const rules = {
project_id: [{ required: true, message: '请选择项目', trigger: 'change' }],
doc_name: [{ required: true, message: '请输入文档名称', trigger: 'blur' }],
doc_type: [{ required: true, message: '请选择文档类型', trigger: 'change' }]
}
const handleFileChange = (file: any) => {
selectedFile.value = file.raw
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getDocuments(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const resetQuery = () => {
Object.assign(query, { page: 1, project_id: '', doc_type: '', doc_name: '' })
loadData()
}
const openDialog = (row?: any) => {
editItem.value = row || null
selectedFile.value = null
if (row) {
Object.assign(form, { project_id: row.project_id, doc_name: row.doc_name, doc_type: row.doc_type, content: row.content })
} else {
Object.assign(form, { project_id: '', doc_name: '', doc_type: '需求文档', content: '' })
}
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
const fd = new FormData()
fd.append('project_id', form.project_id)
fd.append('doc_name', form.doc_name)
fd.append('doc_type', form.doc_type)
fd.append('content', form.content || '')
if (selectedFile.value) fd.append('attachment', selectedFile.value)
if (editItem.value) {
await updateDocument(editItem.value.id, fd)
ElMessage.success('更新成功')
} else {
await createDocument(fd)
ElMessage.success('上传成功')
}
dialogVisible.value = false
loadData()
} finally {
saving.value = false
}
})
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定删除文档【${row.doc_name}】吗?`, '警告', { type: 'warning' })
await deleteDocument(row.id)
ElMessage.success('删除成功')
loadData()
}
onMounted(async () => {
loadData()
const res: any = await getAllProjects()
projects.value = res.data || []
})
</script>
<style scoped>
.text-gray { color: #c0c4cc; }
.action-btns { display: flex; align-items: center; gap: 4px; }
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>员工管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">新增员工</el-button>
</div>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="5">
<el-input v-model="query.name" placeholder="姓名" clearable @change="loadData" />
</el-col>
<el-col :span="5">
<el-select v-model="query.role" placeholder="角色" clearable @change="loadData">
<el-option v-for="r in roles" :key="r" :label="r" :value="r" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="query.status" placeholder="状态" clearable @change="loadData">
<el-option label="在职" value="在职" />
<el-option label="离职" value="离职" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="name" label="姓名" min-width="90" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="role" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="roleType(row.role)" size="small">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" width="90" />
<el-table-column label="入职时间" width="110">
<template #default="{ row }">{{ row.entry_date?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="年假" width="80">
<template #default="{ row }">
{{ row.annual_leave_days - row.used_leave_days }}/{{ row.annual_leave_days }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === '在职' ? 'success' : 'danger'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button text type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">离职</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.limit"
:total="total"
layout="total, prev, pager, next"
@change="loadData"
/>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="editItem ? '编辑员工' : '新增员工'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="editItem ? '新密码' : '密码'" :prop="editItem ? '' : 'password'">
<el-input v-model="form.password" type="password" :placeholder="editItem ? '不填则不修改' : '请输入密码'" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电话">
<el-input v-model="form.phone" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" style="width:100%">
<el-option v-for="r in roles" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门">
<el-input v-model="form.department" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="入职时间">
<el-date-picker v-model="form.entry_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%">
<el-option label="在职" value="在职" />
<el-option label="离职" value="离职" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getEmployees, createEmployee, updateEmployee, deleteEmployee, getAllRoles } from '@/api'
const roles = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editItem = ref<any>(null)
const list = ref<any[]>([])
const total = ref(0)
const formRef = ref()
const query = reactive({ page: 1, limit: 10, name: '', role: '', status: '' })
const form = reactive<any>({
name: '', email: '', password: '', phone: '',
role: '开发', department: '', entry_date: '', status: '在职'
})
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
const roleType = (r: string) => {
const map: any = { 管理员: 'danger', 项目经理: 'warning', 开发: 'primary', 设计: '', 财务: 'success' }
return map[r] || 'info'
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getEmployees(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const resetQuery = () => {
Object.assign(query, { page: 1, name: '', role: '', status: '' })
loadData()
}
const openDialog = (row?: any) => {
editItem.value = row || null
if (row) {
Object.assign(form, {
name: row.name, email: row.email, password: '',
phone: row.phone, role: row.role, department: row.department,
entry_date: row.entry_date?.slice(0, 10), status: row.status
})
} else {
Object.assign(form, { name: '', email: '', password: '', phone: '', role: '开发', department: '', entry_date: '', status: '在职' })
}
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
const data: any = { ...form }
if (editItem.value && !data.password) delete data.password
if (editItem.value) {
await updateEmployee(editItem.value.id, data)
ElMessage.success('更新成功')
} else {
await createEmployee(data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
saving.value = false
}
})
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定将员工【${row.name}】标记为离职吗?`, '提示', { type: 'warning' })
await deleteEmployee(row.id)
ElMessage.success('操作成功')
loadData()
}
const loadRoles = async () => {
try {
const res: any = await getAllRoles()
roles.value = (res.data || []).map((r: any) => r.name)
} catch { /* ignore */ }
}
onMounted(() => {
loadRoles()
loadData()
})
</script>
<style scoped>
.action-btns {
display: flex;
align-items: center;
gap: 4px;
}
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>操作日志</h3>
<div style="display: flex; gap: 8px">
<el-button icon="Download" @click="handleExport">导出CSV</el-button>
<el-button type="danger" icon="Delete" @click="handleClear">清空日志</el-button>
</div>
</div>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="5">
<el-select v-model="query.module" placeholder="操作模块" clearable @change="loadData">
<el-option v-for="m in modules" :key="m" :label="m" :value="m" />
</el-select>
</el-col>
<el-col :span="5">
<el-input v-model="query.operator_name" placeholder="操作人" clearable @change="loadData" />
</el-col>
<el-col :span="5">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="onDateChange"
style="width: 100%"
/>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading" max-height="calc(100vh - 340px)">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="module" label="操作模块" width="110">
<template #default="{ row }">
<el-tag size="small">{{ row.module }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="rx_action" label="操作描述" min-width="250" show-overflow-tooltip />
<el-table-column prop="operator_name" label="操作人" width="100" />
<el-table-column prop="operator_role" label="角色" width="90" />
<el-table-column label="操作时间" width="170" sortable="custom">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.limit"
:total="total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadData"
/>
</div>
</el-card>
<!-- 清空日志确认对话框 -->
<el-dialog v-model="clearDialogVisible" title="清空日志" width="450px">
<p>请选择要清除的日志范围:</p>
<el-radio-group v-model="clearDays" style="display: flex; flex-direction: column; gap: 8px; margin: 12px 0">
<el-radio :value="7">清除 7 天前的日志</el-radio>
<el-radio :value="30">清除 30 天前的日志</el-radio>
<el-radio :value="90">清除 90 天前的日志</el-radio>
<el-radio :value="0">清除全部日志</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="clearDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="clearing" @click="confirmClear">确认清除</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getOperationLogs, getLogModules, clearLogs, exportOperationLogs } from '@/api'
const loading = ref(false)
const clearing = ref(false)
const clearDialogVisible = ref(false)
const clearDays = ref(7)
const list = ref<any[]>([])
const total = ref(0)
const modules = ref<string[]>([])
const dateRange = ref<[string, string] | null>(null)
const query = reactive({
page: 1,
limit: 15,
module: '',
operator_name: '',
start_date: '',
end_date: ''
})
const loadData = async () => {
loading.value = true
try {
const res: any = await getOperationLogs(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const loadModules = async () => {
try {
const res: any = await getLogModules()
modules.value = res.data || []
} catch { /* ignore */ }
}
const resetQuery = () => {
Object.assign(query, { page: 1, module: '', operator_name: '', start_date: '', end_date: '' })
dateRange.value = null
loadData()
}
const onDateChange = (val: [string, string] | null) => {
if (val) {
query.start_date = val[0]
query.end_date = val[1]
} else {
query.start_date = ''
query.end_date = ''
}
loadData()
}
const formatTime = (t: string) => {
if (!t) return ''
return new Date(t).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const handleExport = () => {
const params: any = {}
if (query.module) params.module = query.module
if (query.operator_name) params.operator_name = query.operator_name
if (query.start_date) params.start_date = query.start_date
if (query.end_date) params.end_date = query.end_date
const url = `/api/logs/export?${new URLSearchParams(params).toString()}`
const a = document.createElement('a')
a.href = url
a.download = ''
a.click()
}
const handleClear = () => {
clearDays.value = 7
clearDialogVisible.value = true
}
const confirmClear = async () => {
clearing.value = true
try {
if (clearDays.value === 0) {
await ElMessageBox.confirm('确定要清空全部日志吗?此操作不可恢复!', '严重警告', {
type: 'error',
confirmButtonText: '确定清空',
cancelButtonText: '取消'
})
await clearLogs({})
} else {
await ElMessageBox.confirm(`确定要清除 ${clearDays.value} 天前的日志吗?`, '提示', { type: 'warning' })
await clearLogs({ before_days: clearDays.value })
}
ElMessage.success('日志已清除')
clearDialogVisible.value = false
loadData()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.response?.data?.message || '操作失败')
}
} finally {
clearing.value = false
}
}
onMounted(() => {
loadModules()
loadData()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<h3>项目管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">新增项目</el-button>
</div>
<!-- 搜索栏 -->
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="6">
<el-input v-model="query.project_name" placeholder="项目名称" clearable @change="loadData" />
</el-col>
<el-col :span="5">
<el-select v-model="query.project_stage" placeholder="项目阶段" clearable @change="loadData">
<el-option v-for="s in stages" :key="s" :label="s" :value="s" />
</el-select>
</el-col>
<el-col :span="5">
<el-input v-model="query.project_type" placeholder="项目类型" clearable @change="loadData" />
</el-col>
<el-col :span="4">
<el-button type="primary" icon="Search" @click="loadData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading" row-key="id">
<el-table-column prop="project_no" label="项目编号" width="160" />
<el-table-column prop="project_name" label="项目名称" min-width="150" />
<el-table-column prop="manager_name" label="项目经理" width="100" />
<el-table-column prop="project_type" label="项目类型" width="100" />
<el-table-column prop="client" label="客户" width="120" />
<el-table-column prop="project_stage" label="阶段" width="90">
<template #default="{ row }">
<el-tag :type="stageType(row.project_stage)" size="small">{{ row.project_stage }}</el-tag>
</template>
</el-table-column>
<el-table-column label="计划时间" width="200">
<template #default="{ row }">
<span v-if="row.plan_start_date">
{{ row.plan_start_date?.slice(0, 10) }} ~ {{ row.plan_end_date?.slice(0, 10) || '未设置' }}
</span>
<span v-else class="text-gray">未设置</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button text type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.limit"
:total="total"
layout="total, prev, pager, next"
@change="loadData"
/>
</div>
</el-card>
<!-- 对话框 -->
<el-dialog v-model="dialogVisible" :title="editItem ? '编辑项目' : '新增项目'" width="620px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="项目编号" prop="project_no">
<el-input v-model="form.project_no" :disabled="!!editItem" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目名称" prop="project_name">
<el-input v-model="form.project_name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目经理">
<el-select v-model="form.manager_id" placeholder="请选择" clearable filterable style="width:100%">
<el-option v-for="e in managers" :key="e.id" :label="e.name" :value="e.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目类型">
<el-input v-model="form.project_type" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户">
<el-input v-model="form.client" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目阶段">
<el-select v-model="form.project_stage" style="width:100%">
<el-option v-for="s in stages" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划开始">
<el-date-picker v-model="form.plan_start_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划结束">
<el-date-picker v-model="form.plan_end_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="项目描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProjects, createProject, updateProject, deleteProject, getAllEmployees } from '@/api'
const stages = ['立项', '需求分析', '设计', '开发', '测试', '上线', '完成', '暂停']
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editItem = ref<any>(null)
const list = ref<any[]>([])
const total = ref(0)
const managers = ref<any[]>([])
const formRef = ref()
const query = reactive({ page: 1, limit: 10, project_name: '', project_stage: '', project_type: '' })
const form = reactive<any>({
project_no: '', project_name: '', manager_id: null,
project_type: '', client: '', project_stage: '立项',
plan_start_date: '', plan_end_date: '', description: ''
})
const rules = {
project_no: [{ required: true, message: '请输入项目编号', trigger: 'blur' }],
project_name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
}
const stageType = (s: string) => {
const map: any = { 完成: 'success', 开发: 'primary', 测试: 'warning', 上线: 'success', 暂停: 'danger', 立项: 'info' }
return map[s] || ''
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getProjects(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const resetQuery = () => {
Object.assign(query, { page: 1, project_name: '', project_stage: '', project_type: '' })
loadData()
}
const openDialog = (row?: any) => {
editItem.value = row || null
if (row) {
Object.assign(form, {
project_no: row.project_no, project_name: row.project_name,
manager_id: row.manager_id, project_type: row.project_type,
client: row.client, project_stage: row.project_stage,
plan_start_date: row.plan_start_date?.slice(0, 10),
plan_end_date: row.plan_end_date?.slice(0, 10), description: row.description
})
} else {
Object.assign(form, {
project_no: '', project_name: '', manager_id: null,
project_type: '', client: '', project_stage: '立项',
plan_start_date: '', plan_end_date: '', description: ''
})
}
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
if (editItem.value) {
await updateProject(editItem.value.id, form)
ElMessage.success('更新成功')
} else {
await createProject(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
saving.value = false
}
})
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定删除项目【${row.project_name}】吗?`, '警告', { type: 'warning' })
await deleteProject(row.id)
ElMessage.success('删除成功')
loadData()
}
onMounted(async () => {
loadData()
const res: any = await getAllEmployees()
managers.value = (res.data?.list || []).filter((e: any) => ['项目经理', '管理员'].includes(e.role))
})
</script>
<style scoped>
.text-gray { color: #c0c4cc; }
.action-btns { display: flex; align-items: center; gap: 4px; }
</style>
<template>
<div class="page-container">
<div class="page-header">
<h3>角色管理</h3>
<el-button type="primary" icon="Plus" @click="openDialog()">新增角色</el-button>
</div>
<el-card shadow="never" class="search-card">
<el-row :gutter="16">
<el-col :span="6">
<el-input v-model="query.name" placeholder="角色名称" clearable @change="loadData" />
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="角色名称" min-width="120" />
<el-table-column prop="description" label="角色描述" min-width="200" show-overflow-tooltip />
<el-table-column label="权限模块" min-width="300">
<template #default="{ row }">
<el-tag
v-for="p in row.permissions"
:key="p"
size="small"
style="margin: 2px"
>{{ getPermissionLabel(p) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ row.created_at?.slice(0, 19).replace('T', ' ') }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button text type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button text type="warning" size="small" @click="openPermissionDialog(row)">分配权限</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.limit"
:total="total"
layout="total, prev, pager, next"
@change="loadData"
/>
</div>
</el-card>
<!-- 新增/编辑角色对话框 -->
<el-dialog v-model="dialogVisible" :title="editItem ? '编辑角色' : '新增角色'" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入角色描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- 分配权限对话框 -->
<el-dialog v-model="permDialogVisible" title="分配权限" width="500px" destroy-on-close>
<el-checkbox-group v-model="selectedPermissions">
<el-checkbox
v-for="mod in allPermissions"
:key="mod.key"
:label="mod.key"
:value="mod.key"
style="display: block; margin-bottom: 8px"
>{{ mod.label }}</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="permDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingPerm" @click="handleSavePermissions">保存权限</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getRoles, getAllRoles, getRole, createRole, updateRole, deleteRole,
getPermissionsList, getRolePermissions
} from '@/api'
const loading = ref(false)
const saving = ref(false)
const savingPerm = ref(false)
const dialogVisible = ref(false)
const permDialogVisible = ref(false)
const editItem = ref<any>(null)
const permRoleId = ref<number | null>(null)
const list = ref<any[]>([])
const total = ref(0)
const formRef = ref()
const allPermissions = ref<{ key: string; label: string }[]>([])
const selectedPermissions = ref<string[]>([])
const query = reactive({ page: 1, limit: 10, name: '' })
const form = reactive({ name: '', description: '' })
const rules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
}
const getPermissionLabel = (key: string) => {
const mod = allPermissions.value.find(m => m.key === key)
return mod ? mod.label : key
}
const loadData = async () => {
loading.value = true
try {
const res: any = await getRoles(query)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
const loadPermissions = async () => {
try {
const res: any = await getPermissionsList()
allPermissions.value = res.data || []
} catch { /* ignore */ }
}
const resetQuery = () => {
Object.assign(query, { page: 1, name: '' })
loadData()
}
const openDialog = (row?: any) => {
editItem.value = row || null
if (row) {
Object.assign(form, { name: row.name, description: row.description || '' })
} else {
Object.assign(form, { name: '', description: '' })
}
dialogVisible.value = true
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
if (editItem.value) {
await updateRole(editItem.value.id, { ...form })
ElMessage.success('更新成功')
} else {
await createRole({ ...form })
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
saving.value = false
}
})
}
const openPermissionDialog = async (row: any) => {
permRoleId.value = row.id
try {
const res: any = await getRolePermissions(row.id)
selectedPermissions.value = res.data?.permissions || []
} catch {
selectedPermissions.value = []
}
permDialogVisible.value = true
}
const handleSavePermissions = async () => {
if (!permRoleId.value) return
savingPerm.value = true
try {
await updateRole(permRoleId.value, { permissions: selectedPermissions.value })
ElMessage.success('权限保存成功')
permDialogVisible.value = false
loadData()
} finally {
savingPerm.value = false
}
}
const handleDelete = async (row: any) => {
await ElMessageBox.confirm(`确定要删除角色【${row.name}】吗?`, '提示', { type: 'warning' })
try {
await deleteRole(row.id)
ElMessage.success('删除成功')
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
}
onMounted(() => {
loadPermissions()
loadData()
})
</script>
<style scoped>
.action-btns {
display: flex;
align-items: center;
gap: 4px;
}
</style>
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment