This commit is contained in:
2026-06-26 15:56:49 +08:00
commit 339a93f472
283 changed files with 40431 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { generateToken } from '@/lib/auth';
import bcrypt from 'bcryptjs';
// POST /api/auth/login - 用户登录
export async function POST(request) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return Response.json(
{ error: '用户名和密码不能为空' },
{ status: 400 }
);
}
const db = await getDb();
// 查找用户
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
return Response.json(
{ error: '用户名或密码错误' },
{ status: 401 }
);
}
if (user.status !== 1) {
return Response.json(
{ error: '该账户已被停用,禁止登录' },
{ status: 403 }
);
}
// 验证密码
const isValid = bcrypt.compareSync(password, user.password);
if (!isValid) {
return Response.json(
{ error: '用户名或密码错误' },
{ status: 401 }
);
}
// 生成 token
const token = generateToken(user);
// 获取用户信息(不含密码)
const { password: _, ...userInfo } = user;
// 获取部门名称
if (user.department_id) {
const dept = db.prepare('SELECT name FROM departments WHERE id = ?').get(user.department_id);
if (dept) {
userInfo.department_name = dept.name;
}
}
// 获取系统配置(登录页面展示用)
const configs = db.prepare('SELECT config_key, config_value FROM system_config').all();
const system_config = {};
for (const config of configs) {
system_config[config.config_key] = config.config_value;
}
return Response.json({
token,
user: userInfo,
system_config,
});
} catch (error) {
console.error('登录失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
// GET /api/auth/me - 获取当前登录用户信息
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) {
return unauthorizedResponse();
}
const db = await getDb();
// 获取完整用户信息
const fullUser = db.prepare(
'SELECT id, username, real_name, role, department_id, status, created_at FROM users WHERE id = ?'
).get(user.id);
if (!fullUser || fullUser.status !== 1) {
return unauthorizedResponse();
}
// 获取部门名称
if (fullUser.department_id) {
const dept = db.prepare('SELECT name FROM departments WHERE id = ?').get(fullUser.department_id);
if (dept) {
fullUser.department_name = dept.name;
}
}
// 获取角色权限
const permissions = db.prepare(
'SELECT menu_key, visible FROM role_permissions WHERE role = ?'
).all(fullUser.role);
return Response.json({
user: fullUser,
role_permissions: permissions,
permissions: permissions,
});
} catch (error) {
console.error('获取用户信息失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
export const dynamic = "force-dynamic";
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';
// POST /api/config/logo - 上传公司 Logo(仅管理员)
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return Response.json(
{ error: '请选择要上传的文件' },
{ status: 400 }
);
}
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 生成唯一文件名
const ext = path.extname(file.name) || '.png';
const fileName = `logo_${uuidv4()}${ext}`;
const filePath = path.join(uploadDir, fileName);
// 保存文件
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer);
// 返回文件路径(相对于 public 的路径)
const publicPath = `/uploads/${fileName}`;
return Response.json({
path: publicPath,
fileName: file.name,
});
} catch (error) {
console.error('上传 Logo 失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
// GET /api/config - 获取系统配置(公开,无需认证)
export async function GET() {
try {
const db = await getDb();
const configs = db.prepare('SELECT config_key, config_value FROM system_config').all();
const result = {};
for (const config of configs) {
result[config.config_key] = config.config_value;
}
return Response.json(result);
} catch (error) {
console.error('获取系统配置失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// PUT /api/config - 更新系统配置(仅管理员)
export async function PUT(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const body = await request.json();
const db = await getDb();
const updateStmt = db.prepare(
'UPDATE system_config SET config_value = ?, updated_at = CURRENT_TIMESTAMP WHERE config_key = ?'
);
const allowedKeys = ['company_name', 'company_logo', 'profit_percentage'];
const updateMany = db.transaction(() => {
for (const key of allowedKeys) {
if (body[key] !== undefined) {
updateStmt.run(String(body[key]), key);
}
}
});
updateMany();
// 返回更新后的配置
const configs = db.prepare('SELECT config_key, config_value FROM system_config').all();
const result = {};
for (const config of configs) {
result[config.config_key] = config.config_value;
}
return Response.json(result);
} catch (error) {
console.error('更新系统配置失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+175
View File
@@ -0,0 +1,175 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
// POST /api/contracts/[id]/approve - 审批合同
export async function POST(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const { approved, opinion } = await request.json();
if (typeof approved !== 'boolean') {
return Response.json(
{ error: '请指定审批结果' },
{ status: 400 }
);
}
const db = await getDb();
const contract = db.prepare('SELECT * FROM contracts WHERE id = ?').get(id);
if (!contract) {
return Response.json(
{ error: '合同不存在' },
{ status: 404 }
);
}
// 获取审批人完整信息
const approver = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
const currentStatus = contract.status;
// 根据当前状态判断权限和下一步操作
switch (currentStatus) {
case 'pending_supervisor': {
// 只有同部门的主管可以审批
if (user.role !== 'supervisor') {
return Response.json(
{ error: '只有主管可以进行初审' },
{ status: 403 }
);
}
if (user.department_id !== contract.department_id) {
return Response.json(
{ error: '只能审批本部门的合同' },
{ status: 403 }
);
}
if (approved) {
db.prepare(`
UPDATE contracts SET
status = 'pending_finance',
supervisor_opinion = ?,
supervisor_approved = 1,
supervisor_id = ?,
supervisor_name = ?,
supervisor_time = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '同意', approver.id, approver.real_name, id);
} else {
db.prepare(`
UPDATE contracts SET
status = 'rejected',
supervisor_opinion = ?,
supervisor_approved = 0,
supervisor_id = ?,
supervisor_name = ?,
supervisor_time = CURRENT_TIMESTAMP,
reject_reason = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '不同意', approver.id, approver.real_name, opinion || '主管驳回', id);
}
break;
}
case 'pending_finance': {
// 只有财务角色可以审批
if (user.role !== 'finance') {
return Response.json(
{ error: '只有财务人员可以进行财务审批' },
{ status: 403 }
);
}
if (approved) {
db.prepare(`
UPDATE contracts SET
status = 'pending_secretary',
finance_opinion = ?,
finance_approved = 1,
finance_id = ?,
finance_name = ?,
finance_time = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '同意', approver.id, approver.real_name, id);
} else {
db.prepare(`
UPDATE contracts SET
status = 'rejected',
finance_opinion = ?,
finance_approved = 0,
finance_id = ?,
finance_name = ?,
finance_time = CURRENT_TIMESTAMP,
reject_reason = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '不同意', approver.id, approver.real_name, opinion || '财务驳回', id);
}
break;
}
case 'pending_secretary': {
// 只有秘书长角色可以审批
if (user.role !== 'secretary') {
return Response.json(
{ error: '只有秘书长可以进行终审' },
{ status: 403 }
);
}
if (approved) {
db.prepare(`
UPDATE contracts SET
status = 'approved',
secretary_opinion = ?,
secretary_approved = 1,
secretary_id = ?,
secretary_name = ?,
secretary_time = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '同意', approver.id, approver.real_name, id);
} else {
db.prepare(`
UPDATE contracts SET
status = 'rejected',
secretary_opinion = ?,
secretary_approved = 0,
secretary_id = ?,
secretary_name = ?,
secretary_time = CURRENT_TIMESTAMP,
reject_reason = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(opinion || '不同意', approver.id, approver.real_name, opinion || '秘书长驳回', id);
}
break;
}
default:
return Response.json(
{ error: `当前合同状态 (${currentStatus}) 不允许审批操作` },
{ status: 400 }
);
}
// 返回更新后的合同
const updated = db.prepare('SELECT * FROM contracts WHERE id = ?').get(id);
return Response.json(updated);
} catch (error) {
console.error('审批合同失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+190
View File
@@ -0,0 +1,190 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
function buildOpinions(contract) {
const opinions = [];
if (contract.supervisor_approved === 1 || contract.supervisor_approved === 0) {
opinions.push({
approver_name: contract.supervisor_name || '主管',
role: 'supervisor',
created_at: contract.supervisor_time,
approved: contract.supervisor_approved === 1,
opinion: contract.supervisor_opinion
});
}
if (contract.finance_approved === 1 || contract.finance_approved === 0) {
opinions.push({
approver_name: contract.finance_name || '财务',
role: 'finance',
created_at: contract.finance_time,
approved: contract.finance_approved === 1,
opinion: contract.finance_opinion
});
}
if (contract.secretary_approved === 1 || contract.secretary_approved === 0) {
opinions.push({
approver_name: contract.secretary_name || '秘书长',
role: 'secretary',
created_at: contract.secretary_time,
approved: contract.secretary_approved === 1,
opinion: contract.secretary_opinion
});
}
return opinions;
}
// GET /api/contracts/[id] - 获取单个合同详情(含附件)
export async function GET(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const db = await getDb();
const contract = db.prepare('SELECT * FROM contracts WHERE id = ?').get(id);
if (!contract) {
return Response.json(
{ error: '合同不存在' },
{ status: 404 }
);
}
// 获取附件
const attachments = db.prepare(
'SELECT * FROM contract_attachments WHERE contract_id = ? ORDER BY created_at DESC'
).all(id);
return Response.json({
...contract,
attachments,
opinions: buildOpinions(contract),
});
} catch (error) {
console.error('获取合同详情失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// PUT /api/contracts/[id] - 更新合同(仅创建者可更新,且合同状态为 rejected 时才能更新)
export async function PUT(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const db = await getDb();
const contract = db.prepare('SELECT * FROM contracts WHERE id = ?').get(id);
if (!contract) {
return Response.json(
{ error: '合同不存在' },
{ status: 404 }
);
}
// 只有创建者可以更新
if (contract.creator_id !== user.id) {
return Response.json(
{ error: '只有合同创建者可以修改' },
{ status: 403 }
);
}
// 只有被驳回的合同才能修改
if (contract.status !== 'rejected') {
return Response.json(
{ error: '只有被驳回的合同才能修改' },
{ status: 400 }
);
}
const body = await request.json();
const {
project_name, partner_id, contract_content, contract_years,
contract_amount, estimated_profit, payment_type, sign_date, project_leader,
} = body;
// 查找合作单位名称
let partner_name = contract.partner_name;
if (partner_id && partner_id !== contract.partner_id) {
const partner = db.prepare('SELECT name FROM partners WHERE id = ? AND is_deleted = 0').get(partner_id);
if (!partner) {
return Response.json(
{ error: '合作单位不存在' },
{ status: 400 }
);
}
partner_name = partner.name;
}
// 更新合同并重置状态和审批字段
db.prepare(`
UPDATE contracts SET
project_name = ?,
partner_id = ?,
partner_name = ?,
contract_content = ?,
contract_years = ?,
contract_amount = ?,
estimated_profit = ?,
payment_type = ?,
sign_date = ?,
project_leader = ?,
status = 'pending_supervisor',
supervisor_opinion = NULL,
supervisor_approved = NULL,
supervisor_id = NULL,
supervisor_name = NULL,
supervisor_time = NULL,
finance_opinion = NULL,
finance_approved = NULL,
finance_id = NULL,
finance_name = NULL,
finance_time = NULL,
secretary_opinion = NULL,
secretary_approved = NULL,
secretary_id = NULL,
secretary_name = NULL,
secretary_time = NULL,
reject_reason = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
project_name || contract.project_name,
partner_id || contract.partner_id,
partner_name,
contract_content !== undefined ? contract_content : contract.contract_content,
contract_years !== undefined ? contract_years : contract.contract_years,
contract_amount !== undefined ? contract_amount : contract.contract_amount,
estimated_profit !== undefined ? estimated_profit : contract.estimated_profit,
payment_type || contract.payment_type,
sign_date !== undefined ? sign_date : contract.sign_date,
project_leader !== undefined ? project_leader : contract.project_leader,
id
);
const updated = db.prepare('SELECT * FROM contracts WHERE id = ?').get(id);
// 获取附件
const attachments = db.prepare(
'SELECT * FROM contract_attachments WHERE contract_id = ? ORDER BY created_at DESC'
).all(id);
return Response.json({
...updated,
attachments,
opinions: buildOpinions(updated),
});
} catch (error) {
console.error('更新合同失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+135
View File
@@ -0,0 +1,135 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
import ExcelJS from 'exceljs';
// GET /api/contracts/export - 导出合同为 Excel
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const search = searchParams.get('search');
const date_from = searchParams.get('date_from');
const date_to = searchParams.get('date_to');
const department_id = searchParams.get('department_id');
const db = await getDb();
let whereClauses = [];
let queryParams = [];
// 根据角色应用可见性规则(与列表 API 相同)
switch (user.role) {
case 'employee':
whereClauses.push('c.creator_id = ?');
queryParams.push(user.id);
break;
case 'supervisor':
whereClauses.push('c.department_id = ?');
queryParams.push(user.department_id);
break;
case 'finance':
case 'secretary':
case 'admin':
break;
}
if (status && status !== 'all') {
whereClauses.push('c.status = ?');
queryParams.push(status);
}
if (search && search.trim()) {
whereClauses.push('(c.project_name LIKE ? OR c.partner_name LIKE ?)');
const keyword = `%${search.trim()}%`;
queryParams.push(keyword, keyword);
}
if (date_from) {
whereClauses.push('c.sign_date >= ?');
queryParams.push(date_from);
}
if (date_to) {
whereClauses.push('c.sign_date <= ?');
queryParams.push(date_to);
}
if (department_id) {
whereClauses.push('c.department_id = ?');
queryParams.push(department_id);
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
const contracts = db.prepare(
`SELECT c.* FROM contracts c ${whereSQL} ORDER BY c.created_at DESC`
).all(...queryParams);
// 创建 Excel 工作簿
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('合同列表');
// 设置列
worksheet.columns = [
{ header: '经办日期', key: 'created_at', width: 18 },
{ header: '经办人', key: 'creator_name', width: 12 },
{ header: '经办部门', key: 'department_name', width: 15 },
{ header: '项目负责人', key: 'project_leader', width: 12 },
{ header: '签订时间', key: 'sign_date', width: 15 },
{ header: '项目名称', key: 'project_name', width: 25 },
{ header: '合作单位', key: 'partner_name', width: 25 },
{ header: '项目内容', key: 'contract_content', width: 30 },
{ header: '合同总金额', key: 'contract_amount', width: 15 },
{ header: '预估利润', key: 'estimated_profit', width: 15 },
{ header: '收款/付款', key: 'payment_type', width: 12 },
{ header: '初审意见', key: 'supervisor_opinion', width: 20 },
{ header: '财务部意见', key: 'finance_opinion', width: 20 },
{ header: '终审意见', key: 'secretary_opinion', width: 20 },
];
// 设置表头样式
const headerRow = worksheet.getRow(1);
headerRow.font = { bold: true };
headerRow.alignment = { horizontal: 'center' };
// 添加数据
const paymentTypeMap = { receive: '收款', pay: '付款' };
for (const contract of contracts) {
worksheet.addRow({
created_at: contract.created_at ? contract.created_at.substring(0, 10) : '',
creator_name: contract.creator_name || '',
department_name: contract.department_name || '',
project_leader: contract.project_leader || '',
sign_date: contract.sign_date || '',
project_name: contract.project_name || '',
partner_name: contract.partner_name || '',
contract_content: contract.contract_content || '',
contract_amount: contract.contract_amount || 0,
estimated_profit: contract.estimated_profit !== null && contract.estimated_profit !== undefined ? contract.estimated_profit : '',
payment_type: paymentTypeMap[contract.payment_type] || contract.payment_type || '',
supervisor_opinion: contract.supervisor_opinion || '',
finance_opinion: contract.finance_opinion || '',
secretary_opinion: contract.secretary_opinion || '',
});
}
// 生成 Excel 文件
const buffer = await workbook.xlsx.writeBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename=contracts_${new Date().toISOString().substring(0, 10)}.xlsx`,
},
});
} catch (error) {
console.error('导出合同失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+405
View File
@@ -0,0 +1,405 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
import ExcelJS from 'exceljs';
import path from 'path';
// Helper to parse numeric values from Excel
const parseNum = (val) => {
if (val === null || val === undefined || val === '') return null;
if (typeof val === 'number') return val;
const cleaned = String(val).replace(/[¥$,\s]/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
};
// Helper to fuzzy match Chinese names (handles sound-alike/homophone typos)
const findUserFuzzy = (name, userMap, allUsers) => {
if (!name) return null;
const cleanedName = name.trim();
// 1. 尝试精确匹配
if (userMap.has(cleanedName)) {
return userMap.get(cleanedName);
}
// 2. 尝试模糊匹配(如 乔新晓 匹配 乔欣晓)
// 三字姓名:如果姓和名匹配2个字,即可认为同一人
if (cleanedName.length === 3) {
for (const u of allUsers) {
if (u.real_name.length === 3) {
let matchCount = 0;
if (u.real_name[0] === cleanedName[0]) matchCount++;
if (u.real_name[1] === cleanedName[1]) matchCount++;
if (u.real_name[2] === cleanedName[2]) matchCount++;
if (matchCount >= 2) {
return u;
}
}
}
} else if (cleanedName.length === 2) {
// 二字姓名:如果姓(第一个字)相同,即可作为候选匹配
for (const u of allUsers) {
if (u.real_name.length === 2 && u.real_name[0] === cleanedName[0]) {
return u;
}
}
}
return null;
};
// Helper to parse dates from Excel (fixes 1970-01-01 Excel serial date number issue)
const parseDate = (val) => {
if (val instanceof Date) {
return val.toISOString().substring(0, 10);
}
if (val === null || val === undefined || val === '') return null;
const strVal = String(val).trim();
// 1. 针对 YYYYMMDD (8位数字) 格式的处理
if (/^\d{8}$/.test(strVal)) {
const year = strVal.substring(0, 4);
const month = strVal.substring(4, 6);
const day = strVal.substring(6, 8);
return `${year}-${month}-${day}`;
}
// 2. 如果是数字且在合理范围内,代表 Excel 序列日期数(40000 - 60000 之间为近十年日期)
if (typeof val === 'number' && val > 0 && val < 100000) {
const excelEpoch = new Date(Date.UTC(1899, 11, 30));
const msPerDay = 24 * 60 * 60 * 1000;
const date = new Date(excelEpoch.getTime() + val * msPerDay);
if (!isNaN(date.getTime())) {
return date.toISOString().substring(0, 10);
}
}
// 3. 针对中文常见日期字符串格式(如“2023年10月24日”)进行正则提取,防止 JS 原生 Date 解析报错
const match = strVal.match(/(\d{4})[-/年. ]\s*(\d{1,2})[-/月. ]\s*(\d{1,2})[日\s]*/);
if (match) {
const year = match[1];
const month = match[2].padStart(2, '0');
const day = match[3].padStart(2, '0');
return `${year}-${month}-${day}`;
}
const date = new Date(val);
if (!isNaN(date.getTime())) {
return date.toISOString().substring(0, 10);
}
return strVal;
};
// POST /api/contracts/import - 导入历史已通过合同 Excel
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return Response.json(
{ error: '请选择要上传的 Excel 文件' },
{ status: 400 }
);
}
const arrayBuffer = await file.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(Buffer.from(arrayBuffer));
const worksheet = workbook.worksheets[0];
if (!worksheet) {
return Response.json(
{ error: 'Excel 工作表为空' },
{ status: 400 }
);
}
const db = await getDb();
// 预先获取全量用户用于匹配经办人和项目负责人
const allUsers = db.prepare('SELECT id, username, real_name, role, department_id FROM users').all();
const userMap = new Map();
for (const u of allUsers) {
userMap.set(u.real_name, u);
}
// 预先获取全量部门名称和ID映射
const allDepts = db.prepare('SELECT id, name FROM departments').all();
const deptMap = new Map();
const deptIdToNameMap = new Map();
for (const d of allDepts) {
deptMap.set(d.name, d.id);
deptIdToNameMap.set(d.id, d.name);
}
// 预先获取合作伙伴列表
const allPartners = db.prepare('SELECT id, name FROM partners WHERE is_deleted = 0').all();
const partnerMap = new Map();
for (const p of allPartners) {
partnerMap.set(p.name, p.id);
}
// 解析表头列索引
const headers = {};
const headerRow = worksheet.getRow(1);
headerRow.eachCell((cell, colNumber) => {
const text = cell.text ? cell.text.trim() : '';
if (!text) return;
if (text.includes('创建时间') || text.includes('经办日期')) headers.created_at = colNumber;
else if (text.includes('经办人') || text.includes('创建人')) headers.creator_name = colNumber;
else if (text.includes('所属部门')) headers.department_name = colNumber;
else if (text.includes('项目负责人') || text.includes('负责人')) headers.project_leader = colNumber;
else if (text.includes('签订') || text.includes('签署') || text.includes('签字') || text.includes('签章')) headers.sign_date = colNumber;
else if (text.includes('项目名称') || text.includes('合同名称')) headers.project_name = colNumber;
else if (text.includes('合作单位') || text.includes('合作企业')) headers.partner_name = colNumber;
else if (text.includes('项目内容') || text.includes('合同内容')) headers.contract_content = colNumber;
else if (text.includes('合同总金额') || text.includes('合同金额') || text.includes('金额')) headers.contract_amount = colNumber;
else if (text.includes('预估利润') || text.includes('利润')) headers.estimated_profit = colNumber;
else if (text.includes('收款/付款') || text.includes('类型')) headers.payment_type = colNumber;
else if (text.includes('初审意见') || text.includes('主管意见')) headers.supervisor_opinion = colNumber;
else if (text.includes('财务部意见') || text.includes('财务意见')) headers.finance_opinion = colNumber;
else if (text.includes('终审意见') || text.includes('秘书长意见')) headers.secretary_opinion = colNumber;
else if (text.includes('合同期限') || text.includes('合作期限')) headers.contract_years = colNumber;
});
// 默认审核人员(财务和秘书长)
const financeUser = allUsers.find(u => u.role === 'finance');
const finance_id = financeUser ? financeUser.id : null;
const finance_name = financeUser ? financeUser.real_name : null;
const secretaryUser = allUsers.find(u => u.role === 'secretary');
const secretary_id = secretaryUser ? secretaryUser.id : null;
const secretary_name = secretaryUser ? secretaryUser.real_name : null;
let importedCount = 0;
// 使用事务包装批量写入操作,极大提升 SQLite 性能并保证数据一致性
const runImport = db.transaction(() => {
for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
const row = worksheet.getRow(rowNumber);
console.log(`[Importer] Processing row: ${rowNumber}, Has Values: ${row.hasValues}`);
if (!row.hasValues) continue;
const getVal = (key) => {
const colIndex = headers[key];
if (!colIndex) return undefined;
const cell = row.getCell(colIndex);
if (!cell || !cell.value) return undefined;
// Handle formula objects
if (typeof cell.value === 'object' && cell.value !== null && cell.value.formula) {
return cell.value.result;
}
return cell.value;
};
const getValText = (key) => {
const colIndex = headers[key];
if (!colIndex) return '';
const cell = row.getCell(colIndex);
if (!cell) return '';
return cell.text ? cell.text.trim() : '';
};
const project_name = getValText('project_name');
// if (!project_name) continue; // 项目名称不能为空,略过空行
// 1. 匹配经办人与部门
const excelCreator = getValText('creator_name');
let creator_id = user.id; // 默认当前管理员
let creator_name = excelCreator || user.real_name;
let department_id = null;
let department_name = null;
if (excelCreator) {
const matchedUser = findUserFuzzy(excelCreator, userMap, allUsers);
if (matchedUser) {
creator_id = matchedUser.id;
creator_name = matchedUser.real_name;
department_id = matchedUser.department_id;
department_name = deptIdToNameMap.get(department_id) || null;
}
}
// 如果经办人没有匹配到,看 Excel 中是否填了“所属部门”
if (!department_id) {
const excelDept = getValText('department_name');
if (excelDept && deptMap.has(excelDept)) {
department_id = deptMap.get(excelDept);
department_name = excelDept;
}
}
// 2. 匹配或自动创建合作单位
let partner_name = getValText('partner_name');
let partner_id = null;
if (!partner_name) {
partner_name = '历史导入合作单位';
}
if (partnerMap.has(partner_name)) {
partner_id = partnerMap.get(partner_name);
} else {
// 数据库没有,动态插入合作单位作为种子数据
const pResult = db.prepare(
'INSERT INTO partners (name, is_deleted) VALUES (?, 0)'
).run(partner_name);
partner_id = pResult.lastInsertRowid;
partnerMap.set(partner_name, partner_id);
}
// 3. 匹配项目负责人(部门主管)
const excelLeader = getValText('project_leader');
let project_leader = excelLeader || null;
let matchedLeaderUser = null;
if (excelLeader) {
const matchedLeader = findUserFuzzy(excelLeader, userMap, allUsers);
if (matchedLeader) {
project_leader = matchedLeader.real_name;
matchedLeaderUser = matchedLeader;
}
}
// 如果上面经办人没能定下部门(例如经办人在库里不存在且Excel无部门),
// 但项目负责人(主管)匹配成功了,则以项目负责人的部门作为合同的归属部门,这样主管就可以看得到了!
if (!department_id && matchedLeaderUser && matchedLeaderUser.department_id) {
department_id = matchedLeaderUser.department_id;
department_name = deptIdToNameMap.get(department_id) || null;
}
// 如果还是没有,回退到当前登录账号(管理员)的部门
if (!department_id) {
department_id = user.department_id || null;
department_name = deptIdToNameMap.get(department_id) || null;
}
// 4. 收付款类型
const excelPayment = getValText('payment_type');
let payment_type = 'receive';
if (excelPayment.includes('付') || excelPayment.toLowerCase().includes('pay')) {
payment_type = 'pay';
}
// 5. 合同金额与预估利润
const contract_amount = parseNum(getVal('contract_amount')) || 0;
// 如果是付款,预估利润强置为 null,否则允许解析为空或数字
let estimated_profit = null;
if (payment_type !== 'pay') {
estimated_profit = parseNum(getVal('estimated_profit'));
}
// 6. 日期格式解析
const created_at_val = parseDate(getValText('created_at'));
const sign_date = parseDate(getValText('sign_date'));
const created_at = created_at_val || sign_date || new Date().toISOString().substring(0, 10);
const approval_time = sign_date || created_at || new Date().toISOString().substring(0, 10);
// 7. 匹配部门初审主管作为初审人
let supervisor_id = null;
let supervisor_name = null;
if (matchedLeaderUser) {
supervisor_id = matchedLeaderUser.id;
supervisor_name = matchedLeaderUser.real_name;
} else if (department_id) {
const matchedSupervisor = allUsers.find(u => u.department_id === department_id && u.role === 'supervisor');
if (matchedSupervisor) {
supervisor_id = matchedSupervisor.id;
supervisor_name = matchedSupervisor.real_name;
}
}
// 如果上面没配出主管,但是项目负责人匹配出来的正是一个主管,则直接用他
if (!supervisor_name && matchedLeaderUser && matchedLeaderUser.role === 'supervisor') {
supervisor_id = matchedLeaderUser.id;
supervisor_name = matchedLeaderUser.real_name;
}
if (!supervisor_name) {
const anySupervisor = allUsers.find(u => u.role === 'supervisor');
if (anySupervisor) {
supervisor_id = anySupervisor.id;
supervisor_name = anySupervisor.real_name;
}
}
// 8. 审核意见(默认为 '无')
const supervisor_opinion = getValText('supervisor_opinion') || '无';
const finance_opinion = getValText('finance_opinion') || '无';
const secretary_opinion = getValText('secretary_opinion') || '无';
const contract_content = getValText('contract_content') || null;
const contract_years = getValText('contract_years') || null; // 留空即可
// 9. 写入数据库并设为终审通过状态
db.prepare(`
INSERT INTO contracts (
project_name, partner_id, partner_name, contract_content, contract_years,
contract_amount, estimated_profit, payment_type, sign_date,
creator_id, creator_name, department_id, department_name, project_leader,
status,
supervisor_opinion, supervisor_approved, supervisor_id, supervisor_name, supervisor_time,
finance_opinion, finance_approved, finance_id, finance_name, finance_time,
secretary_opinion, secretary_approved, secretary_id, secretary_name, secretary_time,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?, 1, ?, ?, ?, ?, 1, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
`).run(
project_name,
partner_id,
partner_name,
contract_content,
contract_years,
contract_amount,
estimated_profit,
payment_type,
sign_date,
creator_id,
creator_name,
department_id,
department_name,
project_leader,
supervisor_opinion,
supervisor_id,
supervisor_name,
null, // supervisor_time
finance_opinion,
finance_id,
finance_name,
null, // finance_time
secretary_opinion,
secretary_id,
secretary_name,
null, // secretary_time,
created_at,
created_at
);
importedCount++;
}
return importedCount;
});
const totalImported = runImport();
console.log(`[Contract Import] Successfully imported ${totalImported} rows.`);
return Response.json({
success: true,
message: `成功导入了 ${totalImported} 条合同数据。`,
count: totalImported
});
} catch (error) {
console.error('导入合同数据失败:', error);
return Response.json(
{ error: `导入失败: ${error.message || '服务器内部错误'}` },
{ status: 500 }
);
}
}
+183
View File
@@ -0,0 +1,183 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
// GET /api/contracts - 获取合同列表(带筛选和分页)
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const search = searchParams.get('search');
const date_from = searchParams.get('date_from');
const date_to = searchParams.get('date_to');
const department_id = searchParams.get('department_id');
const page = parseInt(searchParams.get('page') || '1', 10);
const page_size = parseInt(searchParams.get('page_size') || '10', 10);
const db = await getDb();
let whereClauses = [];
let queryParams = [];
// 根据角色应用可见性规则
switch (user.role) {
case 'employee':
// 员工只能看到自己创建的合同
whereClauses.push('c.creator_id = ?');
queryParams.push(user.id);
break;
case 'supervisor':
// 主管只能看到本部门的合同
whereClauses.push('c.department_id = ?');
queryParams.push(user.department_id);
break;
case 'finance':
case 'secretary':
case 'admin':
// 财务、秘书长、管理员可以看到所有合同
break;
}
// 状态筛选
if (status && status !== 'all') {
whereClauses.push('c.status = ?');
queryParams.push(status);
}
// 搜索筛选(项目名称、合作单位名称)
if (search && search.trim()) {
whereClauses.push('(c.project_name LIKE ? OR c.partner_name LIKE ?)');
const keyword = `%${search.trim()}%`;
queryParams.push(keyword, keyword);
}
// 日期范围筛选
if (date_from) {
whereClauses.push('c.sign_date >= ?');
queryParams.push(date_from);
}
if (date_to) {
whereClauses.push('c.sign_date <= ?');
queryParams.push(date_to);
}
// 部门筛选
if (department_id) {
whereClauses.push('c.department_id = ?');
queryParams.push(department_id);
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
// 获取总数
const countRow = db.prepare(
`SELECT COUNT(*) as total FROM contracts c ${whereSQL}`
).get(...queryParams);
const total = countRow.total;
// 获取分页数据
const offset = (page - 1) * page_size;
const contracts = db.prepare(
`SELECT c.*, p.name as partner_name
FROM contracts c
LEFT JOIN partners p ON c.partner_id = p.id
${whereSQL}
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`
).all(...queryParams, page_size, offset);
return Response.json({
contracts,
data: contracts,
total,
page,
page_size,
});
} catch (error) {
console.error('获取合同列表失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// POST /api/contracts - 创建合同(仅员工可创建)
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['employee'])) {
return forbiddenResponse();
}
const body = await request.json();
const {
project_name, partner_id, contract_content, contract_years,
contract_amount, estimated_profit, payment_type, sign_date, project_leader,
} = body;
if (!project_name || !partner_id) {
return Response.json(
{ error: '项目名称和合作单位不能为空' },
{ status: 400 }
);
}
const db = await getDb();
// 获取用户完整信息(包含部门)
const fullUser = db.prepare(
`SELECT u.*, d.name as department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?`
).get(user.id);
// 查找合作单位名称
const partner = db.prepare('SELECT name FROM partners WHERE id = ? AND is_deleted = 0').get(partner_id);
if (!partner) {
return Response.json(
{ error: '合作单位不存在' },
{ status: 400 }
);
}
const result = db.prepare(`
INSERT INTO contracts (
project_name, partner_id, partner_name, contract_content, contract_years,
contract_amount, estimated_profit, payment_type, sign_date,
creator_id, creator_name, department_id, department_name, project_leader,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_supervisor')
`).run(
project_name,
partner_id,
partner.name,
contract_content || null,
contract_years || null,
contract_amount || 0,
estimated_profit !== undefined && estimated_profit !== null ? estimated_profit : null,
payment_type || 'receive',
sign_date || null,
fullUser.id,
fullUser.real_name,
fullUser.department_id,
fullUser.department_name || null,
project_leader || null
);
const contract = db.prepare('SELECT * FROM contracts WHERE id = ?').get(result.lastInsertRowid);
return Response.json(contract, { status: 201 });
} catch (error) {
console.error('创建合同失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+96
View File
@@ -0,0 +1,96 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
// PUT /api/departments/[id] - 更新部门(仅管理员)
export async function PUT(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { id } = await params;
const { name } = await request.json();
if (!name || !name.trim()) {
return Response.json(
{ error: '部门名称不能为空' },
{ status: 400 }
);
}
const db = await getDb();
// 检查部门是否存在
const dept = db.prepare('SELECT id FROM departments WHERE id = ?').get(id);
if (!dept) {
return Response.json(
{ error: '部门不存在' },
{ status: 404 }
);
}
// 检查部门名是否重复(排除自身)
const existing = db.prepare('SELECT id FROM departments WHERE name = ? AND id != ?').get(name.trim(), id);
if (existing) {
return Response.json(
{ error: '部门名称已存在' },
{ status: 400 }
);
}
db.prepare('UPDATE departments SET name = ? WHERE id = ?').run(name.trim(), id);
const updated = db.prepare('SELECT * FROM departments WHERE id = ?').get(id);
return Response.json(updated);
} catch (error) {
console.error('更新部门失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// DELETE /api/departments/[id] - 删除部门(仅管理员)
export async function DELETE(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { id } = await params;
const db = await getDb();
// 检查部门是否存在
const dept = db.prepare('SELECT id FROM departments WHERE id = ?').get(id);
if (!dept) {
return Response.json(
{ error: '部门不存在' },
{ status: 404 }
);
}
// 使用事务:解除关联并删除部门
const deleteTx = db.transaction(() => {
// 1. 将该部门下的用户的 department_id 设为 null
db.prepare('UPDATE users SET department_id = NULL WHERE department_id = ?').run(id);
// 2. 将该部门下的合同的 department_id 设为 null (保留 department_name 做历史记录)
db.prepare('UPDATE contracts SET department_id = NULL WHERE department_id = ?').run(id);
// 3. 删除部门
db.prepare('DELETE FROM departments WHERE id = ?').run(id);
});
deleteTx();
return Response.json({ message: '删除成功' });
} catch (error) {
console.error('删除部门失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
// GET /api/departments - 获取所有部门列表
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const db = await getDb();
const departments = db.prepare('SELECT * FROM departments ORDER BY id ASC').all();
return Response.json(departments);
} catch (error) {
console.error('获取部门列表失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// POST /api/departments - 创建部门(仅管理员)
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { name } = await request.json();
if (!name || !name.trim()) {
return Response.json(
{ error: '部门名称不能为空' },
{ status: 400 }
);
}
const db = await getDb();
// 检查部门名是否重复
const existing = db.prepare('SELECT id FROM departments WHERE name = ?').get(name.trim());
if (existing) {
return Response.json(
{ error: '部门名称已存在' },
{ status: 400 }
);
}
const result = db.prepare('INSERT INTO departments (name) VALUES (?)').run(name.trim());
const department = db.prepare('SELECT * FROM departments WHERE id = ?').get(result.lastInsertRowid);
return Response.json(department, { status: 201 });
} catch (error) {
console.error('创建部门失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+113
View File
@@ -0,0 +1,113 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
// GET /api/partners/[id] - 获取单个合作单位
export async function GET(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const db = await getDb();
const partner = db.prepare('SELECT * FROM partners WHERE id = ? AND is_deleted = 0').get(id);
if (!partner) {
return Response.json(
{ error: '合作单位不存在' },
{ status: 404 }
);
}
return Response.json(partner);
} catch (error) {
console.error('获取合作单位失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// PUT /api/partners/[id] - 更新合作单位
export async function PUT(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const body = await request.json();
const { name, short_name, english_name, website, logo, address, description } = body;
const db = await getDb();
const existing = db.prepare('SELECT id FROM partners WHERE id = ? AND is_deleted = 0').get(id);
if (!existing) {
return Response.json(
{ error: '合作单位不存在' },
{ status: 404 }
);
}
if (!name || !name.trim()) {
return Response.json(
{ error: '合作单位名称不能为空' },
{ status: 400 }
);
}
db.prepare(
`UPDATE partners SET name = ?, short_name = ?, english_name = ?, website = ?, logo = ?, address = ?, description = ?
WHERE id = ?`
).run(
name.trim(),
short_name || null,
english_name || null,
website || null,
logo || null,
address || null,
description || null,
id
);
const updated = db.prepare('SELECT * FROM partners WHERE id = ?').get(id);
return Response.json(updated);
} catch (error) {
console.error('更新合作单位失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// DELETE /api/partners/[id] - 软删除合作单位
export async function DELETE(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const db = await getDb();
const existing = db.prepare('SELECT id FROM partners WHERE id = ? AND is_deleted = 0').get(id);
if (!existing) {
return Response.json(
{ error: '合作单位不存在' },
{ status: 404 }
);
}
db.prepare('UPDATE partners SET is_deleted = 1 WHERE id = ?').run(id);
return Response.json({ message: '删除成功' });
} catch (error) {
console.error('删除合作单位失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+79
View File
@@ -0,0 +1,79 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
// GET /api/partners - 获取合作单位列表
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { searchParams } = new URL(request.url);
const search = searchParams.get('search');
const db = await getDb();
let sql = 'SELECT * FROM partners WHERE is_deleted = 0';
const queryParams = [];
if (search && search.trim()) {
sql += ' AND (name LIKE ? OR short_name LIKE ?)';
const keyword = `%${search.trim()}%`;
queryParams.push(keyword, keyword);
}
sql += ' ORDER BY id DESC';
const partners = db.prepare(sql).all(...queryParams);
return Response.json(partners);
} catch (error) {
console.error('获取合作单位列表失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// POST /api/partners - 创建合作单位
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const body = await request.json();
const { name, short_name, english_name, website, logo, address, description } = body;
if (!name || !name.trim()) {
return Response.json(
{ error: '合作单位名称不能为空' },
{ status: 400 }
);
}
const db = await getDb();
const result = db.prepare(
'INSERT INTO partners (name, short_name, english_name, website, logo, address, description) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(
name.trim(),
short_name || null,
english_name || null,
website || null,
logo || null,
address || null,
description || null
);
const partner = db.prepare('SELECT * FROM partners WHERE id = ?').get(result.lastInsertRowid);
return Response.json(partner, { status: 201 });
} catch (error) {
console.error('创建合作单位失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+78
View File
@@ -0,0 +1,78 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
// GET /api/permissions - 获取所有角色权限(仅管理员)
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const db = await getDb();
const permissions = db.prepare(
'SELECT * FROM role_permissions ORDER BY role, menu_key'
).all();
return Response.json(permissions);
} catch (error) {
console.error('获取权限配置失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// PUT /api/permissions - 更新角色权限(仅管理员)
export async function PUT(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
let body = await request.json();
if (!Array.isArray(body)) {
if (body && Array.isArray(body.permissions)) {
body = body.permissions;
} else {
return Response.json(
{ error: '请提供权限配置数组' },
{ status: 400 }
);
}
}
const db = await getDb();
const upsertStmt = db.prepare(`
INSERT INTO role_permissions (role, menu_key, visible)
VALUES (?, ?, ?)
ON CONFLICT(role, menu_key)
DO UPDATE SET visible = excluded.visible
`);
const updateMany = db.transaction(() => {
for (const item of body) {
if (item.role && item.menu_key !== undefined && item.visible !== undefined) {
upsertStmt.run(item.role, item.menu_key, item.visible ? 1 : 0);
}
}
});
updateMany();
// 返回更新后的权限
const permissions = db.prepare(
'SELECT * FROM role_permissions ORDER BY role, menu_key'
).all();
return Response.json(permissions);
} catch (error) {
console.error('更新权限配置失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+127
View File
@@ -0,0 +1,127 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse } from '@/lib/auth';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';
// POST /api/upload - 上传文件附件
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const formData = await request.formData();
const file = formData.get('file');
const contract_id = formData.get('contract_id');
if (!file) {
return Response.json(
{ error: '请选择要上传的文件' },
{ status: 400 }
);
}
if (!contract_id) {
return Response.json(
{ error: '请指定合同ID' },
{ status: 400 }
);
}
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'attachments');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 生成唯一文件名
const ext = path.extname(file.name) || '';
const fileName = `${uuidv4()}${ext}`;
const filePath = path.join(uploadDir, fileName);
// 保存文件
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer);
// 相对于 public 的路径
const publicPath = `/api/upload?file=${fileName}`;
// 插入附件记录
const db = await getDb();
const result = db.prepare(
'INSERT INTO contract_attachments (contract_id, file_name, file_path, file_size) VALUES (?, ?, ?, ?)'
).run(contract_id, file.name, publicPath, buffer.length);
const attachment = db.prepare('SELECT * FROM contract_attachments WHERE id = ?').get(result.lastInsertRowid);
return Response.json(attachment, { status: 201 });
} catch (error) {
console.error('上传文件失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// GET /api/upload - 动态读取并服务已上传的文件(规避 Next.js 静态文件缓存导致的 404)
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { searchParams } = new URL(request.url);
const file = searchParams.get('file');
if (!file) {
return Response.json(
{ error: '缺少文件名' },
{ status: 400 }
);
}
// 过滤路径,防止路径穿越安全漏洞
const cleanFile = path.basename(file);
const filePath = path.join(process.cwd(), 'public', 'uploads', 'attachments', cleanFile);
if (!fs.existsSync(filePath)) {
return Response.json(
{ error: '文件不存在' },
{ status: 404 }
);
}
const buffer = fs.readFileSync(filePath);
const ext = path.extname(cleanFile).toLowerCase();
// 映射 Content-Type
let contentType = 'application/octet-stream';
if (ext === '.png') contentType = 'image/png';
else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg';
else if (ext === '.gif') contentType = 'image/gif';
else if (ext === '.webp') contentType = 'image/webp';
else if (ext === '.bmp') contentType = 'image/bmp';
else if (ext === '.pdf') contentType = 'application/pdf';
else if (ext === '.xlsx') contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
else if (ext === '.xls') contentType = 'application/vnd.ms-excel';
else if (ext === '.docx') contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
else if (ext === '.txt') contentType = 'text/plain; charset=utf-8';
else if (ext === '.csv') contentType = 'text/csv; charset=utf-8';
else if (ext === '.json') contentType = 'application/json; charset=utf-8';
return new Response(buffer, {
headers: {
'Content-Type': contentType,
'Content-Length': buffer.length.toString(),
'Content-Disposition': `inline; filename="${encodeURIComponent(cleanFile)}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
} catch (error) {
console.error('获取文件失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+157
View File
@@ -0,0 +1,157 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
import bcrypt from 'bcryptjs';
// GET /api/users/[id] - 获取单个用户信息
export async function GET(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const { id } = await params;
const db = await getDb();
const targetUser = db.prepare(
`SELECT u.id, u.username, u.real_name, u.role, u.department_id, u.status, u.created_at,
d.name as department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?`
).get(id);
if (!targetUser) {
return Response.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
return Response.json(targetUser);
} catch (error) {
console.error('获取用户信息失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// PUT /api/users/[id] - 更新用户(仅管理员)
export async function PUT(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { id } = await params;
const body = await request.json();
const { username, password, real_name, role, department_id, status } = body;
const db = await getDb();
// 检查用户是否存在
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
if (!existing) {
return Response.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
// 检查用户名是否重复(排除自身)
if (username) {
const duplicate = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id);
if (duplicate) {
return Response.json(
{ error: '用户名已存在' },
{ status: 400 }
);
}
}
// 构建更新字段
const updates = [];
const values = [];
if (username !== undefined) { updates.push('username = ?'); values.push(username); }
if (real_name !== undefined) { updates.push('real_name = ?'); values.push(real_name); }
if (role !== undefined) { updates.push('role = ?'); values.push(role); }
if (department_id !== undefined) { updates.push('department_id = ?'); values.push(department_id || null); }
if (status !== undefined) { updates.push('status = ?'); values.push(status); }
// 如果提供了密码,加密后更新
if (password) {
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);
updates.push('password = ?');
values.push(hashedPassword);
}
if (updates.length === 0) {
return Response.json(
{ error: '没有需要更新的字段' },
{ status: 400 }
);
}
values.push(id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
const updatedUser = db.prepare(
`SELECT u.id, u.username, u.real_name, u.role, u.department_id, u.status, u.created_at,
d.name as department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?`
).get(id);
return Response.json(updatedUser);
} catch (error) {
console.error('更新用户失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - 直接删除用户(仅管理员)
export async function DELETE(request, { params }) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { id } = await params;
const db = await getDb();
// 检查用户是否存在
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
if (!existing) {
return Response.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
// 不能删除自己
if (Number(id) === user.id) {
return Response.json(
{ error: '不能删除自己的账号' },
{ status: 400 }
);
}
// 直接删除用户,不保留
db.prepare('DELETE FROM users WHERE id = ?').run(id);
return Response.json({ message: '删除成功' });
} catch (error) {
console.error('删除用户失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
+82
View File
@@ -0,0 +1,82 @@
export const dynamic = "force-dynamic";
import getDb from '@/lib/db';
import { getUserFromRequest, unauthorizedResponse, forbiddenResponse, checkRole } from '@/lib/auth';
import bcrypt from 'bcryptjs';
// GET /api/users - 获取所有用户列表(仅管理员)
export async function GET(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
const db = await getDb();
const users = db.prepare(`
SELECT u.id, u.username, u.real_name, u.role, u.department_id, u.status, u.created_at,
d.name as department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
ORDER BY u.id ASC
`).all();
return Response.json(users);
} catch (error) {
console.error('获取用户列表失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
// POST /api/users - 创建用户(仅管理员)
export async function POST(request) {
try {
const user = getUserFromRequest(request);
if (!user) return unauthorizedResponse();
if (!checkRole(user, ['admin'])) return forbiddenResponse();
const { username, password, real_name, role, department_id } = await request.json();
if (!username || !password || !real_name || !role) {
return Response.json(
{ error: '用户名、密码、姓名和角色不能为空' },
{ status: 400 }
);
}
const db = await getDb();
// 检查用户名是否重复
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return Response.json(
{ error: '用户名已存在' },
{ status: 400 }
);
}
// 加密密码
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);
const result = db.prepare(
'INSERT INTO users (username, password, real_name, role, department_id) VALUES (?, ?, ?, ?, ?)'
).run(username, hashedPassword, real_name, role, department_id || null);
const newUser = db.prepare(
`SELECT u.id, u.username, u.real_name, u.role, u.department_id, u.status, u.created_at,
d.name as department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?`
).get(result.lastInsertRowid);
return Response.json(newUser, { status: 201 });
} catch (error) {
console.error('创建用户失败:', error);
return Response.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
File diff suppressed because it is too large Load Diff
+618
View File
@@ -0,0 +1,618 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '../../layout';
import { authFetch } from '@/lib/auth-client';
export default function CreateContractPage() {
const router = useRouter();
const user = useUser();
// Form state
const [form, setForm] = useState({
project_name: '',
project_leader: '',
sign_date: '',
partner_id: '',
contract_content: '',
cooperate_start: '',
cooperate_end: '',
contract_amount: '',
estimated_profit: '',
payment_type: 'receive',
});
// Supervisors list
const [supervisors, setSupervisors] = useState([]);
// Partner search
const [partners, setPartners] = useState([]);
const [partnerSearch, setPartnerSearch] = useState('');
const [partnerDropdownOpen, setPartnerDropdownOpen] = useState(false);
const [selectedPartnerName, setSelectedPartnerName] = useState('');
const partnerRef = useRef(null);
// File upload
const [files, setFiles] = useState([]);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef(null);
// Config
const [profitPercentage, setProfitPercentage] = useState(0);
const [profitEdited, setProfitEdited] = useState(false);
// UI state
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({});
const [toast, setToast] = useState(null);
// Fetch partners
useEffect(() => {
authFetch('/api/partners')
.then(r => r.json())
.then(data => {
setPartners(data.partners || data || []);
})
.catch(() => {});
}, []);
// Fetch supervisors
useEffect(() => {
authFetch('/api/users')
.then(r => r.json())
.then(data => {
const list = (data || []).filter(u => u.role === 'supervisor' && u.status === 1);
setSupervisors(list);
})
.catch(() => {});
}, []);
// Default supervisor selection based on user's department
useEffect(() => {
if (supervisors.length > 0 && user && !form.project_leader) {
const defaultSupervisor = supervisors.find(s => s.department_id === user.department_id);
if (defaultSupervisor) {
setForm(prev => ({ ...prev, project_leader: defaultSupervisor.real_name }));
} else {
setForm(prev => ({ ...prev, project_leader: supervisors[0]?.real_name || '' }));
}
}
}, [supervisors, user]);
// Fetch config for profit percentage
useEffect(() => {
authFetch('/api/config')
.then(r => r.json())
.then(data => {
const pct = data.profit_percentage || data.config?.profit_percentage || 0;
setProfitPercentage(Number(pct));
})
.catch(() => {});
}, []);
// Auto-calculate profit when amount changes
useEffect(() => {
if (form.payment_type !== 'pay' && !profitEdited && form.contract_amount && profitPercentage > 0) {
const profit = (Number(form.contract_amount) * profitPercentage / 100).toFixed(2);
setForm(prev => ({ ...prev, estimated_profit: profit }));
}
}, [form.contract_amount, profitPercentage, profitEdited, form.payment_type]);
// Close partner dropdown on outside click
useEffect(() => {
const handleClick = (e) => {
if (partnerRef.current && !partnerRef.current.contains(e.target)) {
setPartnerDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleChange = (field, value) => {
setForm(prev => {
const updated = { ...prev, [field]: value };
if (field === 'payment_type' && value === 'pay') {
updated.estimated_profit = '';
}
return updated;
});
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const handleProfitChange = (value) => {
setProfitEdited(true);
handleChange('estimated_profit', value);
};
const filteredPartners = partners.filter(p =>
(p.name || '').toLowerCase().includes(partnerSearch.toLowerCase())
);
const selectPartner = (partner) => {
setForm(prev => ({ ...prev, partner_id: partner.id }));
setSelectedPartnerName(partner.name);
setPartnerSearch(partner.name);
setPartnerDropdownOpen(false);
};
// File handling
const handleFiles = useCallback((newFiles) => {
const fileArray = Array.from(newFiles);
setFiles(prev => [...prev, ...fileArray]);
}, []);
const removeFile = (index) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const handleDragOver = (e) => {
e.preventDefault();
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
};
// Upload files after contract creation
const uploadFiles = async (contractId) => {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('contract_id', contractId);
try {
await authFetch('/api/upload', {
method: 'POST',
body: formData,
});
} catch (err) {
console.error('文件上传失败:', err);
}
}
};
// Validate and submit
const validate = () => {
const newErrors = {};
if (!form.project_name.trim()) newErrors.project_name = '请输入项目名称';
if (!form.partner_id) newErrors.partner_id = '请选择合作单位';
if (!form.contract_amount || Number(form.contract_amount) <= 0) newErrors.contract_amount = '请输入合同金额';
// Validate cooperation duration range
if (!form.cooperate_start || !form.cooperate_end) {
newErrors.contract_years = '请选择完整的合作时间范围';
} else if (form.cooperate_start > form.cooperate_end) {
newErrors.contract_years = '开始时间不能晚于结束时间';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
try {
const body = {
project_name: form.project_name,
project_leader: form.project_leader || null,
sign_date: form.sign_date || null,
partner_id: form.partner_id,
contract_content: form.contract_content,
contract_years: form.cooperate_start && form.cooperate_end ? `${form.cooperate_start} ~ ${form.cooperate_end}` : null,
contract_amount: Number(form.contract_amount),
estimated_profit: form.estimated_profit === '' || form.estimated_profit === null || form.estimated_profit === undefined ? null : Number(form.estimated_profit),
payment_type: form.payment_type,
};
const res = await authFetch('/api/contracts', {
method: 'POST',
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '创建失败', 'error');
setSubmitting(false);
return;
}
const contractId = data.id || data.contract?.id;
// Upload files if any
if (files.length > 0 && contractId) {
await uploadFiles(contractId);
}
showToast('合同创建成功!');
setTimeout(() => {
router.push(`/dashboard/contracts/${contractId}`);
}, 800);
} catch (err) {
showToast(err.message || '提交失败,请重试', 'error');
setSubmitting(false);
}
};
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
if (user && user.role !== 'employee') {
return (
<div className="animate-fadeIn" style={{ maxWidth: 600, margin: '40px auto' }}>
<div className="card" style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>暂无访问权限</h2>
<p style={{ color: 'var(--gray-500)', marginBottom: 24 }}>仅员工角色可新建合同流转任务。</p>
<button className="btn btn-primary" onClick={() => router.push('/dashboard/contracts')}>
返回合同列表
</button>
</div>
</div>
);
}
return (
<div className="animate-fadeIn">
{/* Toast */}
{toast && (
<div className="toast-container">
<div className={`toast toast-${toast.type}`}>
{toast.type === 'success' ? '✅' : '❌'} {toast.message}
</div>
</div>
)}
<div className="page-header">
<div>
<h1 className="page-title">新建合同流转任务</h1>
<p className="page-subtitle">填写合同信息并提交审批</p>
</div>
</div>
<form onSubmit={handleSubmit}>
{/* 基本信息 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-header">
<h3 className="card-title">📝 基本信息</h3>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 20 }}>
<div className="form-group">
<label className="form-label">
项目名称 <span className="required">*</span>
</label>
<input
type="text"
className="form-input"
placeholder="请输入项目名称"
value={form.project_name}
onChange={e => handleChange('project_name', e.target.value)}
/>
{errors.project_name && <div className="form-error">{errors.project_name}</div>}
</div>
<div className="form-group">
<label className="form-label">项目负责人</label>
<select
className="form-select"
value={form.project_leader}
onChange={e => handleChange('project_leader', e.target.value)}
>
<option value="">-- 请选择部门主管 --</option>
{supervisors.map(s => (
<option key={s.id} value={s.real_name}>
{s.real_name} ({s.department_name || '无部门'})
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">签订时间</label>
<input
type="date"
className="form-input"
value={form.sign_date}
onChange={e => handleChange('sign_date', e.target.value)}
/>
</div>
</div>
</div>
</div>
{/* 合作单位 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-header">
<h3 className="card-title">🏢 合作单位 <span className="required">*</span></h3>
</div>
<div className="card-body">
<div className="form-group" ref={partnerRef}>
{form.partner_id ? (
<div className="selected-partner-card animate-fadeIn" style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 14px',
background: 'var(--gray-50)',
border: '1.5px solid var(--gray-200)',
borderRadius: 'var(--radius-md)',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>🏢 {selectedPartnerName}</span>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ color: 'var(--danger)', padding: '4px 8px' }}
onClick={() => {
setForm(prev => ({ ...prev, partner_id: '' }));
setSelectedPartnerName('');
setPartnerSearch('');
setPartnerDropdownOpen(true);
}}
>
重新选择
</button>
</div>
) : (
<div className="search-select">
<input
type="text"
className="search-select-input"
placeholder="搜索合作单位..."
value={partnerSearch}
onChange={e => {
setPartnerSearch(e.target.value);
setPartnerDropdownOpen(true);
}}
onFocus={() => setPartnerDropdownOpen(true)}
/>
{partnerDropdownOpen && (
<div className="search-select-dropdown">
{filteredPartners.length > 0 ? (
filteredPartners.map(p => (
<div
key={p.id}
className={`search-select-option ${form.partner_id === p.id ? 'selected' : ''}`}
onClick={() => selectPartner(p)}
>
{p.name}
</div>
))
) : (
<div className="search-select-empty">
未找到?
<a href="/dashboard/partners" onClick={e => { e.preventDefault(); router.push('/dashboard/partners'); }}>
前往添加合作单位
</a>
</div>
)}
</div>
)}
</div>
)}
{errors.partner_id && <div className="form-error">{errors.partner_id}</div>}
</div>
</div>
</div>
{/* 合同详情 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-header">
<h3 className="card-title">📄 合同详情</h3>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label">合同内容</label>
<textarea
className="form-textarea"
placeholder="请输入合同主要内容描述..."
value={form.contract_content}
onChange={e => handleChange('contract_content', e.target.value)}
rows={4}
/>
</div>
<div className="form-group">
<label className="form-label">合作期限 <span className="required">*</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, maxWidth: 400 }}>
<input
type="date"
className="form-input"
value={form.cooperate_start}
onChange={e => handleChange('cooperate_start', e.target.value)}
/>
<span style={{ color: 'var(--gray-400)' }}>至</span>
<input
type="date"
className="form-input"
value={form.cooperate_end}
onChange={e => handleChange('cooperate_end', e.target.value)}
/>
</div>
{errors.contract_years && <div className="form-error">{errors.contract_years}</div>}
</div>
</div>
</div>
{/* 金额信息 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-header">
<h3 className="card-title">💰 金额信息</h3>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 20 }}>
<div className="form-group">
<label className="form-label">
合同金额(元)<span className="required">*</span>
</label>
<input
type="number"
className="form-input"
placeholder="请输入合同金额"
min="0"
step="0.01"
value={form.contract_amount}
onChange={e => handleChange('contract_amount', e.target.value)}
/>
{errors.contract_amount && <div className="form-error">{errors.contract_amount}</div>}
</div>
{/* <div className="form-group">
<label className="form-label">预估利润(元)</label>
<div style={{ position: 'relative' }}>
<input
type="number"
className="form-input"
placeholder={form.payment_type === 'pay' ? '付款合同无利润' : '自动计算'}
min="0"
step="0.01"
value={form.estimated_profit}
onChange={e => handleProfitChange(e.target.value)}
disabled={form.payment_type === 'pay'}
/>
{form.payment_type !== 'pay' && profitPercentage > 0 && !profitEdited && (
<div className="form-hint">
按默认比例 {profitPercentage}% 自动计算,可手动修改
</div>
)}
{form.payment_type !== 'pay' && profitEdited && (
<div className="form-hint">
<span
style={{ color: 'var(--primary)', cursor: 'pointer' }}
onClick={() => {
setProfitEdited(false);
if (form.contract_amount && profitPercentage > 0) {
const profit = (Number(form.contract_amount) * profitPercentage / 100).toFixed(2);
setForm(prev => ({ ...prev, estimated_profit: profit }));
}
}}
>
恢复自动计算
</span>
</div>
)}
</div>
</div> */}
<div className="form-group">
<label className="form-label">收款/付款</label>
<div style={{ display: 'flex', gap: 24, marginTop: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 14 }}>
<input
type="radio"
name="payment_type"
value="receive"
checked={form.payment_type === 'receive'}
onChange={e => handleChange('payment_type', e.target.value)}
style={{ accentColor: 'var(--primary)' }}
/>
收款
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 14 }}>
<input
type="radio"
name="payment_type"
value="pay"
checked={form.payment_type === 'pay'}
onChange={e => handleChange('payment_type', e.target.value)}
style={{ accentColor: 'var(--primary)' }}
/>
付款
</label>
</div>
</div>
</div>
</div>
</div>
{/* 附件上传 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-header">
<h3 className="card-title">📎 附件上传</h3>
</div>
<div className="card-body">
<div
className={`upload-area ${dragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="upload-icon">📁</div>
<div className="upload-text">点击或拖拽文件到此处上传</div>
<div className="upload-hint">支持各种文件格式</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => {
if (e.target.files.length > 0) {
handleFiles(e.target.files);
e.target.value = '';
}
}}
/>
</div>
{files.length > 0 && (
<div className="file-list">
{files.map((file, index) => (
<div key={index} className="file-item">
<span className="file-item-name">
📄 {file.name}
<span style={{ color: 'var(--gray-400)', fontSize: 12 }}>
({formatFileSize(file.size)})
</span>
</span>
<button
type="button"
className="file-item-remove"
onClick={() => removeFile(index)}
>
✕
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => router.back()}
>
取消
</button>
<button
type="submit"
className="btn btn-primary btn-lg"
disabled={submitting}
>
{submitting ? '提交中...' : '✨ 提交审批'}
</button>
</div>
</form>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '../layout';
import { authFetch, STATUS_NAMES, ROLE_NAMES, STATUS_COLORS } from '@/lib/auth-client';
const PAGE_SIZE = 10;
function Toast({ message, type, onClose }) {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`toast toast-${type}`}>
{type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'} {message}
</div>
);
}
export default function ContractsPage() {
const user = useUser();
const router = useRouter();
// 筛选状态
const [filters, setFilters] = useState({
status: '',
date_from: '',
date_to: '',
search: '',
});
const [appliedFilters, setAppliedFilters] = useState({
status: '',
date_from: '',
date_to: '',
search: '',
});
// 数据状态
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [exporting, setExporting] = useState(false);
const [toasts, setToasts] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// 构建查询参数
const buildQuery = useCallback((pageNum, filterObj) => {
const params = new URLSearchParams();
params.set('page', pageNum);
params.set('page_size', PAGE_SIZE);
if (filterObj.status) params.set('status', filterObj.status);
if (filterObj.date_from) params.set('date_from', filterObj.date_from);
if (filterObj.date_to) params.set('date_to', filterObj.date_to);
if (filterObj.search) params.set('search', filterObj.search);
return params.toString();
}, []);
// 加载数据
const loadContracts = useCallback(async (pageNum = 1, filterObj = appliedFilters) => {
setLoading(true);
try {
const query = buildQuery(pageNum, filterObj);
const res = await authFetch(`/api/contracts?${query}`);
const data = await res.json();
setContracts(data.contracts || []);
setTotal(data.total || 0);
setPage(pageNum);
} catch (err) {
showToast('加载合同列表失败', 'error');
} finally {
setLoading(false);
}
}, [appliedFilters, buildQuery, showToast]);
useEffect(() => {
loadContracts(1, appliedFilters);
}, []);
// 查询
const handleSearch = () => {
setAppliedFilters({ ...filters });
loadContracts(1, { ...filters });
};
// 重置
const handleReset = () => {
const empty = { status: '', date_from: '', date_to: '', search: '' };
setFilters(empty);
setAppliedFilters(empty);
loadContracts(1, empty);
};
// 导出
const handleExport = async () => {
setExporting(true);
try {
const params = new URLSearchParams();
if (appliedFilters.status) params.set('status', appliedFilters.status);
if (appliedFilters.date_from) params.set('date_from', appliedFilters.date_from);
if (appliedFilters.date_to) params.set('date_to', appliedFilters.date_to);
if (appliedFilters.search) params.set('search', appliedFilters.search);
const res = await authFetch(`/api/contracts/export?${params.toString()}`);
if (!res.ok) throw new Error('导出失败');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `合同列表_${new Date().toLocaleDateString('zh-CN')}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
showToast('导出成功', 'success');
} catch (err) {
showToast('导出失败,请稍后重试', 'error');
} finally {
setExporting(false);
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('zh-CN');
};
const formatMoney = (val) => {
if (val == null) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(val);
};
const totalPages = Math.ceil(total / PAGE_SIZE);
// 生成分页页码
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="animate-fadeIn">
{/* Toast 通知 */}
<div className="toast-container">
{toasts.map(t => (
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
))}
</div>
<div className="page-header">
<div>
<h1 className="page-title">合同流转</h1>
<p className="page-subtitle">管理和查看所有合同信息</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{user?.role === 'employee' && (
<button
className="btn btn-primary"
onClick={() => router.push('/dashboard/contracts/create')}
>
➕ 新建合同
</button>
)}
<button
className="btn btn-outline"
onClick={handleExport}
disabled={exporting}
>
{exporting ? '⏳ 导出中...' : '📥 导出 Excel'}
</button>
</div>
</div>
{/* 筛选栏 */}
<div className="filter-bar">
<select
className="form-select"
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
>
<option value="">全部状态</option>
{Object.entries(STATUS_NAMES).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<input
type="date"
className="form-input"
value={filters.date_from}
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
placeholder="开始日期"
/>
<input
type="date"
className="form-input"
value={filters.date_to}
onChange={(e) => setFilters(prev => ({ ...prev, date_to: e.target.value }))}
placeholder="结束日期"
/>
<input
type="text"
className="form-input"
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
placeholder="搜索项目名称 / 合作单位..."
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
<div className="filter-actions">
<button className="btn btn-primary btn-sm" onClick={handleSearch}>
🔍 查询
</button>
<button className="btn btn-ghost btn-sm" onClick={handleReset}>
🔄 重置
</button>
</div>
</div>
{/* 数据表格 */}
<div className="card">
<div className="card-body" style={{ padding: 0 }}>
{loading ? (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
) : contracts.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📭</div>
<p className="empty-state-text">暂无合同数据</p>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>经办日期</th>
<th>项目名称</th>
<th>合作单位</th>
<th>合同金额</th>
{/* <th>预估利润</th> */}
<th>收/付款</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{contracts.map((c) => (
<tr key={c.id}>
<td>{formatDate(c.created_at)}</td>
<td>
<a
href={`/dashboard/contracts/${c.id}`}
onClick={(e) => { e.preventDefault(); router.push(`/dashboard/contracts/${c.id}`); }}
style={{ fontWeight: 600, color: 'var(--primary)' }}
>
{c.project_name || '-'}
</a>
</td>
<td>{c.partner_name || '-'}</td>
<td style={{ fontWeight: 600, color: 'var(--text-dark)' }}>
{formatMoney(c.contract_amount)}
</td>
{/* <td>{c.payment_type === 'pay' ? '-' : formatMoney(c.estimated_profit)}</td> */}
<td>{c.payment_type === 'receive' ? '收款' : c.payment_type === 'pay' ? '付款' : '-'}</td>
<td>
<span className={`status-badge status-${c.status}`}>
{STATUS_NAMES[c.status] || c.status}
</span>
</td>
<td>
<button
className="btn btn-outline btn-sm"
onClick={() => router.push(`/dashboard/contracts/${c.id}`)}
>
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 分页 */}
{totalPages > 0 && (
<div className="pagination">
<button
className="pagination-btn"
disabled={page <= 1}
onClick={() => loadContracts(page - 1, appliedFilters)}
>
‹ 上一页
</button>
{getPageNumbers().map((p) => (
<button
key={p}
className={`pagination-btn ${p === page ? 'active' : ''}`}
onClick={() => loadContracts(p, appliedFilters)}
>
{p}
</button>
))}
<button
className="pagination-btn"
disabled={page >= totalPages}
onClick={() => loadContracts(page + 1, appliedFilters)}
>
下一页 ›
</button>
<span style={{ marginLeft: '12px', fontSize: '13px', color: 'var(--gray-500)' }}>
共 {total} 条
</span>
</div>
)}
</div>
);
}
+708
View File
@@ -0,0 +1,708 @@
/* ============================
仪表盘布局样式
============================ */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: #f0f2f5;
}
/* 侧边栏 */
.sidebar {
width: 260px;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: width 0.3s ease;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
}
.sidebar.collapsed {
width: 72px;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
}
.sidebar-logo {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.sidebar-logo img {
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
}
.sidebar-brand {
font-size: 16px;
font-weight: 700;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
}
.sidebar.collapsed .sidebar-brand {
display: none;
}
/* 导航菜单 */
.sidebar-nav {
flex: 1;
padding: 12px 8px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
color: #94a3b8;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 2px;
text-decoration: none;
white-space: nowrap;
position: relative;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: #e2e8f0;
}
.nav-item.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.15));
color: #a78bfa;
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #6366f1, #8b5cf6);
}
.nav-item-icon {
font-size: 20px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.nav-item-text {
overflow: hidden;
}
.sidebar.collapsed .nav-item-text {
display: none;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 12px;
}
/* 侧边栏底部 */
.sidebar-footer {
padding: 12px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, 0.05);
color: #64748b;
cursor: pointer;
width: 100%;
font-size: 13px;
transition: all 0.2s ease;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.1);
color: #94a3b8;
}
/* 主内容区 */
.main-content {
flex: 1;
margin-left: 260px;
transition: margin-left 0.3s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.sidebar.collapsed ~ .main-content,
.main-content.collapsed {
margin-left: 72px;
}
/* 顶部导航 */
.top-header {
height: 64px;
background: white;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
position: sticky;
top: 0;
z-index: 50;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: 600;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.user-role {
font-size: 11px;
padding: 2px 8px;
border-radius: 12px;
background: #eef2ff;
color: #6366f1;
font-weight: 500;
}
.logout-btn {
padding: 8px 16px;
font-size: 13px;
color: #64748b;
background: #f1f5f9;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font-sans);
}
.logout-btn:hover {
background: #fee2e2;
color: #ef4444;
}
/* 内容区域 */
.page-content {
flex: 1;
padding: 28px;
}
/* 页面标题 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
}
.page-subtitle {
font-size: 14px;
color: #64748b;
margin-top: 4px;
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.stat-card {
background: white;
border-radius: 16px;
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border: 1px solid #f1f5f9;
transition: all 0.3s ease;
animation: slideUp 0.5s ease forwards;
opacity: 0;
}
.stat-card:nth-child(1) { animation-delay: 0.1s; }
.stat-card:nth-child(2) { animation-delay: 0.2s; }
.stat-card:nth-child(3) { animation-delay: 0.3s; }
.stat-card:nth-child(4) { animation-delay: 0.4s; }
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
flex-shrink: 0;
}
.stat-icon.blue { background: linear-gradient(135deg, #dbeafe, #bfdbfe); }
.stat-icon.orange { background: linear-gradient(135deg, #fef3c7, #fde68a); }
.stat-icon.green { background: linear-gradient(135deg, #d1fae5, #a7f3d0); }
.stat-icon.red { background: linear-gradient(135deg, #fee2e2, #fecaca); }
.stat-icon.purple { background: linear-gradient(135deg, #ede9fe, #ddd6fe); }
.stat-value {
font-size: 28px;
font-weight: 800;
color: #1e293b;
line-height: 1;
}
.stat-label {
font-size: 13px;
color: #64748b;
margin-top: 4px;
}
/* 筛选栏 */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 20px;
padding: 16px 20px;
background: white;
border-radius: 12px;
border: 1px solid #f1f5f9;
}
.filter-bar .form-input,
.filter-bar .form-select {
width: auto;
min-width: 160px;
padding: 8px 12px;
font-size: 13px;
}
.filter-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
/* 审批流程时间线 */
.approval-timeline {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 32px 20px;
margin: 24px 0;
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 0 0 auto;
}
.timeline-dot {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
position: relative;
z-index: 1;
transition: all 0.3s ease;
}
.timeline-dot.completed {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.timeline-dot.current {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
animation: pulse 2s ease-in-out infinite;
}
.timeline-dot.rejected {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.timeline-dot.pending {
background: #e5e7eb;
color: #9ca3af;
}
.timeline-label {
font-size: 13px;
font-weight: 500;
margin-top: 10px;
color: #64748b;
white-space: nowrap;
}
.timeline-info {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
text-align: center;
max-width: 120px;
}
.timeline-line {
width: 80px;
height: 3px;
background: #e5e7eb;
margin: 0 -1px;
margin-bottom: 40px;
}
.timeline-line.completed {
background: linear-gradient(90deg, #10b981, #10b981);
}
.timeline-line.active {
background: linear-gradient(90deg, #10b981, #6366f1);
}
/* Toast */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 文件上传区域 */
.upload-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #fafbfc;
}
.upload-area:hover {
border-color: #6366f1;
background: #f5f3ff;
}
.upload-area.dragging {
border-color: #6366f1;
background: #eef2ff;
}
.upload-icon {
font-size: 40px;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
color: #64748b;
}
.upload-hint {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
.file-list {
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.file-item-name {
display: flex;
align-items: center;
gap: 8px;
color: #374151;
}
.file-item-remove {
color: #ef4444;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
border: none;
background: transparent;
font-size: 16px;
}
.file-item-remove:hover {
background: #fee2e2;
}
/* 合同详情区域 */
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.detail-item {
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
}
.detail-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
font-size: 15px;
color: #1e293b;
font-weight: 500;
}
.detail-item.full {
grid-column: 1 / -1;
}
/* 搜索下拉框 */
.search-select {
position: relative;
}
.search-select-input {
width: 100%;
padding: 10px 14px;
font-size: 14px;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
outline: none;
transition: all 0.2s ease;
font-family: var(--font-sans);
}
.search-select-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px #e0e7ff;
}
.search-select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
z-index: 10;
margin-top: 4px;
}
.search-select-option {
padding: 10px 14px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s ease;
}
.search-select-option:hover {
background: #f0f0ff;
}
.search-select-option.selected {
background: #eef2ff;
color: #4f46e5;
font-weight: 500;
}
.search-select-empty {
padding: 16px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.search-select-empty a {
color: #6366f1;
font-weight: 500;
}
/* 权限矩阵表格 */
.permission-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.permission-table th,
.permission-table td {
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid #f1f5f9;
}
.permission-table th {
background: #f8fafc;
font-weight: 600;
font-size: 13px;
color: #475569;
}
.permission-table th:first-child,
.permission-table td:first-child {
text-align: left;
font-weight: 500;
}
.permission-checkbox {
width: 18px;
height: 18px;
accent-color: #6366f1;
cursor: pointer;
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar {
width: 72px;
}
.sidebar .nav-item-text,
.sidebar .sidebar-brand {
display: none;
}
.main-content {
margin-left: 72px;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
.detail-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-bar .form-input,
.filter-bar .form-select {
width: 100%;
}
.approval-timeline {
flex-wrap: wrap;
}
}
+275
View File
@@ -0,0 +1,275 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useUser } from '../layout';
import { authFetch } from '@/lib/auth-client';
function Toast({ message, type, onClose }) {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`toast toast-${type}`}>
{type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'} {message}
</div>
);
}
export default function DepartmentsPage() {
const user = useUser();
const [departments, setDepartments] = useState([]);
const [loading, setLoading] = useState(true);
// 模态框
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState(null);
const [name, setName] = useState('');
const [nameError, setNameError] = useState('');
const [saving, setSaving] = useState(false);
// 删除确认
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
// Toast
const [toasts, setToasts] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// 加载部门列表
const loadDepartments = useCallback(async () => {
setLoading(true);
try {
const res = await authFetch('/api/departments');
const data = await res.json();
setDepartments(data.departments || data || []);
} catch (err) {
showToast('加载部门列表失败', 'error');
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
loadDepartments();
}, []);
// 权限检查
if (user && user.role !== 'admin') {
return (
<div className="animate-fadeIn">
<div className="empty-state">
<div className="empty-state-icon">🔒</div>
<p className="empty-state-text">仅系统管理员可访问此页面</p>
</div>
</div>
);
}
const openCreateModal = () => {
setName('');
setEditingId(null);
setNameError('');
setShowModal(true);
};
const openEditModal = (dept) => {
setName(dept.name || '');
setEditingId(dept.id);
setNameError('');
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditingId(null);
setName('');
setNameError('');
};
const handleSubmit = async () => {
if (!name.trim()) {
setNameError('部门名称不能为空');
return;
}
setNameError('');
setSaving(true);
try {
const url = editingId ? `/api/departments/${editingId}` : '/api/departments';
const method = editingId ? 'PUT' : 'POST';
const res = await authFetch(url, {
method,
body: JSON.stringify({ name: name.trim() }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || '操作失败');
}
showToast(editingId ? '部门已更新' : '部门已创建', 'success');
closeModal();
loadDepartments();
} catch (err) {
showToast(err.message, 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const res = await authFetch(`/api/departments/${deleteTarget.id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || '删除失败');
}
showToast('部门已删除', 'success');
setDeleteTarget(null);
loadDepartments();
} catch (err) {
showToast(err.message, 'error');
} finally {
setDeleting(false);
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('zh-CN');
};
return (
<div className="animate-fadeIn">
{/* Toast */}
<div className="toast-container">
{toasts.map(t => (
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
))}
</div>
<div className="page-header">
<div>
<h1 className="page-title">部门管理</h1>
<p className="page-subtitle">管理系统中的组织部门</p>
</div>
<button className="btn btn-primary" onClick={openCreateModal}>
➕ 新增部门
</button>
</div>
{/* 数据表格 */}
<div className="card">
<div className="card-body" style={{ padding: 0 }}>
{loading ? (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
) : departments.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏛️</div>
<p className="empty-state-text">暂无部门数据</p>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>部门名称</th>
<th>创建时间</th>
<th style={{ width: '180px' }}>操作</th>
</tr>
</thead>
<tbody>
{departments.map((dept) => (
<tr key={dept.id}>
<td style={{ fontWeight: 600 }}>{dept.name}</td>
<td>{formatDate(dept.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-outline btn-sm" onClick={() => openEditModal(dept)}>
✏️ 编辑
</button>
<button className="btn btn-danger btn-sm" onClick={() => setDeleteTarget(dept)}>
🗑️ 删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 新增/编辑模态框 */}
{showModal && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '420px' }}>
<div className="modal-header">
<h3 className="modal-title">{editingId ? '编辑部门' : '新增部门'}</h3>
<button className="modal-close" onClick={closeModal}>✕</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">
部门名称 <span className="required">*</span>
</label>
<input
type="text"
className="form-input"
value={name}
onChange={(e) => { setName(e.target.value); setNameError(''); }}
placeholder="请输入部门名称"
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus
/>
{nameError && <p className="form-error">{nameError}</p>}
</div>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={closeModal}>取消</button>
<button className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? '⏳ 保存中...' : '💾 保存'}
</button>
</div>
</div>
</div>
)}
{/* 删除确认模态框 */}
{deleteTarget && (
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '420px' }}>
<div className="modal-header">
<h3 className="modal-title">确认删除</h3>
<button className="modal-close" onClick={() => setDeleteTarget(null)}>✕</button>
</div>
<div className="modal-body">
<p style={{ fontSize: '15px', color: 'var(--text-dark-secondary)', lineHeight: 1.6 }}>
确定要删除部门 <strong style={{ color: 'var(--danger)' }}>{deleteTarget.name}</strong> 吗?
此操作不可撤销。
</p>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setDeleteTarget(null)}>取消</button>
<button className="btn btn-danger" onClick={handleDelete} disabled={deleting}>
{deleting ? '⏳ 删除中...' : '🗑️ 确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect, createContext, useContext } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { getToken, clearToken, getUser, authFetch, ROLE_NAMES } from '@/lib/auth-client';
import './dashboard.css';
const UserContext = createContext(null);
export const useUser = () => useContext(UserContext);
const ALL_MENUS = [
{ key: 'dashboard', label: '工作台', icon: '📊', path: '/dashboard' },
{ key: 'contracts', label: '合同流转', icon: '📋', path: '/dashboard/contracts' },
{ key: 'partners', label: '合作单位', icon: '🏢', path: '/dashboard/partners' },
{ key: 'users', label: '用户管理', icon: '👥', path: '/dashboard/users' },
{ key: 'departments', label: '部门管理', icon: '🏛️', path: '/dashboard/departments' },
{ key: 'settings', label: '系统配置', icon: '⚙️', path: '/dashboard/settings' },
{ key: 'permissions', label: '权限配置', icon: '🔐', path: '/dashboard/permissions' },
];
export default function DashboardLayout({ children }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) {
router.replace('/login');
return;
}
authFetch('/api/auth/me')
.then(r => r.json())
.then(data => {
if (data.error) {
clearToken();
router.replace('/login');
return;
}
setUser(data.user);
setPermissions(data.permissions || []);
setLoading(false);
})
.catch(() => {
clearToken();
router.replace('/login');
});
}, [router]);
const handleLogout = () => {
clearToken();
router.replace('/login');
};
const visibleMenus = ALL_MENUS.filter(menu => {
if (!user) return false;
if (user.role === 'admin') return true;
const perm = permissions.find(p => p.menu_key === menu.key);
return perm ? perm.visible === 1 : false;
});
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div className="loading-spinner"></div>
</div>
);
}
return (
<UserContext.Provider value={user}>
<div className="dashboard-layout">
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<div className="sidebar-logo">📄</div>
<span className="sidebar-brand">合同审批系统</span>
</div>
<nav className="sidebar-nav">
{visibleMenus.map(menu => (
<a
key={menu.key}
href={menu.path}
className={`nav-item ${pathname === menu.path || (menu.key !== 'dashboard' && pathname.startsWith(menu.path)) ? 'active' : ''}`}
onClick={(e) => { e.preventDefault(); router.push(menu.path); }}
>
<span className="nav-item-icon">{menu.icon}</span>
<span className="nav-item-text">{menu.label}</span>
</a>
))}
</nav>
<div className="sidebar-footer">
<button className="sidebar-toggle" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? '→' : '← 收起菜单'}
</button>
</div>
</aside>
<div className={`main-content ${collapsed ? 'collapsed' : ''}`}>
<header className="top-header">
<div className="header-left">
<span className="header-title">
{visibleMenus.find(m => pathname === m.path || (m.key !== 'dashboard' && pathname.startsWith(m.path)))?.label || '工作台'}
</span>
</div>
<div className="header-right">
<div className="user-info">
<div className="user-avatar">{user?.real_name?.[0]}</div>
<div>
<div className="user-name">{user?.real_name}</div>
<span className="user-role">{ROLE_NAMES[user?.role] || user?.role}</span>
</div>
</div>
<button className="logout-btn" onClick={handleLogout}>退出登录</button>
</div>
</header>
<main className="page-content">
{children}
</main>
</div>
</div>
</UserContext.Provider>
);
}
+183
View File
@@ -0,0 +1,183 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useUser } from './layout';
import { authFetch, STATUS_NAMES } from '@/lib/auth-client';
// 数字递增动画 Hook
function useCountUp(target, duration = 500) {
const [value, setValue] = useState(0);
const rafRef = useRef(null);
useEffect(() => {
if (target === 0) { setValue(0); return; }
const startTime = performance.now();
const animate = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeOutQuart
const eased = 1 - Math.pow(1 - progress, 4);
setValue(Math.round(eased * target));
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate);
}
};
rafRef.current = requestAnimationFrame(animate);
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [target, duration]);
return value;
}
function StatCard({ icon, label, value, colorClass }) {
const animatedValue = useCountUp(value, 600);
return (
<div className="stat-card">
<div className={`stat-icon ${colorClass}`}>
<span>{icon}</span>
</div>
<div>
<div className="stat-value">{animatedValue}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
export default function DashboardPage() {
const user = useUser();
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ total: 0, pending: 0, approved: 0, rejected: 0 });
useEffect(() => {
loadContracts();
}, []);
const loadContracts = async () => {
try {
const res = await authFetch('/api/contracts?page_size=500');
const data = await res.json();
const list = data.contracts || [];
setContracts(list);
// 计算统计数据
const total = list.length;
const pending = list.filter(c =>
['pending_supervisor', 'pending_finance', 'pending_secretary'].includes(c.status)
).length;
const approved = list.filter(c => c.status === 'approved').length;
const rejected = list.filter(c => c.status === 'rejected').length;
setStats({ total, pending, approved, rejected });
} catch (err) {
console.error('加载合同数据失败:', err);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('zh-CN');
};
const formatMoney = (val) => {
if (val == null) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(val);
};
// 最近5条合同
const recentContracts = contracts.slice(0, 5);
if (loading) {
return (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
);
}
return (
<div className="animate-fadeIn">
{/* 欢迎语 */}
<div className="page-header">
<div>
<h1 className="page-title">
{getGreeting()},{user?.real_name || '用户'} 👋
</h1>
<p className="page-subtitle">欢迎回到合同流转审批系统,以下是您的工作概况</p>
</div>
</div>
{/* 统计卡片 */}
<div className="stats-grid">
<StatCard icon="📋" label="合同总数" value={stats.total} colorClass="blue" />
<StatCard icon="⏳" label="待审批" value={stats.pending} colorClass="orange" />
<StatCard icon="✅" label="已通过" value={stats.approved} colorClass="green" />
<StatCard icon="❌" label="已驳回" value={stats.rejected} colorClass="red" />
</div>
{/* 最近合同 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">📄 最近合同</h2>
<a href="/dashboard/contracts" className="btn btn-outline btn-sm">
查看全部 →
</a>
</div>
<div className="card-body" style={{ padding: 0 }}>
{recentContracts.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📭</div>
<p className="empty-state-text">暂无合同数据</p>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>项目名称</th>
<th>合作单位</th>
<th>合同金额</th>
<th>经办日期</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{recentContracts.map((c) => (
<tr key={c.id}>
<td>
<a href={`/dashboard/contracts/${c.id}`} style={{ fontWeight: 500 }}>
{c.project_name || '-'}
</a>
</td>
<td>{c.partner_name || '-'}</td>
<td style={{ fontWeight: 600, color: 'var(--primary-dark)' }}>
{formatMoney(c.contract_amount)}
</td>
<td>{formatDate(c.created_at)}</td>
<td>
<span className={`status-badge status-${c.status}`}>
{STATUS_NAMES[c.status] || c.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
function getGreeting() {
const hour = new Date().getHours();
if (hour < 6) return '夜深了';
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
}
+376
View File
@@ -0,0 +1,376 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useUser } from '../layout';
import { authFetch } from '@/lib/auth-client';
const EMPTY_FORM = {
name: '',
short_name: '',
english_name: '',
website: '',
address: '',
description: '',
};
function Toast({ message, type, onClose }) {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`toast toast-${type}`}>
{type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'} {message}
</div>
);
}
export default function PartnersPage() {
const user = useUser();
const [partners, setPartners] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// 模态框状态
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ ...EMPTY_FORM });
const [saving, setSaving] = useState(false);
const [formErrors, setFormErrors] = useState({});
// 删除确认
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
// Toast
const [toasts, setToasts] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// 加载数据
const loadPartners = useCallback(async (keyword = '') => {
setLoading(true);
try {
const params = keyword ? `?search=${encodeURIComponent(keyword)}` : '';
const res = await authFetch(`/api/partners${params}`);
const data = await res.json();
setPartners(data.partners || data || []);
} catch (err) {
showToast('加载合作单位失败', 'error');
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
loadPartners();
}, []);
// 搜索
const handleSearch = () => {
loadPartners(search);
};
// 打开新增模态框
const openCreateModal = () => {
setForm({ ...EMPTY_FORM });
setEditingId(null);
setFormErrors({});
setShowModal(true);
};
// 打开编辑模态框
const openEditModal = (partner) => {
setForm({
name: partner.name || '',
short_name: partner.short_name || '',
english_name: partner.english_name || '',
website: partner.website || '',
address: partner.address || '',
description: partner.description || '',
});
setEditingId(partner.id);
setFormErrors({});
setShowModal(true);
};
// 关闭模态框
const closeModal = () => {
setShowModal(false);
setEditingId(null);
setForm({ ...EMPTY_FORM });
setFormErrors({});
};
// 验证表单
const validateForm = () => {
const errors = {};
if (!form.name.trim()) errors.name = '企业名称不能为空';
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSaving(true);
try {
const url = editingId ? `/api/partners/${editingId}` : '/api/partners';
const method = editingId ? 'PUT' : 'POST';
const res = await authFetch(url, {
method,
body: JSON.stringify(form),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || '操作失败');
}
showToast(editingId ? '合作单位已更新' : '合作单位已创建', 'success');
closeModal();
loadPartners(search);
} catch (err) {
showToast(err.message, 'error');
} finally {
setSaving(false);
}
};
// 删除
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const res = await authFetch(`/api/partners/${deleteTarget.id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || '删除失败');
}
showToast('合作单位已删除', 'success');
setDeleteTarget(null);
loadPartners(search);
} catch (err) {
showToast(err.message, 'error');
} finally {
setDeleting(false);
}
};
return (
<div className="animate-fadeIn">
{/* Toast */}
<div className="toast-container">
{toasts.map(t => (
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
))}
</div>
<div className="page-header">
<div>
<h1 className="page-title">合作单位管理</h1>
<p className="page-subtitle">管理合同相关的合作企业信息</p>
</div>
<button className="btn btn-primary" onClick={openCreateModal}>
➕ 新增合作单位
</button>
</div>
{/* 搜索栏 */}
<div className="filter-bar">
<input
type="text"
className="form-input"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索企业名称、简称..."
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
style={{ minWidth: '280px' }}
/>
<div className="filter-actions">
<button className="btn btn-primary btn-sm" onClick={handleSearch}>
🔍 搜索
</button>
<button className="btn btn-ghost btn-sm" onClick={() => { setSearch(''); loadPartners(''); }}>
🔄 重置
</button>
</div>
</div>
{/* 数据表格 */}
<div className="card">
<div className="card-body" style={{ padding: 0 }}>
{loading ? (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
) : partners.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏢</div>
<p className="empty-state-text">暂无合作单位数据</p>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>企业名称</th>
<th>简称</th>
<th>英文名称</th>
<th>官网</th>
<th>地址</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{partners.map((p) => (
<tr key={p.id}>
<td style={{ fontWeight: 600 }}>{p.name}</td>
<td>{p.short_name || '-'}</td>
<td>{p.english_name || '-'}</td>
<td>
{p.website ? (
<a href={p.website} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary)' }}>
{p.website}
</a>
) : '-'}
</td>
<td>{p.address || '-'}</td>
<td>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-outline btn-sm" onClick={() => openEditModal(p)}>
✏️ 编辑
</button>
<button className="btn btn-danger btn-sm" onClick={() => setDeleteTarget(p)}>
🗑️ 删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 新增/编辑模态框 */}
{showModal && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3 className="modal-title">{editingId ? '编辑合作单位' : '新增合作单位'}</h3>
<button className="modal-close" onClick={closeModal}>✕</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">
企业名称 <span className="required">*</span>
</label>
<input
type="text"
className="form-input"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="请输入企业全称"
/>
{formErrors.name && <p className="form-error">{formErrors.name}</p>}
</div>
<div className="form-group">
<label className="form-label">简称</label>
<input
type="text"
className="form-input"
value={form.short_name}
onChange={(e) => setForm(prev => ({ ...prev, short_name: e.target.value }))}
placeholder="请输入企业简称"
/>
</div>
<div className="form-group">
<label className="form-label">英文名称</label>
<input
type="text"
className="form-input"
value={form.english_name}
onChange={(e) => setForm(prev => ({ ...prev, english_name: e.target.value }))}
placeholder="请输入英文名称"
/>
</div>
<div className="form-group">
<label className="form-label">官网</label>
<input
type="url"
className="form-input"
value={form.website}
onChange={(e) => setForm(prev => ({ ...prev, website: e.target.value }))}
placeholder="https://example.com"
/>
</div>
<div className="form-group">
<label className="form-label">地址</label>
<input
type="text"
className="form-input"
value={form.address}
onChange={(e) => setForm(prev => ({ ...prev, address: e.target.value }))}
placeholder="请输入企业地址"
/>
</div>
<div className="form-group">
<label className="form-label">描述</label>
<textarea
className="form-textarea"
value={form.description}
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
placeholder="请输入企业描述(可选)"
rows={3}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={closeModal}>取消</button>
<button className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? '⏳ 保存中...' : '💾 保存'}
</button>
</div>
</div>
</div>
)}
{/* 删除确认模态框 */}
{deleteTarget && (
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '420px' }}>
<div className="modal-header">
<h3 className="modal-title">确认删除</h3>
<button className="modal-close" onClick={() => setDeleteTarget(null)}>✕</button>
</div>
<div className="modal-body">
<p style={{ fontSize: '15px', color: 'var(--text-dark-secondary)', lineHeight: 1.6 }}>
确定要删除合作单位 <strong style={{ color: 'var(--danger)' }}>{deleteTarget.name}</strong> 吗?
此操作不可撤销。
</p>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setDeleteTarget(null)}>取消</button>
<button className="btn btn-danger" onClick={handleDelete} disabled={deleting}>
{deleting ? '⏳ 删除中...' : '🗑️ 确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from 'react';
import { useUser } from '../layout';
import { authFetch, ROLE_NAMES } from '@/lib/auth-client';
const ROLES = ['employee', 'supervisor', 'finance', 'secretary'];
const MENU_ITEMS = [
{ key: 'dashboard', label: '工作台' },
{ key: 'contracts', label: '合同流转' },
{ key: 'partners', label: '合作单位' },
{ key: 'users', label: '用户管理' },
{ key: 'departments', label: '部门管理' },
{ key: 'settings', label: '系统配置' },
{ key: 'permissions', label: '权限配置' },
];
export default function PermissionsPage() {
const user = useUser();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState(null);
// Permission matrix: { role: { menu_key: 0|1 } }
const [matrix, setMatrix] = useState({});
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// Fetch permissions
useEffect(() => {
const fetchPermissions = async () => {
try {
const res = await authFetch('/api/permissions');
const data = await res.json();
const perms = data.permissions || data || [];
// Build matrix
const m = {};
ROLES.forEach(role => {
m[role] = {};
MENU_ITEMS.forEach(menu => {
m[role][menu.key] = 0;
});
});
perms.forEach(p => {
if (m[p.role] !== undefined && MENU_ITEMS.find(mi => mi.key === p.menu_key)) {
m[p.role][p.menu_key] = p.visible ? 1 : 0;
}
});
setMatrix(m);
} catch {
showToast('加载权限配置失败', 'error');
} finally {
setLoading(false);
}
};
fetchPermissions();
}, []);
const togglePermission = (role, menuKey) => {
setMatrix(prev => ({
...prev,
[role]: {
...prev[role],
[menuKey]: prev[role][menuKey] ? 0 : 1,
},
}));
};
const handleSave = async () => {
setSaving(true);
try {
// Build permissions array
const permissions = [];
ROLES.forEach(role => {
MENU_ITEMS.forEach(menu => {
permissions.push({
role,
menu_key: menu.key,
visible: matrix[role]?.[menu.key] || 0,
});
});
});
const res = await authFetch('/api/permissions', {
method: 'PUT',
body: JSON.stringify({ permissions }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '保存失败', 'error');
} else {
showToast('权限配置已保存');
}
} catch {
showToast('保存失败,请重试', 'error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
);
}
return (
<div className="animate-fadeIn">
{/* Toast */}
{toast && (
<div className="toast-container">
<div className={`toast toast-${toast.type}`}>
{toast.type === 'success' ? '✅' : '❌'} {toast.message}
</div>
</div>
)}
<div className="page-header">
<div>
<h1 className="page-title">权限配置</h1>
<p className="page-subtitle">配置各角色的菜单访问权限</p>
</div>
<button
className="btn btn-primary"
disabled={saving}
onClick={handleSave}
>
{saving ? '保存中...' : '💾 保存配置'}
</button>
</div>
<div className="card">
<div className="card-body" style={{ padding: 0 }}>
<div className="table-container">
<table className="permission-table">
<thead>
<tr>
<th style={{ minWidth: 120 }}>角色</th>
{MENU_ITEMS.map(menu => (
<th key={menu.key}>{menu.label}</th>
))}
</tr>
</thead>
<tbody>
{ROLES.map(role => (
<tr key={role}>
<td>
<span style={{ fontWeight: 600, color: 'var(--text-dark)' }}>
{ROLE_NAMES[role] || role}
</span>
</td>
{MENU_ITEMS.map(menu => (
<td key={menu.key}>
<input
type="checkbox"
className="permission-checkbox"
checked={matrix[role]?.[menu.key] === 1}
onChange={() => togglePermission(role, menu.key)}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div style={{
marginTop: 16,
padding: '12px 16px',
background: 'var(--info-light)',
borderRadius: 'var(--radius-md)',
fontSize: 13,
color: 'var(--info)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
ℹ️ 管理员(admin)默认拥有所有菜单权限,无需单独配置。修改后请点击"保存配置"按钮使更改生效。
</div>
</div>
);
}
+379
View File
@@ -0,0 +1,379 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useUser } from '../layout';
import { authFetch } from '@/lib/auth-client';
export default function SettingsPage() {
const user = useUser();
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState(null);
// Company info
const [companyName, setCompanyName] = useState('');
const [companyLogo, setCompanyLogo] = useState('');
const [logoPreview, setLogoPreview] = useState('');
const [savingCompany, setSavingCompany] = useState(false);
const logoInputRef = useRef(null);
// Profit config
const [profitPercentage, setProfitPercentage] = useState('');
const [savingProfit, setSavingProfit] = useState(false);
// Historical Import
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const importInputRef = useRef(null);
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// Fetch config
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await authFetch('/api/config');
const data = await res.json();
const config = data.config || data;
setCompanyName(config.company_name || '');
setCompanyLogo(config.company_logo || '');
setLogoPreview(config.company_logo || '');
setProfitPercentage(config.profit_percentage ?? '');
} catch {
showToast('加载配置失败', 'error');
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
// Upload logo
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Preview
const reader = new FileReader();
reader.onload = (ev) => setLogoPreview(ev.target.result);
reader.readAsDataURL(file);
// Upload
const formData = new FormData();
formData.append('file', file);
try {
const res = await authFetch('/api/config/logo', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Logo上传失败', 'error');
return;
}
const newLogoPath = data.path || data.logo || data.file_path || '';
setCompanyLogo(newLogoPath);
showToast('Logo上传成功');
} catch {
showToast('Logo上传失败', 'error');
}
};
// Save company info
const handleSaveCompany = async () => {
setSavingCompany(true);
try {
const res = await authFetch('/api/config', {
method: 'PUT',
body: JSON.stringify({
company_name: companyName,
company_logo: companyLogo,
}),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '保存失败', 'error');
} else {
showToast('企业信息已保存');
}
} catch {
showToast('保存失败,请重试', 'error');
} finally {
setSavingCompany(false);
}
};
// Save profit config
const handleSaveProfit = async () => {
if (profitPercentage === '' || Number(profitPercentage) < 0) {
showToast('请输入有效的利润比例', 'error');
return;
}
setSavingProfit(true);
try {
const res = await authFetch('/api/config', {
method: 'PUT',
body: JSON.stringify({
profit_percentage: Number(profitPercentage),
}),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '保存失败', 'error');
} else {
showToast('利润配置已保存');
}
} catch {
showToast('保存失败,请重试', 'error');
} finally {
setSavingProfit(false);
}
};
// Select Excel for import
const handleImportFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
setImportResult(null);
};
// Start import Excel
const handleStartImport = async () => {
if (!importFile) {
showToast('请先选择 Excel 文件', 'error');
return;
}
setImporting(true);
setImportResult(null);
const formData = new FormData();
formData.append('file', importFile);
try {
const res = await authFetch('/api/contracts/import', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '导入失败,请检查文件格式', 'error');
} else {
showToast(data.message || '导入成功');
setImportResult({ type: 'success', text: data.message });
setImportFile(null);
if (importInputRef.current) importInputRef.current.value = '';
}
} catch (err) {
showToast('导入失败,请稍后重试', 'error');
} finally {
setImporting(false);
}
};
if (loading) {
return (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
);
}
return (
<div className="animate-fadeIn">
{/* Toast */}
{toast && (
<div className="toast-container">
<div className={`toast toast-${toast.type}`}>
{toast.type === 'success' ? '✅' : '❌'} {toast.message}
</div>
</div>
)}
<div className="page-header">
<div>
<h1 className="page-title">系统配置</h1>
<p className="page-subtitle">管理企业信息和系统参数</p>
</div>
</div>
{/* Card 1: 企业信息 */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-header">
<h3 className="card-title">🏢 企业信息</h3>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label">企业名称</label>
<input
type="text"
className="form-input"
placeholder="请输入企业名称"
value={companyName}
onChange={e => setCompanyName(e.target.value)}
style={{ maxWidth: 480 }}
/>
</div>
<div className="form-group">
<label className="form-label">企业Logo</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
{/* Logo Preview */}
<div style={{
width: 80,
height: 80,
borderRadius: 'var(--radius-lg)',
border: '2px dashed var(--gray-200)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: '#ffffff',
flexShrink: 0,
}}>
{logoPreview ? (
<img
src={logoPreview}
alt="企业Logo"
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: '#ffffff', padding: 4 }}
/>
) : (
<span style={{ fontSize: 32, opacity: 0.3 }}>🏢</span>
)}
</div>
<div>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => logoInputRef.current?.click()}
>
📁 上传Logo
</button>
<input
ref={logoInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleLogoUpload}
/>
<div className="form-hint" style={{ marginTop: 6 }}>
支持 JPG、PNG 格式,建议尺寸 200×200
</div>
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
<button
className="btn btn-primary"
disabled={savingCompany}
onClick={handleSaveCompany}
>
{savingCompany ? '保存中...' : '💾 保存企业信息'}
</button>
</div>
</div>
</div>
{/* Card 2: 利润配置 */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-header">
<h3 className="card-title">📊 利润配置</h3>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label">预估利润默认比例</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: 240 }}>
<input
type="number"
className="form-input"
placeholder="请输入比例"
min="0"
max="100"
step="0.1"
value={profitPercentage}
onChange={e => setProfitPercentage(e.target.value)}
/>
<span style={{ fontSize: 18, fontWeight: 600, color: 'var(--gray-500)', flexShrink: 0 }}>%</span>
</div>
<div className="form-hint">
新建合同时,预估利润将自动按此比例计算(合同金额 × 比例)
</div>
</div>
<div style={{ marginTop: 20 }}>
<button
className="btn btn-primary"
disabled={savingProfit}
onClick={handleSaveProfit}
>
{savingProfit ? '保存中...' : '💾 保存利润配置'}
</button>
</div>
</div>
</div>
{/* Card 3: 导入历史合同 (仅管理员可见) */}
{user?.role === 'admin' && (
<div className="card">
<div className="card-header">
<h3 className="card-title">📥 导入历史合同</h3>
</div>
<div className="card-body">
<p className="form-hint" style={{ marginBottom: 16, fontSize: 14, color: 'var(--gray-600)' }}>
此功能用于导入以往已通过终审的合同 Excel 记录。系统会自动识别表格内容、自动生成审核记录,并将合同归档至已通过状态。
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline"
onClick={() => importInputRef.current?.click()}
disabled={importing}
>
📁 选择 Excel 模板/文件
</button>
<input
ref={importInputRef}
type="file"
accept=".xlsx, .xls"
style={{ display: 'none' }}
onChange={handleImportFileSelect}
/>
{importFile && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--gray-700)' }}>
📄 {importFile.name} ({(importFile.size / 1024).toFixed(1)} KB)
</span>
<button
className="btn btn-primary"
disabled={importing}
onClick={handleStartImport}
>
{importing ? '⚡ 正在读取并导入...' : '⚡ 开始导入并归档'}
</button>
</div>
)}
</div>
{importResult && (
<div style={{
marginTop: 16,
padding: 12,
borderRadius: 'var(--radius-md)',
background: 'var(--green-50)',
border: '1px solid var(--green-200)',
color: 'var(--green-800)',
fontSize: 14
}}>
🎉 {importResult.text}
</div>
)}
</div>
</div>
)}
</div>
);
}
+375
View File
@@ -0,0 +1,375 @@
'use client';
import { useState, useEffect } from 'react';
import { useUser } from '../layout';
import { authFetch, ROLE_NAMES } from '@/lib/auth-client';
export default function UsersPage() {
const user = useUser();
const [users, setUsers] = useState([]);
const [departments, setDepartments] = useState([]);
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState(null);
// Modal
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [form, setForm] = useState({
username: '',
password: '',
real_name: '',
role: 'employee',
department_id: '',
});
const [submitting, setSubmitting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchUsers = async () => {
try {
const res = await authFetch('/api/users');
const data = await res.json();
setUsers(data.users || data || []);
} catch {
showToast('加载用户列表失败', 'error');
} finally {
setLoading(false);
}
};
const fetchDepartments = async () => {
try {
const res = await authFetch('/api/departments');
const data = await res.json();
setDepartments(data.departments || data || []);
} catch {
// ignore
}
};
useEffect(() => {
fetchUsers();
fetchDepartments();
}, []);
const openCreateModal = () => {
setEditingUser(null);
setForm({ username: '', password: '', real_name: '', role: 'employee', department_id: '' });
setModalOpen(true);
};
const openEditModal = (u) => {
setEditingUser(u);
setForm({
username: u.username,
password: '',
real_name: u.real_name,
role: u.role,
department_id: u.department_id || '',
});
setModalOpen(true);
};
const handleSubmit = async () => {
if (!form.username.trim()) { showToast('请输入用户名', 'error'); return; }
if (!editingUser && !form.password) { showToast('请输入密码', 'error'); return; }
if (!form.real_name.trim()) { showToast('请输入姓名', 'error'); return; }
if (!form.role) { showToast('请选择角色', 'error'); return; }
setSubmitting(true);
try {
const body = { ...form };
if (editingUser && !body.password) {
delete body.password;
}
if (body.department_id === '') {
body.department_id = null;
}
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
const method = editingUser ? 'PUT' : 'POST';
const res = await authFetch(url, {
method,
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '操作失败', 'error');
} else {
showToast(editingUser ? '用户已更新' : '用户已创建');
setModalOpen(false);
fetchUsers();
}
} catch {
showToast('操作失败,请重试', 'error');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (userId) => {
try {
const res = await authFetch(`/api/users/${userId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) {
showToast(data.error || '删除失败', 'error');
} else {
showToast('用户已删除');
fetchUsers();
}
} catch {
showToast('删除失败', 'error');
}
setDeleteConfirm(null);
};
const toggleStatus = async (targetUser) => {
const newStatus = targetUser.status === 1 ? 0 : 1;
try {
const res = await authFetch(`/api/users/${targetUser.id}`, {
method: 'PUT',
body: JSON.stringify({ status: newStatus }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || '操作失败', 'error');
} else {
showToast(newStatus === 1 ? '用户已启用' : '用户已停用');
fetchUsers();
}
} catch {
showToast('操作失败,请重试', 'error');
}
};
const getDepartmentName = (deptId) => {
const dept = departments.find(d => d.id === deptId);
return dept ? dept.name : '-';
};
if (loading) {
return (
<div className="loading-overlay">
<div className="loading-spinner"></div>
</div>
);
}
return (
<div className="animate-fadeIn">
{/* Toast */}
{toast && (
<div className="toast-container">
<div className={`toast toast-${toast.type}`}>
{toast.type === 'success' ? '✅' : '❌'} {toast.message}
</div>
</div>
)}
{/* Header */}
<div className="page-header">
<div>
<h1 className="page-title">用户管理</h1>
<p className="page-subtitle">管理系统用户和角色权限</p>
</div>
<button className="btn btn-primary" onClick={openCreateModal}>
+ 新增用户
</button>
</div>
{/* Table */}
<div className="card">
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>用户名</th>
<th>姓名</th>
<th>角色</th>
<th>部门</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan="6">
<div className="empty-state">
<div className="empty-state-icon">👥</div>
<div className="empty-state-text">暂无用户数据</div>
</div>
</td>
</tr>
) : (
users.map(u => (
<tr key={u.id}>
<td style={{ fontWeight: 500 }}>{u.username}</td>
<td>{u.real_name}</td>
<td>
<span className="status-badge" style={{
background: u.role === 'admin' ? '#fef3c7' : '#eef2ff',
color: u.role === 'admin' ? '#d97706' : '#6366f1',
}}>
{ROLE_NAMES[u.role] || u.role}
</span>
</td>
<td>{getDepartmentName(u.department_id)}</td>
<td>
<span
className="status-badge"
style={{
background: u.status === 1 || u.status === 'active' ? 'var(--success-light)' : 'var(--gray-100)',
color: u.status === 1 || u.status === 'active' ? 'var(--success)' : 'var(--gray-400)',
}}
>
{u.status === 1 || u.status === 'active' ? '启用' : '停用'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm btn-outline" onClick={() => openEditModal(u)}>
编辑
</button>
{u.id !== user?.id && (
<>
<button
className="btn btn-sm btn-outline"
style={{
color: u.status === 1 ? '#ef4444' : '#10b981',
borderColor: u.status === 1 ? '#fecaca' : '#a7f3d0',
}}
onClick={() => toggleStatus(u)}
>
{u.status === 1 ? '🚫 停用' : '✅ 启用'}
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => setDeleteConfirm(u)}
>
删除
</button>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Create/Edit Modal */}
{modalOpen && (
<div className="modal-overlay" onClick={() => setModalOpen(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3 className="modal-title">{editingUser ? '编辑用户' : '新增用户'}</h3>
<button className="modal-close" onClick={() => setModalOpen(false)}>✕</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">用户名 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="请输入用户名"
value={form.username}
onChange={e => setForm(prev => ({ ...prev, username: e.target.value }))}
disabled={!!editingUser}
/>
</div>
<div className="form-group">
<label className="form-label">
密码 {!editingUser && <span className="required">*</span>}
</label>
<input
type="password"
className="form-input"
placeholder={editingUser ? '留空则不修改' : '请输入密码'}
value={form.password}
onChange={e => setForm(prev => ({ ...prev, password: e.target.value }))}
/>
{editingUser && <div className="form-hint">留空则不修改密码</div>}
</div>
<div className="form-group">
<label className="form-label">姓名 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="请输入姓名"
value={form.real_name}
onChange={e => setForm(prev => ({ ...prev, real_name: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">角色 <span className="required">*</span></label>
<select
className="form-select"
value={form.role}
onChange={e => setForm(prev => ({ ...prev, role: e.target.value }))}
>
<option value="employee">员工</option>
<option value="supervisor">部门主管</option>
<option value="finance">财务部</option>
<option value="secretary">秘书长</option>
<option value="admin">系统管理员</option>
</select>
</div>
<div className="form-group">
<label className="form-label">部门</label>
<select
className="form-select"
value={form.department_id}
onChange={e => setForm(prev => ({ ...prev, department_id: e.target.value }))}
>
<option value="">请选择部门</option>
{departments.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>取消</button>
<button className="btn btn-primary" disabled={submitting} onClick={handleSubmit}>
{submitting ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">
<h3 className="modal-title">确认删除</h3>
<button className="modal-close" onClick={() => setDeleteConfirm(null)}>✕</button>
</div>
<div className="modal-body">
<p style={{ fontSize: 15, color: 'var(--text-dark-secondary)' }}>
确定要删除用户 <strong>{deleteConfirm.real_name}</strong>({deleteConfirm.username})吗?
此操作无法撤销。
</p>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setDeleteConfirm(null)}>取消</button>
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirm.id)}>
确认删除
</button>
</div>
</div>
</div>
)}
</div>
);
}
+721
View File
@@ -0,0 +1,721 @@
/* ============================
合同流转审批系统 - 全局设计系统
============================ */
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
/* CSS 变量 - 设计 Token */
:root {
/* 主色调 */
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--primary-50: #eef2ff;
--primary-100: #e0e7ff;
--primary-200: #c7d2fe;
--primary-500: #6366f1;
--primary-600: #4f46e5;
--primary-700: #4338ca;
/* 辅助色 */
--accent: #06b6d4;
--accent-light: #22d3ee;
/* 语义色 */
--success: #10b981;
--success-light: #d1fae5;
--warning: #f59e0b;
--warning-light: #fef3c7;
--danger: #ef4444;
--danger-light: #fee2e2;
--info: #3b82f6;
--info-light: #dbeafe;
/* 中性色 */
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
/* 背景色 */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-card: rgba(30, 41, 59, 0.8);
--bg-glass: rgba(255, 255, 255, 0.05);
--bg-page: #f0f2f5;
/* 文字色 */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-dark: #1e293b;
--text-dark-secondary: #475569;
/* 边框 */
--border-color: rgba(255, 255, 255, 0.1);
--border-light: #e2e8f0;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
/* 圆角 */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* 间距 */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
/* 过渡 */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 350ms ease;
/* 侧边栏 */
--sidebar-width: 260px;
--sidebar-collapsed-width: 72px;
--header-height: 64px;
/* 字体 */
--font-sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ============== 全局重置 ============== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
line-height: 1.6;
color: var(--text-dark);
background: var(--bg-page);
min-height: 100vh;
}
a {
color: var(--primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--primary-dark);
}
img {
max-width: 100%;
height: auto;
}
/* ============== 通用组件样式 ============== */
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
font-family: var(--font-sans);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
white-space: nowrap;
line-height: 1.5;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.2), transparent);
opacity: 0;
transition: opacity var(--transition-fast);
}
.btn:hover::after {
opacity: 1;
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.4);
}
.btn-primary:hover {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
transform: translateY(-1px);
}
.btn-success {
background: linear-gradient(135deg, var(--success), #059669);
color: white;
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.4);
}
.btn-success:hover {
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.5);
transform: translateY(-1px);
}
.btn-danger {
background: linear-gradient(135deg, var(--danger), #dc2626);
color: white;
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.4);
}
.btn-danger:hover {
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.5);
transform: translateY(-1px);
}
.btn-outline {
background: transparent;
color: var(--primary);
border: 1.5px solid var(--primary-200);
}
.btn-outline:hover {
background: var(--primary-50);
border-color: var(--primary);
}
.btn-ghost {
background: transparent;
color: var(--text-dark-secondary);
}
.btn-ghost:hover {
background: var(--gray-100);
}
.btn-sm {
padding: 6px 14px;
font-size: 13px;
}
.btn-lg {
padding: 14px 28px;
font-size: 16px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* 输入框 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-dark);
margin-bottom: 6px;
}
.form-label .required {
color: var(--danger);
margin-left: 2px;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 10px 14px;
font-size: 14px;
font-family: var(--font-sans);
color: var(--text-dark);
background: white;
border: 1.5px solid var(--gray-200);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
outline: none;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-100);
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--gray-400);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-hint {
font-size: 12px;
color: var(--text-dark-secondary);
margin-top: 4px;
}
.form-error {
font-size: 12px;
color: var(--danger);
margin-top: 4px;
}
/* 卡片 */
.card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-100);
transition: box-shadow var(--transition-base);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--gray-100);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-dark);
}
.card-body {
padding: 24px;
}
/* 表格 */
.table-container {
overflow-x: auto;
border-radius: var(--radius-lg);
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
.data-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.data-table th {
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: var(--text-dark-secondary);
background: var(--gray-50);
border-bottom: 2px solid var(--gray-200);
white-space: nowrap;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--gray-100);
color: var(--text-dark);
vertical-align: middle;
}
.data-table tbody tr {
transition: background var(--transition-fast);
}
.data-table tbody tr:hover {
background: var(--primary-50);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* 状态标签 */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-full);
white-space: nowrap;
}
.status-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-draft { background: #f3f4f6; color: #6b7280; }
.status-pending_supervisor { background: #dbeafe; color: #2563eb; }
.status-pending_finance { background: #fef3c7; color: #d97706; }
.status-pending_secretary { background: #ede9fe; color: #7c3aed; }
.status-approved { background: #d1fae5; color: #059669; }
.status-rejected { background: #fee2e2; color: #dc2626; }
/* 分页 */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 20px 0;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 8px;
font-size: 14px;
color: var(--text-dark-secondary);
background: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.pagination-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.pagination-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 模态框 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
animation: fadeIn 0.2s ease forwards;
}
.modal-content {
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
transform: scale(0.95) translateY(10px);
animation: modalIn 0.3s ease forwards;
}
.modal-header {
padding: 24px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 20px;
font-weight: 700;
color: var(--text-dark);
}
.modal-close {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--gray-100);
border-radius: var(--radius-full);
cursor: pointer;
color: var(--gray-500);
font-size: 18px;
transition: all var(--transition-fast);
}
.modal-close:hover {
background: var(--gray-200);
color: var(--gray-700);
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Toast 通知 */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 14px 20px;
border-radius: var(--radius-md);
color: white;
font-size: 14px;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideInRight 0.3s ease forwards;
min-width: 280px;
max-width: 420px;
display: flex;
align-items: center;
gap: 10px;
}
.toast-success { background: linear-gradient(135deg, #10b981, #059669); }
.toast-error { background: linear-gradient(135deg, #ef4444, #dc2626); }
.toast-warning { background: linear-gradient(135deg, #f59e0b, #d97706); }
.toast-info { background: linear-gradient(135deg, #3b82f6, #2563eb); }
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--gray-400);
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-text {
font-size: 16px;
color: var(--gray-500);
}
/* 加载动画 */
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
}
/* ============== 关键帧动画 ============== */
@keyframes fadeIn {
to { opacity: 1; }
}
@keyframes modalIn {
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 渐入动画工具类 */
.animate-fadeIn { animation: fadeIn 0.5s ease forwards; }
.animate-slideUp { animation: slideUp 0.5s ease forwards; }
.animate-slideInLeft { animation: slideInLeft 0.3s ease forwards; }
/* ============== 滚动条美化 ============== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* ============== 工具类 ============== */
.text-center { text-align: center; }
.text-right { text-align: right; }
.font-bold { font-weight: 700; }
.mt-sm { margin-top: var(--space-sm); }
.mt-md { margin-top: var(--space-md); }
.mt-lg { margin-top: var(--space-lg); }
.mb-md { margin-bottom: var(--space-md); }
.mb-lg { margin-bottom: var(--space-lg); }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
/* ============== 响应式 ============== */
@media (max-width: 768px) {
.hide-mobile { display: none !important; }
.modal-content {
width: 95%;
max-height: 90vh;
}
.data-table th,
.data-table td {
padding: 10px 12px;
font-size: 13px;
}
}
+14
View File
@@ -0,0 +1,14 @@
import './globals.css';
export const metadata = {
title: '合同流转审批系统',
description: '企业合同流转审批管理平台',
};
export default function RootLayout({ children }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+229
View File
@@ -0,0 +1,229 @@
/* ============================
登录页样式
============================ */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 25%, #312e81 50%, #1e1b4b 75%, #0f172a 100%);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
position: relative;
overflow: hidden;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* 浮动几何形状 */
.login-bg-shapes {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.login-bg-shapes .shape {
position: absolute;
border-radius: 50%;
opacity: 0.08;
animation: floatShape 20s ease-in-out infinite;
}
.login-bg-shapes .shape:nth-child(1) {
width: 300px; height: 300px;
background: linear-gradient(135deg, #6366f1, #a78bfa);
top: -50px; left: -50px;
animation-duration: 18s;
}
.login-bg-shapes .shape:nth-child(2) {
width: 200px; height: 200px;
background: linear-gradient(135deg, #06b6d4, #22d3ee);
top: 60%; right: -30px;
animation-duration: 22s;
animation-delay: -5s;
}
.login-bg-shapes .shape:nth-child(3) {
width: 150px; height: 150px;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
bottom: 10%; left: 15%;
animation-duration: 16s;
animation-delay: -8s;
}
.login-bg-shapes .shape:nth-child(4) {
width: 250px; height: 250px;
background: linear-gradient(135deg, #ec4899, #f472b6);
top: 20%; right: 20%;
animation-duration: 24s;
animation-delay: -3s;
}
@keyframes floatShape {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(30px, -40px) rotate(90deg); }
50% { transform: translate(-20px, 30px) rotate(180deg); }
75% { transform: translate(40px, 20px) rotate(270deg); }
}
/* 登录卡片 */
.login-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
padding: 48px 40px;
width: 420px;
max-width: 90vw;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
position: relative;
z-index: 1;
animation: cardIn 0.6s ease forwards;
transform: translateY(20px);
opacity: 0;
}
@keyframes cardIn {
to { transform: translateY(0); opacity: 1; }
}
.login-logo-area {
text-align: center;
margin-bottom: 32px;
}
.login-logo-area img {
width: 72px;
height: 72px;
border-radius: 16px;
object-fit: contain;
margin-bottom: 12px;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.3);
background-color: #ffffff;
padding: 6px;
}
.login-logo-area .default-icon {
width: 72px;
height: 72px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 12px;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.3);
}
.login-logo-area h1 {
font-size: 24px;
font-weight: 700;
color: #f8fafc;
letter-spacing: 1px;
}
.login-logo-area p {
font-size: 14px;
color: #94a3b8;
margin-top: 4px;
}
/* 表单输入 */
.login-form-group {
margin-bottom: 20px;
position: relative;
}
.login-form-group .input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
z-index: 1;
opacity: 0.6;
}
.login-input {
width: 100%;
padding: 14px 14px 14px 44px;
font-size: 15px;
font-family: var(--font-sans);
color: #f8fafc;
background: rgba(255, 255, 255, 0.06);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
outline: none;
transition: all 0.3s ease;
}
.login-input::placeholder {
color: #64748b;
}
.login-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), 0 0 20px rgba(99, 102, 241, 0.1);
background: rgba(255, 255, 255, 0.1);
}
/* 登录按钮 */
.login-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
font-family: var(--font-sans);
color: white;
background: linear-gradient(135deg, #6366f1, #4f46e5);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
margin-top: 8px;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.5);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 错误信息 */
.login-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
margin-bottom: 16px;
text-align: center;
animation: shake 0.4s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { setToken, setUser } from '@/lib/auth-client';
import './login.css';
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [config, setConfig] = useState({ company_name: '合同流转审批系统', company_logo: '' });
useEffect(() => {
fetch('/api/config')
.then(r => r.json())
.then(data => {
if (data && !data.error) setConfig(data);
})
.catch(() => {});
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!username || !password) {
setError('请输入用户名和密码');
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || '登录失败');
return;
}
setToken(data.token);
setUser(data.user);
router.push('/dashboard');
} catch (err) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-bg-shapes">
<div className="shape"></div>
<div className="shape"></div>
<div className="shape"></div>
<div className="shape"></div>
</div>
<div className="login-card">
<div className="login-logo-area">
{config.company_logo ? (
<img src={config.company_logo} alt="Logo" />
) : (
<div className="default-icon">📄</div>
)}
<h1>{config.company_name}</h1>
<p>企业合同流转审批管理平台</p>
</div>
<form onSubmit={handleSubmit}>
{error && <div className="login-error">{error}</div>}
<div className="login-form-group">
<span className="input-icon">👤</span>
<input
type="text"
className="login-input"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</div>
<div className="login-form-group">
<span className="input-icon">🔒</span>
<input
type="password"
className="login-input"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<button type="submit" className="login-btn" disabled={loading}>
{loading ? '登录中...' : '登 录'}
</button>
</form>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getToken } from '@/lib/auth-client';
export default function Home() {
const router = useRouter();
useEffect(() => {
const token = getToken();
if (token) {
router.replace('/dashboard');
} else {
router.replace('/login');
}
}, [router]);
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div className="loading-spinner"></div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// 客户端认证工具函数
// 获取存储的 Token
export function getToken() {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
}
// 设置 Token
export function setToken(token) {
localStorage.setItem('token', token);
}
// 清除 Token
export function clearToken() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
// 获取存储的用户信息
export function getUser() {
if (typeof window === 'undefined') return null;
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
// 设置用户信息
export function setUser(user) {
localStorage.setItem('user', JSON.stringify(user));
}
// 带认证的 fetch 请求
export async function authFetch(url, options = {}) {
const token = getToken();
const headers = {
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 如果不是 FormData,设置 Content-Type
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, {
...options,
headers,
});
// 401 未授权,跳转登录
if (response.status === 401) {
clearToken();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('未登录或登录已过期');
}
return response;
}
// 角色中文名称映射
export const ROLE_NAMES = {
admin: '系统管理员',
employee: '员工',
supervisor: '部门主管',
finance: '财务部',
secretary: '秘书长',
};
// 状态中文名称映射
export const STATUS_NAMES = {
draft: '草稿',
pending_supervisor: '待初审',
pending_finance: '待财务审核',
pending_secretary: '待终审',
approved: '已通过',
rejected: '已驳回',
};
// 状态颜色映射
export const STATUS_COLORS = {
draft: '#6b7280',
pending_supervisor: '#3b82f6',
pending_finance: '#f59e0b',
pending_secretary: '#8b5cf6',
approved: '#10b981',
rejected: '#ef4444',
};
+60
View File
@@ -0,0 +1,60 @@
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'contract-approval-system-secret-key-2024';
const TOKEN_EXPIRY = '24h';
// 生成 JWT Token
export function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
real_name: user.real_name,
role: user.role,
department_id: user.department_id,
},
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY }
);
}
// 验证 JWT Token
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
// 从请求头获取用户信息
export function getUserFromRequest(request) {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
return verifyToken(token);
}
// 检查是否有权限
export function checkRole(user, allowedRoles) {
if (!user) return false;
return allowedRoles.includes(user.role);
}
// 返回未授权响应
export function unauthorizedResponse() {
return Response.json(
{ error: '未登录或登录已过期' },
{ status: 401 }
);
}
// 返回无权限响应
export function forbiddenResponse() {
return Response.json(
{ error: '无权限访问' },
{ status: 403 }
);
}
+232
View File
@@ -0,0 +1,232 @@
import initSqlJs from 'sql.js';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcryptjs';
const DB_PATH = path.join(process.cwd(), 'data', 'database.sqlite');
let db = null;
let SQL = null;
let initPromise = null;
let transactionDepth = 0;
function saveDb() {
if (transactionDepth > 0) {
return;
}
if (db) {
const data = db.export();
const buffer = Buffer.from(data);
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(DB_PATH, buffer);
}
}
async function ensureInit() {
if (db) return;
if (initPromise) {
await initPromise;
return;
}
initPromise = (async () => {
SQL = await initSqlJs();
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
try {
if (fs.existsSync(DB_PATH)) {
const fileBuffer = fs.readFileSync(DB_PATH);
db = new SQL.Database(fileBuffer);
} else {
db = new SQL.Database();
}
} catch {
db = new SQL.Database();
}
initializeDatabase(db);
saveDb();
})();
await initPromise;
}
// 异步获取 db 代理 - 所有 API 路由使用这个
export default async function getDb() {
await ensureInit();
return {
prepare(sql) {
return {
get(...params) {
const stmt = db.prepare(sql);
if (params.length > 0) stmt.bind(params);
if (stmt.step()) {
const result = stmt.getAsObject();
stmt.free();
return result;
}
stmt.free();
return undefined;
},
all(...params) {
const results = [];
const stmt = db.prepare(sql);
if (params.length > 0) stmt.bind(params);
while (stmt.step()) {
results.push(stmt.getAsObject());
}
stmt.free();
return results;
},
run(...params) {
db.run(sql, params);
const lastId = db.exec('SELECT last_insert_rowid() as id');
const changes = db.getRowsModified();
saveDb();
return {
lastInsertRowid: lastId[0]?.values[0]?.[0] || 0,
changes
};
}
};
},
exec(sql) {
db.exec(sql);
saveDb();
},
transaction(fn) {
return (...args) => {
transactionDepth++;
db.run('BEGIN TRANSACTION');
try {
const result = fn(...args);
transactionDepth--;
db.run('COMMIT');
saveDb();
return result;
} catch (error) {
transactionDepth--;
console.error("TRANSACTION ERROR:", error);
try {
db.run('ROLLBACK');
} catch (rollbackError) {
console.error("ROLLBACK ERROR:", rollbackError);
}
throw error;
}
};
}
};
}
function initializeDatabase(database) {
database.run(`CREATE TABLE IF NOT EXISTS departments (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
database.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, real_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'employee', department_id INTEGER,
status INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (department_id) REFERENCES departments(id))`);
database.run(`CREATE TABLE IF NOT EXISTS partners (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
short_name TEXT, english_name TEXT, website TEXT, logo TEXT,
address TEXT, description TEXT, is_deleted INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
database.run(`CREATE TABLE IF NOT EXISTS contracts (
id INTEGER PRIMARY KEY AUTOINCREMENT, project_name TEXT NOT NULL,
partner_id INTEGER, partner_name TEXT NOT NULL, contract_content TEXT,
contract_years INTEGER, contract_amount REAL DEFAULT 0,
estimated_profit REAL DEFAULT 0, payment_type TEXT DEFAULT 'receive',
sign_date TEXT, status TEXT DEFAULT 'pending_supervisor',
creator_id INTEGER NOT NULL, creator_name TEXT NOT NULL,
department_id INTEGER, department_name TEXT, project_leader TEXT,
supervisor_opinion TEXT, supervisor_approved INTEGER,
supervisor_id INTEGER, supervisor_name TEXT, supervisor_time DATETIME,
finance_opinion TEXT, finance_approved INTEGER,
finance_id INTEGER, finance_name TEXT, finance_time DATETIME,
secretary_opinion TEXT, secretary_approved INTEGER,
secretary_id INTEGER, secretary_name TEXT, secretary_time DATETIME,
reject_reason TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (creator_id) REFERENCES users(id),
FOREIGN KEY (partner_id) REFERENCES partners(id),
FOREIGN KEY (department_id) REFERENCES departments(id))`);
database.run(`CREATE TABLE IF NOT EXISTS contract_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT, contract_id INTEGER NOT NULL,
file_name TEXT NOT NULL, file_path TEXT NOT NULL,
file_size INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (contract_id) REFERENCES contracts(id))`);
database.run(`CREATE TABLE IF NOT EXISTS system_config (
id INTEGER PRIMARY KEY AUTOINCREMENT, config_key TEXT NOT NULL UNIQUE,
config_value TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
database.run(`CREATE TABLE IF NOT EXISTS role_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL,
menu_key TEXT NOT NULL, visible INTEGER DEFAULT 1,
UNIQUE(role, menu_key))`);
initDefaultData(database);
}
function initDefaultData(database) {
const dc = database.exec('SELECT COUNT(*) FROM departments');
if ((dc[0]?.values[0]?.[0] || 0) === 0) {
for (const n of ['技术部', '市场部', '销售部', '财务部', '行政部']) {
database.run('INSERT INTO departments (name) VALUES (?)', [n]);
}
}
const uc = database.exec('SELECT COUNT(*) FROM users');
if ((uc[0]?.values[0]?.[0] || 0) === 0) {
const s = bcrypt.genSaltSync(10);
const users = [
['admin', bcrypt.hashSync('admin123', s), '系统管理员', 'admin', null],
['zhangsan', bcrypt.hashSync('123456', s), '张三', 'employee', 1],
['lisi', bcrypt.hashSync('123456', s), '李四', 'supervisor', 1],
['wangwu_emp', bcrypt.hashSync('123456', s), '王五', 'employee', 2],
['maqi', bcrypt.hashSync('123456', s), '马七', 'supervisor', 2],
['wangwu', bcrypt.hashSync('123456', s), '赵六', 'finance', 4],
['qianba', bcrypt.hashSync('123456', s), '钱八', 'secretary', null],
];
for (const u of users) {
database.run('INSERT INTO users (username, password, real_name, role, department_id) VALUES (?,?,?,?,?)', u);
}
}
const cc = database.exec('SELECT COUNT(*) FROM system_config');
if ((cc[0]?.values[0]?.[0] || 0) === 0) {
database.run("INSERT INTO system_config (config_key, config_value) VALUES ('company_name','合同流转审批系统')");
database.run("INSERT INTO system_config (config_key, config_value) VALUES ('company_logo','')");
database.run("INSERT INTO system_config (config_key, config_value) VALUES ('profit_percentage','10')");
}
const pc = database.exec('SELECT COUNT(*) FROM role_permissions');
if ((pc[0]?.values[0]?.[0] || 0) === 0) {
const menus = ['dashboard','contracts','partners','users','departments','settings','permissions'];
const roles = { admin: menus, employee: ['dashboard','contracts','partners'],
supervisor: ['dashboard','contracts','partners'], finance: ['dashboard','contracts','partners'],
secretary: ['dashboard','contracts','partners'] };
for (const [role, vm] of Object.entries(roles)) {
for (const m of menus) {
database.run('INSERT INTO role_permissions (role, menu_key, visible) VALUES (?,?,?)',
[role, m, vm.includes(m) ? 1 : 0]);
}
}
}
const pac = database.exec('SELECT COUNT(*) FROM partners');
if ((pac[0]?.values[0]?.[0] || 0) === 0) {
const defaultPartners = [
['健帆生物科技集团股份有限公司', '健帆生物', 'Jafron Biomedical Co., Ltd.', 'http://www.jafron.com', '', '珠海市唐家湾科技创新海岸科技一路9号', '血液净化产品的研发、生产与销售'],
['天士力医药集团股份有限公司', '天士力', 'Tasly Pharmaceutical Group Co., Ltd.', 'http://www.tasly.com', '', '天津市北辰科技园区科技东路1号', '现代中药、化学药、生物药的研发、生产与销售'],
['雅培贸易(上海)有限公司', '雅培', 'Abbott Laboratories Trading (Shanghai) Co., Ltd.', 'http://www.abbott.com.cn', '', '中国(上海)自由贸易试验区外高桥保税区富特北路399号', '医疗健康、诊断、营养品及成熟药品'],
['成都嗖嗖优速网络科技有限公司', '嗖嗖优速', 'Chengdu Sousou Yousu Network Technology Co., Ltd.', 'http://www.sousouyousu.com', '', '四川省成都市高新区吉泰五路88号', '互联网信息服务与网络技术研发'],
['成都世纪城会展集团有限公司', '成都世纪城会展', 'Chengdu Century City Exhibition Group Co., Ltd.', 'http://www.centurycityexhibition.com', '', '四川省成都市高新区天府大道中段177号', '会展场馆运营、会议展览策划与执行'],
['正大天晴药业集团股份有限公司', '正大天晴', 'Chia Tai-Tianqing Pharmaceutical Group Co., Ltd.', 'http://www.cttq.com', '', '江苏省连云港市新浦区巨龙北路16号', '抗肿瘤、肝病及呼吸系统药物研发与生产'],
['国家卫生健康委能力建设和继续教育中心', '卫健委能建中心', 'NCCHD, NHC', 'http://www.ncchd.org.cn', '', '北京市海淀区学院路38号', '卫生健康行业人才队伍能力建设与继续医学教育'],
['南京正大天晴制药有限公司', '南京正大天晴', 'Nanjing Chia Tai-Tianqing Pharmaceutical Co., Ltd.', 'http://www.njcttq.com', '', '江苏省南京市经济技术开发区恒广路36号', '心脑血管、外科及消化系统药物制造'],
['西安盛博商务会展服务有限公司', '西安盛博会展', 'Xi\'an Shengbo Business Exhibition Service Co., Ltd.', 'http://www.xashengbo.com', '', '陕西省西安市雁塔区朱雀大街南段2号', '会议展览服务与公关策划业务']
];
for (const p of defaultPartners) {
database.run('INSERT INTO partners (name, short_name, english_name, website, logo, address, description) VALUES (?,?,?,?,?,?,?)', p);
}
}
}