From 987a4678f72459f0974ca8599af6bf71fd1eb43d Mon Sep 17 00:00:00 2001 From: selfrelease Date: Wed, 29 Jul 2026 11:49:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B9=B3=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=AB=AF=20=E2=80=94=20SUPER=5FADMIN=20=E8=A7=92?= =?UTF-8?q?=E8=89=B2=20+=20=E4=BC=81=E4=B8=9A=E7=A7=9F=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20+=20=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=20+=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=80=BB=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 3 +- backend/prisma/schema.prisma | 5 +- backend/prisma/seed-multi-org.ts | 431 ++++++++++++++++++ backend/prisma/seed-platform-admin.ts | 46 ++ backend/src/app.ts | 2 + backend/src/lib/jwt.ts | 18 +- backend/src/middleware/auth.ts | 14 +- backend/src/routes/auth.routes.ts | 12 +- backend/src/routes/platform.routes.ts | 337 ++++++++++++++ backend/src/services/auth.service.ts | 37 ++ frontend/src/App.tsx | 47 ++ .../src/components/layout/PlatformSidebar.tsx | 150 ++++++ .../src/pages/platform/PlatformDashboard.tsx | 122 +++++ frontend/src/pages/platform/PlatformLogin.tsx | 118 +++++ frontend/src/pages/platform/PlatformOrgs.tsx | 246 ++++++++++ frontend/src/pages/platform/PlatformUsers.tsx | 166 +++++++ frontend/src/store/authStore.ts | 4 +- 17 files changed, 1744 insertions(+), 14 deletions(-) create mode 100644 backend/prisma/seed-multi-org.ts create mode 100644 backend/prisma/seed-platform-admin.ts create mode 100644 backend/src/routes/platform.routes.ts create mode 100644 frontend/src/components/layout/PlatformSidebar.tsx create mode 100644 frontend/src/pages/platform/PlatformDashboard.tsx create mode 100644 frontend/src/pages/platform/PlatformLogin.tsx create mode 100644 frontend/src/pages/platform/PlatformOrgs.tsx create mode 100644 frontend/src/pages/platform/PlatformUsers.tsx diff --git a/backend/package.json b/backend/package.json index f7b8c8a..9b59cc4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,8 @@ "start": "node dist/index.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:seed": "tsx prisma/seed.ts" + "prisma:seed": "tsx prisma/seed.ts", + "prisma:seed-multi": "tsx prisma/seed-multi-org.ts" }, "dependencies": { "@prisma/client": "^5.18.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0fe3d40..80877b3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,6 +16,7 @@ enum Plan { } enum Role { + SUPER_ADMIN ADMIN HR VIEWER @@ -175,8 +176,8 @@ model Organization { model User { id String @id @default(cuid()) - orgId String - org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId String? // 平台管理员不绑定企业(null) + org Organization? @relation(fields: [orgId], references: [id], onDelete: Cascade) phone String @unique email String? passwordHash String diff --git a/backend/prisma/seed-multi-org.ts b/backend/prisma/seed-multi-org.ts new file mode 100644 index 0000000..730ad02 --- /dev/null +++ b/backend/prisma/seed-multi-org.ts @@ -0,0 +1,431 @@ +/** + * 多企业测试种子脚本 + * 创建多个不同企业(含管理员、员工、合同、社保配置等),用于多租户测试 + * + * 用法: npx tsx prisma/seed-multi-org.ts + * + * 注意:此脚本不会清空已有数据,而是在现有基础上追加新企业。 + * 如需重置,请先运行 prisma/seed.ts(会清空所有数据)。 + */ +import { PrismaClient, Prisma } from '@prisma/client' +import bcrypt from 'bcryptjs' +import { encrypt } from '../src/lib/crypto' + +const prisma = new PrismaClient() + +// ========== 社保计算工具 ========== +function calcSocial(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100 + const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100 + return { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100 } +} +function calcHousing(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const housingEmp = actualBase * config.housingEmp / 100 + const housingOrg = actualBase * config.housingOrg / 100 + return { housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100 } +} +function calcTax(taxableIncome: number): number { + if (taxableIncome <= 0) return 0 + let tax = 0 + if (taxableIncome <= 36000) tax = taxableIncome * 0.03 + else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520 + else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920 + else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920 + else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920 + else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920 + else tax = taxableIncome * 0.45 - 181920 + return Math.max(0, Math.round(tax * 100) / 100) +} + +// ========== 企业配置定义 ========== +interface OrgConfig { + name: string + city: string + plan: 'FREE' | 'PRO' | 'ENTERPRISE' + maxEmployees: number + adminPhone: string + adminName: string + contactName: string + contactPhone: string + payrollFrequency: number + retirementReminderEnabled: boolean + socialConfig: { + city: string + pensionOrg: number; pensionEmp: number + medicalOrg: number; medicalEmp: number + unemploymentOrg: number; unemploymentEmp: number + injuryOrg: number; maternityOrg: number + baseMin: number; baseMax: number + medicalBaseMin?: number; medicalBaseMax?: number + extraInsurances?: any[] + } + housingConfig: { + city: string + housingOrg: number; housingEmp: number + baseMin: number; baseMax: number + } + employees: { + name: string; gender: string; dept: string; phone: string + idCard: string; salary: number; hireDate: string + socialBase: number; housingBase: number; specialDeduction: number + contractType: 'FIXED' | 'UNFIXED'; years: number + probation: number; probationSalary: number + bank: string; account: string + emergency: string; emergencyPhone: string; address: string + pregnant?: boolean + }[] +} + +// 3 个测试企业配置 +const ORGS: OrgConfig[] = [ + { + name: '北京华夏制造有限公司', + city: '北京', + plan: 'PRO', + maxEmployees: 100, + adminPhone: '13800000010', + adminName: '王建国', + contactName: '王建国', + contactPhone: '13800000010', + payrollFrequency: 1, + retirementReminderEnabled: true, + socialConfig: { + city: '北京', + pensionOrg: 16, pensionEmp: 8, + medicalOrg: 9.8, medicalEmp: 2, + unemploymentOrg: 0.5, unemploymentEmp: 0.5, + injuryOrg: 0.2, maternityOrg: 0.8, + baseMin: 6326, baseMax: 33891, + }, + housingConfig: { + city: '北京', + housingOrg: 12, housingEmp: 12, + baseMin: 6326, baseMax: 33891, + }, + employees: [ + { name: '刘大壮', gender: '男', dept: '生产车间', phone: '13900001001', idCard: '110101198501011001', salary: 8500, hireDate: '2022-03-01', socialBase: 8500, housingBase: 8500, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 6800, bank: '工商银行', account: '6222020100010001001', emergency: '刘父', emergencyPhone: '13800001001', address: '北京市朝阳区工业路1号' }, + { name: '陈秀英', gender: '女', dept: '生产车间', phone: '13900001002', idCard: '110102198803021002', salary: 7500, hireDate: '2023-06-15', socialBase: 7500, housingBase: 7500, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6000, bank: '建设银行', account: '6227000100020002002', emergency: '陈母', emergencyPhone: '13800001002', address: '北京市海淀区中关村2号' }, + { name: '赵铁柱', gender: '男', dept: '生产车间', phone: '13900001003', idCard: '110103199012030003', salary: 9000, hireDate: '2021-01-10', socialBase: 9000, housingBase: 9000, specialDeduction: 3000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228480100030003003', emergency: '赵妻', emergencyPhone: '13800001003', address: '北京市丰台区南三环3号' }, + { name: '孙美丽', gender: '女', dept: '行政部', phone: '13900001004', idCard: '110104199506040004', salary: 11000, hireDate: '2022-09-01', socialBase: 11000, housingBase: 11000, specialDeduction: 1500, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 8800, bank: '中国银行', account: '6217000100040004004', emergency: '孙父', emergencyPhone: '13800001004', address: '北京市西城区西直门4号', pregnant: true }, + { name: '周大伟', gender: '男', dept: '生产车间', phone: '13900001005', idCard: '110105199807050005', salary: 7000, hireDate: '2024-07-01', socialBase: 7000, housingBase: 7000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 5600, bank: '交通银行', account: '6222600100050005005', emergency: '周母', emergencyPhone: '13800001005', address: '北京市石景山区八角5号' }, + { name: '吴小红', gender: '女', dept: '质检部', phone: '13900001006', idCard: '110106199311060006', salary: 9500, hireDate: '2023-11-15', socialBase: 9500, housingBase: 9500, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7600, bank: '招商银行', account: '6225880100060006006', emergency: '吴父', emergencyPhone: '13800001006', address: '北京市通州区新华大街6号' }, + ], + }, + { + name: '深圳鹏程物流有限公司', + city: '深圳', + plan: 'ENTERPRISE', + maxEmployees: 200, + adminPhone: '13800000020', + adminName: '李明华', + contactName: '李明华', + contactPhone: '13800000020', + payrollFrequency: 2, + retirementReminderEnabled: true, + socialConfig: { + city: '深圳', + pensionOrg: 15, pensionEmp: 8, + medicalOrg: 5.2, medicalEmp: 2, + unemploymentOrg: 0.7, unemploymentEmp: 0.3, + injuryOrg: 0.3, maternityOrg: 0.5, + baseMin: 3523, baseMax: 27927, + medicalBaseMin: 6060, medicalBaseMax: 29892, + }, + housingConfig: { + city: '深圳', + housingOrg: 5, housingEmp: 5, + baseMin: 3523, baseMax: 27927, + }, + employees: [ + { name: '黄志强', gender: '男', dept: '运输部', phone: '13900002001', idCard: '440301198501072001', salary: 12000, hireDate: '2021-04-01', socialBase: 12000, housingBase: 12000, specialDeduction: 2000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '工商银行', account: '6222020200010001001', emergency: '黄妻', emergencyPhone: '13800002001', address: '深圳市南山区科技园1号' }, + { name: '林晓燕', gender: '女', dept: '财务部', phone: '13900002002', idCard: '440302199003082002', salary: 14000, hireDate: '2022-06-15', socialBase: 14000, housingBase: 14000, specialDeduction: 1500, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 11200, bank: '建设银行', account: '6227000200020002002', emergency: '林母', emergencyPhone: '13800002002', address: '深圳市福田区华强北2号' }, + { name: '张海涛', gender: '男', dept: '运输部', phone: '13900002003', idCard: '440303198812092003', salary: 10000, hireDate: '2023-01-10', socialBase: 10000, housingBase: 10000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 8000, bank: '招商银行', account: '6225880200030003003', emergency: '张父', emergencyPhone: '13800002003', address: '深圳市罗湖区东门3号' }, + { name: '何丽萍', gender: '女', dept: '客服部', phone: '13900002004', idCard: '440304199506102004', salary: 8000, hireDate: '2024-01-01', socialBase: 8000, housingBase: 8000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '农业银行', account: '6228480200040004004', emergency: '何父', emergencyPhone: '13800002004', address: '深圳市宝安区西乡4号' }, + { name: '马俊杰', gender: '男', dept: '运输部', phone: '13900002005', idCard: '440305199907112005', salary: 9500, hireDate: '2024-07-01', socialBase: 9500, housingBase: 9500, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7600, bank: '中国银行', account: '6217000200050005005', emergency: '马母', emergencyPhone: '13800002005', address: '深圳市龙岗区中心城5号' }, + { name: '高雅婷', gender: '女', dept: '行政部', phone: '13900002006', idCard: '440306199311122006', salary: 11000, hireDate: '2023-05-15', socialBase: 11000, housingBase: 11000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 8800, bank: '交通银行', account: '6222600200060006006', emergency: '高父', emergencyPhone: '13800002006', address: '深圳市龙华区民治6号' }, + { name: '罗建军', gender: '男', dept: '运输部', phone: '13900002007', idCard: '440307198504132007', salary: 13000, hireDate: '2020-08-01', socialBase: 27927, housingBase: 27927, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '民生银行', account: '6226160200070007007', emergency: '罗妻', emergencyPhone: '13800002007', address: '深圳市南山区前海7号' }, + ], + }, + { + name: '杭州云创科技有限公司', + city: '杭州', + plan: 'FREE', + maxEmployees: 20, + adminPhone: '13800000030', + adminName: '赵雪梅', + contactName: '赵雪梅', + contactPhone: '13800000030', + payrollFrequency: 1, + retirementReminderEnabled: false, + socialConfig: { + city: '杭州', + pensionOrg: 14, pensionEmp: 8, + medicalOrg: 9.9, medicalEmp: 2, + unemploymentOrg: 0.5, unemploymentEmp: 0.5, + injuryOrg: 0.2, maternityOrg: 1.2, + baseMin: 4812, baseMax: 24060, + }, + housingConfig: { + city: '杭州', + housingOrg: 12, housingEmp: 12, + baseMin: 4812, baseMax: 24060, + }, + employees: [ + { name: '钱学文', gender: '男', dept: '研发部', phone: '13900003001', idCard: '330101199001013001', salary: 20000, hireDate: '2022-03-01', socialBase: 20000, housingBase: 20000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 16000, bank: '工商银行', account: '6222020300010001001', emergency: '钱父', emergencyPhone: '13800003001', address: '杭州市西湖区文三路1号' }, + { name: '孙雨晴', gender: '女', dept: '研发部', phone: '13900003002', idCard: '330102199503023002', salary: 18000, hireDate: '2023-06-15', socialBase: 18000, housingBase: 18000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 14400, bank: '建设银行', account: '6227000300020002002', emergency: '孙母', emergencyPhone: '13800003002', address: '杭州市滨江区江南大道2号' }, + { name: '周明杰', gender: '男', dept: '产品部', phone: '13900003003', idCard: '330103198812033003', salary: 16000, hireDate: '2021-01-10', socialBase: 16000, housingBase: 16000, specialDeduction: 3000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '招商银行', account: '6225880300030003003', emergency: '周妻', emergencyPhone: '13800003003', address: '杭州市余杭区未来科技城3号' }, + { name: '吴雅芳', gender: '女', dept: '市场部', phone: '13900003004', idCard: '330104199806043004', salary: 12000, hireDate: '2024-07-01', socialBase: 12000, housingBase: 12000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 9600, bank: '农业银行', account: '6228480300040004004', emergency: '吴父', emergencyPhone: '13800003004', address: '杭州市拱墅区莫干山路4号' }, + ], + }, +] + +// ========== 薪酬模版默认项 ========== +const DEFAULT_PAYSILP_ITEMS = [ + { name: '基本工资', code: 'baseSalary', type: 'INPUT' as const, formula: null, order: 1, isDefault: true, isEditable: true }, + { name: '加班费', code: 'overtimePay', type: 'CALCULATED' as const, formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false }, + { name: '津贴补贴', code: 'allowance', type: 'INPUT' as const, formula: null, order: 3, isDefault: true, isEditable: true }, + { name: '奖金', code: 'bonus', type: 'INPUT' as const, formula: null, order: 4, isDefault: true, isEditable: true }, + { name: '扣款', code: 'deduction', type: 'INPUT' as const, formula: null, order: 5, isDefault: true, isEditable: true }, + { name: '应发合计', code: 'totalPay', type: 'CALCULATED' as const, formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false }, + { name: '个人社保', code: 'socialEmp', type: 'CALCULATED' as const, formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false }, + { name: '个人公积金', code: 'housingEmp', type: 'CALCULATED' as const, formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false }, + { name: '个人所得税', code: 'tax', type: 'CALCULATED' as const, formula: 'TAX', order: 9, isDefault: true, isEditable: false }, + { name: '实发工资', code: 'netPay', type: 'CALCULATED' as const, formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false }, +] + +// ========== 创建单个企业 ========== +async function createOrg(orgConfig: OrgConfig) { + // 检查是否已存在(按管理员手机号判断) + const existingUser = await prisma.user.findUnique({ where: { phone: orgConfig.adminPhone } }) + if (existingUser) { + console.log(` ⏭ 企业「${orgConfig.name}」已存在(管理员 ${orgConfig.adminPhone}),跳过`) + return + } + + // 1. 创建企业 + const org = await prisma.organization.create({ + data: { + name: orgConfig.name, + plan: orgConfig.plan, + maxEmployees: orgConfig.maxEmployees, + city: orgConfig.city, + contactName: orgConfig.contactName, + contactPhone: orgConfig.contactPhone, + payrollFrequency: orgConfig.payrollFrequency, + retirementReminderEnabled: orgConfig.retirementReminderEnabled, + }, + }) + console.log(` ✅ 企业已创建: ${org.name} (${org.id})`) + + // 2. 创建管理员 + const passwordHash = await bcrypt.hash('12345678', 10) + const admin = await prisma.user.create({ + data: { + orgId: org.id, + phone: orgConfig.adminPhone, + name: orgConfig.adminName, + passwordHash, + role: 'ADMIN', + }, + }) + console.log(` ✅ 管理员已创建: ${admin.phone} (${admin.name})`) + + // 3. 创建社保配置 + await prisma.socialInsuranceConfig.create({ + data: { + orgId: org.id, + city: orgConfig.socialConfig.city, + pensionOrg: orgConfig.socialConfig.pensionOrg, + pensionEmp: orgConfig.socialConfig.pensionEmp, + medicalOrg: orgConfig.socialConfig.medicalOrg, + medicalEmp: orgConfig.socialConfig.medicalEmp, + unemploymentOrg: orgConfig.socialConfig.unemploymentOrg, + unemploymentEmp: orgConfig.socialConfig.unemploymentEmp, + injuryOrg: orgConfig.socialConfig.injuryOrg, + maternityOrg: orgConfig.socialConfig.maternityOrg, + baseMin: orgConfig.socialConfig.baseMin, + baseMax: orgConfig.socialConfig.baseMax, + medicalBaseMin: orgConfig.socialConfig.medicalBaseMin || 0, + medicalBaseMax: orgConfig.socialConfig.medicalBaseMax || 0, + extraInsurances: orgConfig.socialConfig.extraInsurances || Prisma.JsonNull, + effectiveFrom: '2025-07', + createdBy: admin.id, + }, + }) + console.log(` ✅ 社保配置已创建 (${orgConfig.socialConfig.city})`) + + // 4. 创建公积金配置 + await prisma.housingFundConfig.create({ + data: { + orgId: org.id, + city: orgConfig.housingConfig.city, + housingOrg: orgConfig.housingConfig.housingOrg, + housingEmp: orgConfig.housingConfig.housingEmp, + baseMin: orgConfig.housingConfig.baseMin, + baseMax: orgConfig.housingConfig.baseMax, + effectiveFrom: '2025-07', + createdBy: admin.id, + }, + }) + console.log(` ✅ 公积金配置已创建 (${orgConfig.housingConfig.city})`) + + // 5. 创建通知设置 + await prisma.notificationSetting.create({ + data: { orgId: org.id }, + }) + + // 6. 创建加班配置 + await prisma.overtimeConfig.create({ + data: { orgId: org.id }, + }) + + // 7. 创建薪酬模版 + for (const item of DEFAULT_PAYSILP_ITEMS) { + await prisma.payslipItem.create({ + data: { orgId: org.id, ...item }, + }) + } + console.log(` ✅ 薪酬模版已创建 (10项)`) + + // 8. 创建员工 + 合同 + for (let i = 0; i < orgConfig.employees.length; i++) { + const e = orgConfig.employees[i] + const emp = await prisma.employee.create({ + data: { + orgId: org.id, + name: e.name, + department: e.dept, + hireDate: new Date(e.hireDate), + monthlySalary: encrypt(String(e.salary)), + phone: e.phone, + idCardNumber: encrypt(e.idCard), + gender: e.gender, + socialInsBase: e.socialBase, + housingFundBase: e.housingBase, + specialDeduction: e.specialDeduction, + bankName: e.bank, + bankAccount: encrypt(e.account), + emergencyContact: e.emergency, + emergencyPhone: e.emergencyPhone, + address: e.address, + isPregnant: e.pregnant || false, + createdBy: admin.id, + }, + }) + + // 创建合同 + const startDate = new Date(e.hireDate) + const endDate = e.contractType === 'FIXED' + ? new Date(startDate.getFullYear() + e.years, startDate.getMonth(), startDate.getDate() - 1) + : null + + await prisma.laborContract.create({ + data: { + orgId: org.id, + employeeId: emp.id, + signDate: new Date(e.hireDate), + startDate, + endDate, + contractType: e.contractType as any, + signMethod: 'PAPER', + contractYears: e.years, + probationMonths: e.probation, + probationSalary: e.probationSalary, + createdBy: admin.id, + }, + }) + console.log(` ✅ 员工 ${i + 1}/${orgConfig.employees.length}: ${e.name} - ${e.dept} - ¥${e.salary}/月`) + } + + // 9. 生成 1-6 月历史工资条 + console.log(` 📄 生成历史工资条...`) + const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } }) + const housingConfig = await prisma.housingFundConfig.findFirst({ where: { orgId: org.id, isCurrent: true } }) + const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } }) + + for (const emp of allEmployees) { + const hireYear = emp.hireDate.getFullYear() + if (hireYear > 2026) continue + const hireMonth = hireYear === 2026 ? emp.hireDate.getMonth() + 1 : 1 + + let ytdIncome = 0, ytdSocialEmp = 0, ytdHousingEmp = 0, ytdTaxDeducted = 0 + + for (let m = 1; m <= 6; m++) { + if (m < hireMonth) continue + const monthStr = `2026-${String(m).padStart(2, '0')}` + const baseSalary = emp.socialInsBase || 0 + const social = calcSocial(emp.socialInsBase || baseSalary, socialConfig) + const housing = calcHousing(emp.housingFundBase || baseSalary, housingConfig || socialConfig) + const totalPay = baseSalary + const specialDeduction = emp.specialDeduction * m + + ytdIncome += totalPay + ytdSocialEmp += social.socialEmp + ytdHousingEmp += housing.housingEmp + + const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * m - ytdSocialEmp - ytdHousingEmp - specialDeduction) + const ytdTax = calcTax(ytdTaxableIncome) + const monthTax = Math.max(0, Math.round((ytdTax - ytdTaxDeducted) * 100) / 100) + ytdTaxDeducted += monthTax + + const netPay = Math.round((totalPay - social.socialEmp - housing.housingEmp - monthTax) * 100) / 100 + + await prisma.payslip.create({ + data: { + org: { connect: { id: org.id } }, + employee: { connect: { id: emp.id } }, + month: monthStr, + baseSalary, + overtimePay: 0, + allowance: 0, + deduction: 0, + bonus: 0, + totalPay, + socialEmp: social.socialEmp, + housingEmp: housing.housingEmp, + tax: monthTax, + netPay, + ytdIncome, + ytdTaxDeducted, + ytdSocialEmp, + ytdHousingEmp, + status: 'PUBLISHED', + publishedAt: new Date(`${monthStr}-10T10:00:00Z`), + confirmedAt: new Date(`${monthStr}-12T10:00:00Z`), + }, + }) + } + } + console.log(` ✅ 历史工资条已生成`) +} + +// ========== 主函数 ========== +async function main() { + console.log('\n🚀 开始创建多企业测试数据...\n') + + for (let i = 0; i < ORGS.length; i++) { + console.log(`\n--- [${i + 1}/${ORGS.length}] 创建企业: ${ORGS[i].name} ---`) + await createOrg(ORGS[i]) + } + + console.log('\n===== 多企业测试数据创建完成 =====\n') + console.log('可用登录账号:') + console.log('┌────────────────────────────┬──────────────┬──────────┐') + console.log('│ 企业名称 │ 管理员手机号 │ 密码 │') + console.log('├────────────────────────────┼──────────────┼──────────┤') + for (const org of ORGS) { + console.log(`│ ${org.name.padEnd(26)}│ ${org.adminPhone} │ 12345678 │`) + } + console.log('└────────────────────────────┴──────────────┴──────────┘') +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/backend/prisma/seed-platform-admin.ts b/backend/prisma/seed-platform-admin.ts new file mode 100644 index 0000000..da3b599 --- /dev/null +++ b/backend/prisma/seed-platform-admin.ts @@ -0,0 +1,46 @@ +/** + * 平台管理员种子脚本 + * 创建 SUPER_ADMIN 角色的平台管理员账号 + * + * 用法: npx tsx prisma/seed-platform-admin.ts + */ +import { PrismaClient } from '@prisma/client' +import bcrypt from 'bcryptjs' + +const prisma = new PrismaClient() + +async function main() { + const phone = '13800000000' + const existing = await prisma.user.findUnique({ where: { phone } }) + + if (existing) { + console.log(`平台管理员已存在: ${phone},跳过创建`) + return + } + + const passwordHash = await bcrypt.hash('admin123456', 10) + const admin = await prisma.user.create({ + data: { + name: '平台超级管理员', + phone, + passwordHash, + role: 'SUPER_ADMIN', + orgId: null, + }, + }) + + console.log('===== 平台管理员创建成功 =====') + console.log(`姓名: ${admin.name}`) + console.log(`手机号: ${admin.phone}`) + console.log(`密码: admin123456`) + console.log(`角色: SUPER_ADMIN`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/backend/src/app.ts b/backend/src/app.ts index b9476a5..27f8c0e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -57,6 +57,7 @@ import attendanceRoutes from './routes/attendance.routes' import templateRoutes from './routes/template.routes' import auditRoutes from './routes/audit.routes' import calendarRoutes from './routes/calendar.routes' +import platformRoutes from './routes/platform.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -78,6 +79,7 @@ app.use('/api/v1/attendance', attendanceRoutes) app.use('/api/v1/templates', templateRoutes) app.use('/api/v1/audit', auditRoutes) app.use('/api/v1/calendar', calendarRoutes) +app.use('/api/v1/platform', platformRoutes) app.use(errorHandler) diff --git a/backend/src/lib/jwt.ts b/backend/src/lib/jwt.ts index 2ca41c7..742fd11 100644 --- a/backend/src/lib/jwt.ts +++ b/backend/src/lib/jwt.ts @@ -3,25 +3,31 @@ import jwt from 'jsonwebtoken' const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret' const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret' -export function signAccessToken(payload: { id: string; orgId: string; role: string }): string { +export interface JwtPayload { + id: string + orgId: string | null + role: string +} + +export function signAccessToken(payload: JwtPayload): string { return jwt.sign(payload, JWT_SECRET, { expiresIn: '2h' }) } -export function signRefreshToken(payload: { id: string; orgId: string; role: string }): string { +export function signRefreshToken(payload: JwtPayload): string { return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' }) } -export function verifyAccessToken(token: string): { id: string; orgId: string; role: string } | null { +export function verifyAccessToken(token: string): JwtPayload | null { try { - return jwt.verify(token, JWT_SECRET) as { id: string; orgId: string; role: string } + return jwt.verify(token, JWT_SECRET) as JwtPayload } catch { return null } } -export function verifyRefreshToken(token: string): { id: string; orgId: string; role: string } | null { +export function verifyRefreshToken(token: string): JwtPayload | null { try { - return jwt.verify(token, JWT_REFRESH_SECRET) as { id: string; orgId: string; role: string } + return jwt.verify(token, JWT_REFRESH_SECRET) as JwtPayload } catch { return null } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 90cb6c1..2f7d62b 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -2,8 +2,8 @@ import { Request, Response, NextFunction } from 'express' import { verifyAccessToken } from '../lib/jwt' export interface AuthRequest extends Request { - user?: { id: string; orgId: string; role: string } - orgId?: string + user?: { id: string; orgId: string | null; role: string } + orgId?: string | null } export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) { @@ -26,3 +26,13 @@ export function orgFilterMiddleware(req: AuthRequest, _res: Response, next: Next } next() } + +/** + * 平台管理员鉴权中间件,仅允许 SUPER_ADMIN 角色通过 + */ +export function platformAdminMiddleware(req: AuthRequest, res: Response, next: NextFunction) { + if (!req.user || req.user.role !== 'SUPER_ADMIN') { + return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '需要平台管理员权限' } }) + } + next() +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index d564e6b..435aa31 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express' import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema' -import { register, login, refresh, resetPassword } from '../services/auth.service' +import { register, login, refresh, resetPassword, platformLogin } from '../services/auth.service' import { authLimiter, loginLimiter } from '../middleware/rateLimit' import prisma from '../lib/prisma' import bcrypt from 'bcryptjs' @@ -28,6 +28,16 @@ router.post('/login', loginLimiter, async (req, res, next) => { } }) +router.post('/platform-login', loginLimiter, async (req, res, next) => { + try { + const data = loginSchema.parse(req.body) + const result = await platformLogin(data.phone, data.password) + res.json({ success: true, data: result }) + } catch (err) { + next(err) + } +}) + router.post('/refresh', async (req, res, next) => { try { const data = refreshSchema.parse(req.body) diff --git a/backend/src/routes/platform.routes.ts b/backend/src/routes/platform.routes.ts new file mode 100644 index 0000000..b833cce --- /dev/null +++ b/backend/src/routes/platform.routes.ts @@ -0,0 +1,337 @@ +/** + * 平台管理员路由 + * 管理所有企业租户、用户、数据总览 + * 所有接口需要 SUPER_ADMIN 权限 + */ +import { Router } from 'express' +import prisma from '../lib/prisma' +import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth' +import { loginLimiter } from '../middleware/rateLimit' + +const router = Router() + +// 所有平台路由都需要认证 + SUPER_ADMIN 权限 +router.use(authMiddleware, platformAdminMiddleware) + +// ========== 数据总览 ========== + +/** + * 平台总览数据 + */ +router.get('/dashboard', async (_req: AuthRequest, res, next) => { + try { + const [orgs, users, employees, contracts, payslips] = await Promise.all([ + prisma.organization.count(), + prisma.user.count({ where: { role: { not: 'SUPER_ADMIN' } } }), + prisma.employee.count(), + prisma.laborContract.count(), + prisma.payslip.count(), + ]) + + // 按套餐分组 + const orgsByPlan = await prisma.organization.groupBy({ + by: ['plan'], + _count: true, + }) + + // 最近 7 天注册的企业 + const recentOrgs = await prisma.organization.findMany({ + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, name: true, plan: true, city: true, + createdAt: true, maxEmployees: true, + _count: { select: { employees: true, users: true } }, + }, + }) + + res.json({ + success: true, + data: { + totalOrgs: orgs, + totalUsers: users, + totalEmployees: employees, + totalContracts: contracts, + totalPayslips: payslips, + orgsByPlan: orgsByPlan.map((g: any) => ({ plan: g.plan, count: g._count })), + recentOrgs: recentOrgs.map((o: any) => ({ + id: o.id, + name: o.name, + plan: o.plan, + city: o.city, + createdAt: o.createdAt, + maxEmployees: o.maxEmployees, + employeeCount: o._count.employees, + userCount: o._count.users, + })), + }, + }) + } catch (err) { + next(err) + } +}) + +// ========== 企业租户管理 ========== + +/** + * 企业列表(分页 + 搜索) + */ +router.get('/orgs', async (req: AuthRequest, res, next) => { + try { + const page = parseInt(req.query.page as string) || 1 + const pageSize = parseInt(req.query.pageSize as string) || 20 + const search = (req.query.search as string) || '' + const planFilter = (req.query.plan as string) || '' + + const where: any = {} + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { city: { contains: search, mode: 'insensitive' } }, + { contactName: { contains: search, mode: 'insensitive' } }, + { contactPhone: { contains: search } }, + ] + } + if (planFilter) { + where.plan = planFilter + } + + const [orgs, total] = await Promise.all([ + prisma.organization.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, name: true, plan: true, maxEmployees: true, + city: true, contactName: true, contactPhone: true, + payrollFrequency: true, retirementReminderEnabled: true, + createdAt: true, updatedAt: true, + _count: { + select: { employees: true, users: true, contracts: true, payslips: true }, + }, + }, + }), + prisma.organization.count({ where }), + ]) + + res.json({ + success: true, + data: { + list: orgs.map((o: any) => ({ + ...o, + employeeCount: o._count.employees, + userCount: o._count.users, + contractCount: o._count.contracts, + payslipCount: o._count.payslips, + _count: undefined, + })), + total, + page, + pageSize, + }, + }) + } catch (err) { + next(err) + } +}) + +/** + * 企业详情 + */ +router.get('/orgs/:id', async (req: AuthRequest, res, next) => { + try { + const org = await prisma.organization.findUnique({ + where: { id: req.params.id }, + include: { + _count: { + select: { employees: true, users: true, contracts: true, payslips: true }, + }, + users: { + select: { id: true, name: true, phone: true, role: true, disabled: true, lastLoginAt: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + }) + + if (!org) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '企业不存在' } }) + } + + res.json({ success: true, data: org }) + } catch (err) { + next(err) + } +}) + +/** + * 更新企业信息(套餐、员工上限等) + */ +router.put('/orgs/:id', async (req: AuthRequest, res, next) => { + try { + const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as { + name?: string; plan?: string; maxEmployees?: number; + city?: string; contactName?: string; contactPhone?: string + } + + const updateData: any = {} + if (name) updateData.name = name + if (plan) updateData.plan = plan + if (maxEmployees !== undefined) updateData.maxEmployees = maxEmployees + if (city !== undefined) updateData.city = city + if (contactName !== undefined) updateData.contactName = contactName + if (contactPhone !== undefined) updateData.contactPhone = contactPhone + + const org = await prisma.organization.update({ + where: { id: req.params.id }, + data: updateData, + select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true }, + }) + + res.json({ success: true, data: org }) + } catch (err) { + next(err) + } +}) + +/** + * 删除企业(级联删除所有数据) + */ +router.delete('/orgs/:id', async (req: AuthRequest, res, next) => { + try { + await prisma.organization.delete({ where: { id: req.params.id } }) + res.json({ success: true, data: { message: '企业已删除' } }) + } catch (err) { + next(err) + } +}) + +// ========== 用户管理 ========== + +/** + * 所有企业用户列表(分页 + 搜索) + */ +router.get('/users', async (req: AuthRequest, res, next) => { + try { + const page = parseInt(req.query.page as string) || 1 + const pageSize = parseInt(req.query.pageSize as string) || 20 + const search = (req.query.search as string) || '' + const orgId = (req.query.orgId as string) || '' + + const where: any = { role: { not: 'SUPER_ADMIN' } } + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search } }, + ] + } + if (orgId) { + where.orgId = orgId + } + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, name: true, phone: true, role: true, + disabled: true, lastLoginAt: true, createdAt: true, + org: { select: { id: true, name: true } }, + }, + }), + prisma.user.count({ where }), + ]) + + res.json({ + success: true, + data: { + list: users.map((u: any) => ({ + ...u, + orgName: u.org?.name || null, + org: undefined, + })), + total, + page, + pageSize, + }, + }) + } catch (err) { + next(err) + } +}) + +/** + * 启用/禁用用户 + */ +router.put('/users/:id/toggle', async (req: AuthRequest, res, next) => { + try { + const user = await prisma.user.findUnique({ where: { id: req.params.id } }) + if (!user) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } }) + } + if (user.role === 'SUPER_ADMIN') { + return res.status(400).json({ success: false, error: { code: 'FORBIDDEN', message: '不能操作平台管理员账号' } }) + } + + const updated = await prisma.user.update({ + where: { id: req.params.id }, + data: { disabled: !user.disabled }, + select: { id: true, name: true, phone: true, role: true, disabled: true }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// ========== 平台管理员管理 ========== + +/** + * 平台管理员列表 + */ +router.get('/admins', async (_req: AuthRequest, res, next) => { + try { + const admins = await prisma.user.findMany({ + where: { role: 'SUPER_ADMIN' }, + select: { id: true, name: true, phone: true, disabled: true, lastLoginAt: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }) + res.json({ success: true, data: admins }) + } catch (err) { + next(err) + } +}) + +/** + * 创建平台管理员 + */ +router.post('/admins', async (req: AuthRequest, res, next) => { + try { + const { name, phone, password } = req.body as { name: string; phone: string; password: string } + + if (!name || !phone || !password) { + return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } }) + } + + const existing = await prisma.user.findUnique({ where: { phone } }) + if (existing) { + return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } }) + } + + const bcrypt = await import('bcryptjs') + const passwordHash = await bcrypt.default.hash(password, 10) + const admin = await prisma.user.create({ + data: { name, phone, passwordHash, role: 'SUPER_ADMIN', orgId: null }, + select: { id: true, name: true, phone: true, role: true }, + }) + + res.json({ success: true, data: admin }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index af1d129..7f9503f 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -42,6 +42,43 @@ export async function register(orgName: string, phone: string, password: string) } } +/** + * 平台管理员登录(仅 SUPER_ADMIN 角色可登录平台端) + */ +export async function platformLogin(phone: string, password: string) { + const user = await prisma.user.findUnique({ where: { phone } }) + if (!user) { + throw { code: 'NOT_FOUND', message: '手机号或密码错误' } + } + + const valid = await bcrypt.compare(password, user.passwordHash) + if (!valid) { + throw { code: 'AUTH_FAILED', message: '手机号或密码错误' } + } + + if (user.role !== 'SUPER_ADMIN') { + throw { code: 'FORBIDDEN', message: '该账号无平台管理权限' } + } + + if (user.disabled) { + throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用' } + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }) + + const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role }) + const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role }) + + return { + user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role }, + accessToken, + refreshToken, + } +} + export async function login(phone: string, password: string) { const user = await prisma.user.findUnique({ where: { phone } }) if (!user) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf8a62c..4794f41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,13 @@ const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) const CalendarPage = lazy(() => import('./pages/Calendar')) +// 平台管理端 +const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin')) +const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard')) +const PlatformOrgs = lazy(() => import('./pages/platform/PlatformOrgs')) +const PlatformUsers = lazy(() => import('./pages/platform/PlatformUsers')) +const PlatformSidebar = lazy(() => import('./components/layout/PlatformSidebar')) + function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) if (!isAuthenticated) return @@ -67,6 +74,40 @@ function AdminLayout({ children }: { children: React.ReactNode }) { ) } +function PlatformRoute({ children }: { children: React.ReactNode }) { + const user = useAuthStore((s) => s.user) + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + if (!isAuthenticated || user?.role !== 'SUPER_ADMIN') return + return <>{children} +} + +function PlatformLayout({ children }: { children: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false) + return ( +
+ setSidebarOpen(false)} /> +
+
+ + 平台管理后台 + ADMIN +
+
+
+ }>{children} +
+
+
+
+ ) +} + function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) { if (!showNav) { return ( @@ -112,6 +153,12 @@ export default function App() { } /> } /> + {/* 平台管理端 */} + }>} /> + } /> + } /> + } /> + {/* 员工端 */} } /> } /> diff --git a/frontend/src/components/layout/PlatformSidebar.tsx b/frontend/src/components/layout/PlatformSidebar.tsx new file mode 100644 index 0000000..c55ad01 --- /dev/null +++ b/frontend/src/components/layout/PlatformSidebar.tsx @@ -0,0 +1,150 @@ +/** + * 平台管理端侧边栏导航 + * 深色主题,amber 主色,区别于企业端 + */ +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { useState } from 'react' +import clsx from 'clsx' +import { + LayoutDashboard, Building2, Users, Shield, LogOut, + ChevronDown, ChevronRight, +} from 'lucide-react' +import { useAuthStore } from '../../store/authStore' + +interface NavItem { + path: string + label: string + icon: typeof LayoutDashboard +} + +const navGroups: { title: string; items: NavItem[] }[] = [ + { + title: '平台管理', + items: [ + { path: '/platform/dashboard', label: '数据总览', icon: LayoutDashboard }, + { path: '/platform/orgs', label: '企业租户', icon: Building2 }, + { path: '/platform/users', label: '用户管理', icon: Users }, + ], + }, +] + +export default function PlatformSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) { + const location = useLocation() + const navigate = useNavigate() + const { user, logout } = useAuthStore() + const [expandedGroups, setExpandedGroups] = useState>(new Set(navGroups.map(g => g.title))) + + const toggleGroup = (title: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(title)) next.delete(title) + else next.add(title) + return next + }) + } + + const isActive = (path: string) => location.pathname.startsWith(path) + + const handleLogout = () => { + logout() + navigate('/platform/login') + } + + return ( + <> + {mobileOpen && ( +
+ )} + + + + ) +} diff --git a/frontend/src/pages/platform/PlatformDashboard.tsx b/frontend/src/pages/platform/PlatformDashboard.tsx new file mode 100644 index 0000000..3cf9e68 --- /dev/null +++ b/frontend/src/pages/platform/PlatformDashboard.tsx @@ -0,0 +1,122 @@ +/** + * 平台总览页 — 企业数、员工数、套餐分布、最近注册企业 + */ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { Building2, Users, FileText, Calculator, TrendingUp } from 'lucide-react' +import api from '../../lib/api' + +interface DashboardData { + totalOrgs: number + totalUsers: number + totalEmployees: number + totalContracts: number + totalPayslips: number + orgsByPlan: { plan: string; count: number }[] + recentOrgs: { + id: string; name: string; plan: string; city: string + createdAt: string; maxEmployees: number + employeeCount: number; userCount: number + }[] +} + +const PLAN_LABELS: Record = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' } +const PLAN_COLORS: Record = { FREE: 'bg-gray-100 text-gray-700', PRO: 'bg-blue-50 text-blue-700', ENTERPRISE: 'bg-purple-50 text-purple-700' } + +export default function PlatformDashboard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.get('/platform/dashboard').then((res: any) => setData(res.data)).finally(() => setLoading(false)) + }, []) + + if (loading) return
加载中...
+ if (!data) return null + + const stats = [ + { label: '企业总数', value: data.totalOrgs, icon: Building2, color: 'text-amber-400' }, + { label: '用户总数', value: data.totalUsers, icon: Users, color: 'text-blue-400' }, + { label: '员工总数', value: data.totalEmployees, icon: Users, color: 'text-emerald-400' }, + { label: '合同总数', value: data.totalContracts, icon: FileText, color: 'text-purple-400' }, + { label: '工资条总数', value: data.totalPayslips, icon: Calculator, color: 'text-rose-400' }, + ] + + return ( +
+
+

数据总览

+

平台全局数据看板

+
+ + {/* 统计卡片 */} +
+ {stats.map((s) => { + const Icon = s.icon + return ( +
+
+ {s.label} + +
+
{s.value.toLocaleString()}
+
+ ) + })} +
+ +
+ {/* 套餐分布 */} +
+

+ + 套餐分布 +

+
+ {data.orgsByPlan.map((item) => ( +
+ + {PLAN_LABELS[item.plan] || item.plan} + +
+
+
0 ? (item.count / data.totalOrgs) * 100 : 0}%` }} + /> +
+ {item.count} +
+
+ ))} +
+
+ + {/* 最近注册企业 */} +
+
+

最近注册企业

+ 查看全部 → +
+
+ {data.recentOrgs.map((org) => ( + +
+
{org.name}
+
{org.city || '未设置'} · {org.employeeCount} 员工
+
+ + {PLAN_LABELS[org.plan] || org.plan} + + + ))} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/platform/PlatformLogin.tsx b/frontend/src/pages/platform/PlatformLogin.tsx new file mode 100644 index 0000000..6ceba42 --- /dev/null +++ b/frontend/src/pages/platform/PlatformLogin.tsx @@ -0,0 +1,118 @@ +/** + * 平台管理员登录页 + * 独立入口 /platform/login,仅 SUPER_ADMIN 角色可登录 + */ +import { useState } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { Eye, EyeOff, Shield } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useAuthStore } from '../../store/authStore' +import api from '../../lib/api' +import { Input, Label } from '../../components/ui/Input' +import Button from '../../components/ui/Button' + +const schema = z.object({ + phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'), + password: z.string().min(1, '请输入密码'), +}) + +type FormData = z.infer + +export default function PlatformLogin() { + const navigate = useNavigate() + const { setAuth } = useAuthStore() + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + phone: '13800000000', + password: 'admin123456', + }, + }) + + const onSubmit = async (data: FormData) => { + setError('') + setLoading(true) + try { + const res = await api.post('/auth/platform-login', data) as any + setAuth(res.data.user, res.data.accessToken, res.data.refreshToken) + navigate('/platform/dashboard') + } catch (err: any) { + setError(err.response?.data?.error?.message || '登录失败,请稍后重试') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ +
+
+
用工专家平台
+
PLATFORM ADMIN
+
+
+ +
+

平台管理员登录

+

超级管理员后台,管理所有企业租户

+ + {error && ( +
{error}
+ )} + +
+
+ + + {errors.phone &&

{errors.phone.message}

} +
+ +
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ + +
+ +
+ ← 返回企业登录 +
+
+
+
+ ) +} diff --git a/frontend/src/pages/platform/PlatformOrgs.tsx b/frontend/src/pages/platform/PlatformOrgs.tsx new file mode 100644 index 0000000..d720b07 --- /dev/null +++ b/frontend/src/pages/platform/PlatformOrgs.tsx @@ -0,0 +1,246 @@ +/** + * 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除 + */ +import { useEffect, useState } from 'react' +import { Search, Building2, Eye, Trash2, Edit2 } from 'lucide-react' +import api from '../../lib/api' +import { Input, Select, Label } from '../../components/ui/Input' +import Button from '../../components/ui/Button' + +interface Org { + id: string; name: string; plan: string; maxEmployees: number + city: string | null; contactName: string | null; contactPhone: string | null + payrollFrequency: number; retirementReminderEnabled: boolean + createdAt: string; updatedAt: string + employeeCount: number; userCount: number; contractCount: number; payslipCount: number +} + +const PLAN_LABELS: Record = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' } +const PLAN_COLORS: Record = { FREE: 'bg-gray-100 text-gray-700', PRO: 'bg-blue-50 text-blue-700', ENTERPRISE: 'bg-purple-50 text-purple-700' } + +export default function PlatformOrgs() { + const [orgs, setOrgs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize] = useState(20) + const [search, setSearch] = useState('') + const [planFilter, setPlanFilter] = useState('') + const [loading, setLoading] = useState(true) + const [editOrg, setEditOrg] = useState(null) + const [deleteOrg, setDeleteOrg] = useState(null) + + const fetchOrgs = async () => { + setLoading(true) + try { + const params: any = { page, pageSize } + if (search) params.search = search + if (planFilter) params.plan = planFilter + const res = await api.get('/platform/orgs', { params }) as any + setOrgs(res.data.list) + setTotal(res.data.total) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchOrgs() }, [page, planFilter]) + useEffect(() => { setPage(1) }, [search, planFilter]) + + const totalPages = Math.ceil(total / pageSize) + + const handleSaveEdit = async () => { + if (!editOrg) return + try { + await api.put(`/platform/orgs/${editOrg.id}`, { + name: editOrg.name, + plan: editOrg.plan, + maxEmployees: editOrg.maxEmployees, + city: editOrg.city, + contactName: editOrg.contactName, + contactPhone: editOrg.contactPhone, + }) + setEditOrg(null) + fetchOrgs() + } catch (err: any) { + alert(err.response?.data?.error?.message || '保存失败') + } + } + + const handleDelete = async () => { + if (!deleteOrg) return + try { + await api.delete(`/platform/orgs/${deleteOrg.id}`) + setDeleteOrg(null) + fetchOrgs() + } catch (err: any) { + alert(err.response?.data?.error?.message || '删除失败') + } + } + + return ( +
+
+

企业租户管理

+

共 {total} 家企业

+
+ + {/* 搜索栏 */} +
+
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchOrgs()} + /> +
+ + +
+ + {/* 表格 */} +
+ + + + + + + + + + + + + + + {loading ? ( + + ) : orgs.length === 0 ? ( + + ) : orgs.map((org) => ( + + + + + + + + + + + ))} + +
企业名称套餐城市员工数用户数合同数注册时间操作
加载中...
暂无数据
+
{org.name}
+
{org.contactName || '-'} {org.contactPhone || ''}
+
+ + {PLAN_LABELS[org.plan] || org.plan} + + {org.city || '-'}{org.employeeCount}{org.userCount}{org.contractCount}{new Date(org.createdAt).toLocaleDateString('zh-CN')} +
+ + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 页,共 {total} 条 +
+ + +
+
+ )} +
+ + {/* 编辑弹窗 */} + {editOrg && ( +
setEditOrg(null)}> +
e.stopPropagation()}> +

编辑企业

+
+
+ + setEditOrg({ ...editOrg, name: e.target.value })} /> +
+
+ + +
+
+ + setEditOrg({ ...editOrg, maxEmployees: parseInt(e.target.value) || 0 })} /> +
+
+ + setEditOrg({ ...editOrg, city: e.target.value })} /> +
+
+ + +
+
+
+
+ )} + + {/* 删除确认 */} + {deleteOrg && ( +
setDeleteOrg(null)}> +
e.stopPropagation()}> +
+
+ +
+
+

确认删除

+

此操作不可撤销,将级联删除所有数据

+
+
+

+ 确定要删除企业「{deleteOrg.name}」吗? + 该企业下有 {deleteOrg.employeeCount} 名员工、{deleteOrg.contractCount} 份合同、{deleteOrg.payslipCount} 条工资条,都将被永久删除。 +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/platform/PlatformUsers.tsx b/frontend/src/pages/platform/PlatformUsers.tsx new file mode 100644 index 0000000..5192f38 --- /dev/null +++ b/frontend/src/pages/platform/PlatformUsers.tsx @@ -0,0 +1,166 @@ +/** + * 用户管理页 — 查看所有企业用户、搜索、启用/禁用 + */ +import { useEffect, useState } from 'react' +import { Search, Ban, CheckCircle } from 'lucide-react' +import api from '../../lib/api' +import { Input, Select } from '../../components/ui/Input' +import Button from '../../components/ui/Button' + +interface UserItem { + id: string; name: string; phone: string; role: string + disabled: boolean; lastLoginAt: string | null; createdAt: string + orgName: string | null +} + +const ROLE_LABELS: Record = { + SUPER_ADMIN: '超级管理员', ADMIN: '管理员', HR: 'HR', VIEWER: '只读', +} +const ROLE_COLORS: Record = { + SUPER_ADMIN: 'bg-amber-100 text-amber-800', ADMIN: 'bg-blue-50 text-blue-700', + HR: 'bg-emerald-50 text-emerald-700', VIEWER: 'bg-gray-100 text-gray-600', +} + +export default function PlatformUsers() { + const [users, setUsers] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize] = useState(20) + const [search, setSearch] = useState('') + const [orgFilter, setOrgFilter] = useState('') + const [loading, setLoading] = useState(true) + const [orgs, setOrgs] = useState<{ id: string; name: string }[]>([]) + + const fetchUsers = async () => { + setLoading(true) + try { + const params: any = { page, pageSize } + if (search) params.search = search + if (orgFilter) params.orgId = orgFilter + const res = await api.get('/platform/users', { params }) as any + setUsers(res.data.list) + setTotal(res.data.total) + } finally { + setLoading(false) + } + } + + useEffect(() => { + api.get('/platform/orgs', { params: { pageSize: 200 } }).then((res: any) => { + setOrgs(res.data.list.map((o: any) => ({ id: o.id, name: o.name }))) + }) + }, []) + + useEffect(() => { fetchUsers() }, [page, orgFilter]) + useEffect(() => { setPage(1) }, [search, orgFilter]) + + const handleToggle = async (user: UserItem) => { + try { + await api.put(`/platform/users/${user.id}/toggle`) + fetchUsers() + } catch (err: any) { + alert(err.response?.data?.error?.message || '操作失败') + } + } + + const totalPages = Math.ceil(total / pageSize) + + return ( +
+
+

用户管理

+

共 {total} 个用户

+
+ + {/* 搜索栏 */} +
+
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchUsers()} + /> +
+ + +
+ + {/* 表格 */} +
+ + + + + + + + + + + + + + {loading ? ( + + ) : users.length === 0 ? ( + + ) : users.map((user) => ( + + + + + + + + + + ))} + +
姓名手机号角色所属企业状态最后登录操作
加载中...
暂无数据
{user.name}{user.phone} + + {ROLE_LABELS[user.role] || user.role} + + {user.orgName || '-'} + {user.disabled + ? 已禁用 + : 正常} + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'} + + +
+ + {totalPages > 1 && ( +
+ 第 {page} / {totalPages} 页,共 {total} 条 +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index ee37044..2010929 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -3,10 +3,10 @@ import { persist } from 'zustand/middleware' interface User { id: string - orgId: string + orgId: string | null name: string phone: string - role: 'ADMIN' | 'HR' | 'VIEWER' + role: 'SUPER_ADMIN' | 'ADMIN' | 'HR' | 'VIEWER' } interface AuthState {