feat: 平台管理员端 — SUPER_ADMIN 角色 + 企业租户管理 + 用户管理 + 数据总览
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
+12
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <Navigate to="/login" replace />
|
||||
@@ -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 <Navigate to="/platform/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-100">
|
||||
<PlatformSidebar mobileOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 bg-slate-950 border-b border-slate-800 flex items-center px-4 shrink-0">
|
||||
<button
|
||||
className="md:hidden mr-3 text-slate-400"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-400">平台管理后台</span>
|
||||
<span className="ml-2 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-400 text-slate-950">ADMIN</span>
|
||||
</header>
|
||||
<main className="flex-1 py-6 px-4 md:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
|
||||
if (!showNav) {
|
||||
return (
|
||||
@@ -112,6 +153,12 @@ export default function App() {
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 平台管理端 */}
|
||||
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
|
||||
<Route path="/platform/dashboard" element={<PlatformRoute><PlatformLayout><PlatformDashboard /></PlatformLayout></PlatformRoute>} />
|
||||
<Route path="/platform/orgs" element={<PlatformRoute><PlatformLayout><PlatformOrgs /></PlatformLayout></PlatformRoute>} />
|
||||
<Route path="/platform/users" element={<PlatformRoute><PlatformLayout><PlatformUsers /></PlatformLayout></PlatformRoute>} />
|
||||
|
||||
{/* 员工端 */}
|
||||
<Route path="/portal/login" element={<PortalLayoutWrapper showNav={false}><PortalLogin /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/payslip" element={<PortalLayoutWrapper><Payslip /></PortalLayoutWrapper>} />
|
||||
|
||||
@@ -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<Set<string>>(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 && (
|
||||
<div className="fixed inset-0 bg-black/60 z-40 md:hidden" onClick={onClose} aria-label="关闭侧边栏" />
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed md:sticky top-0 left-0 z-50 md:z-auto',
|
||||
'w-56 h-screen flex-shrink-0',
|
||||
'bg-slate-950 border-r border-slate-800',
|
||||
'flex flex-col',
|
||||
'transition-transform duration-200',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Logo 区 */}
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-slate-800 shrink-0">
|
||||
<div className="w-7 h-7 rounded-md bg-amber-400 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-slate-950" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm text-white">用工专家平台</div>
|
||||
<div className="text-[10px] text-amber-400 tracking-wider">ADMIN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-3">
|
||||
{navGroups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.title)
|
||||
const hasActiveItem = group.items.some(item => isActive(item.path))
|
||||
|
||||
return (
|
||||
<div key={group.title}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full px-2 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
hasActiveItem ? 'text-slate-200' : 'text-slate-400 hover:text-slate-200',
|
||||
)}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.path)
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-amber-400/10 text-amber-400 font-medium'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部用户区 */}
|
||||
<div className="border-t border-slate-800 p-3 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-amber-400 text-sm font-medium">
|
||||
{user?.name?.charAt(0) || 'A'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white truncate">{user?.name}</div>
|
||||
<div className="text-[10px] text-amber-400">SUPER_ADMIN</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' }
|
||||
const PLAN_COLORS: Record<string, string> = { 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<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/platform/dashboard').then((res: any) => setData(res.data)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-slate-400">加载中...</div>
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">数据总览</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">平台全局数据看板</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{stats.map((s) => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.label} className="bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-slate-400">{s.label}</span>
|
||||
<Icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{s.value.toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 套餐分布 */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||
套餐分布
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{data.orgsByPlan.map((item) => (
|
||||
<div key={item.plan} className="flex items-center justify-between">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PLAN_COLORS[item.plan] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{PLAN_LABELS[item.plan] || item.plan}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 rounded-full"
|
||||
style={{ width: `${data.totalOrgs > 0 ? (item.count / data.totalOrgs) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-300 w-8 text-right">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近注册企业 */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-white">最近注册企业</h2>
|
||||
<Link to="/platform/orgs" className="text-xs text-amber-400 hover:underline">查看全部 →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.recentOrgs.map((org) => (
|
||||
<Link
|
||||
key={org.id}
|
||||
to={`/platform/orgs?id=${org.id}`}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-white truncate">{org.name}</div>
|
||||
<div className="text-xs text-slate-500">{org.city || '未设置'} · {org.employeeCount} 员工</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium shrink-0 ${PLAN_COLORS[org.plan] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{PLAN_LABELS[org.plan] || org.plan}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormData>({
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-950 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-400 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-slate-950" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">用工专家平台</div>
|
||||
<div className="text-xs text-amber-400">PLATFORM ADMIN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6">
|
||||
<h1 className="text-lg font-semibold text-white mb-1">平台管理员登录</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">超级管理员后台,管理所有企业租户</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-950 border border-red-800 text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-slate-300">手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-400 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-300">密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-amber-400 text-slate-950 hover:bg-amber-300" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/login" className="text-sm text-slate-400 hover:text-slate-200">← 返回企业登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' }
|
||||
const PLAN_COLORS: Record<string, string> = { 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<Org[]>([])
|
||||
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<Org | null>(null)
|
||||
const [deleteOrg, setDeleteOrg] = useState<Org | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">企业租户管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">共 {total} 家企业</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索企业名称、城市、联系人..."
|
||||
className="bg-slate-900 border-slate-700 text-white placeholder:text-slate-500 pl-10"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchOrgs()}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className="bg-slate-900 border-slate-700 text-white w-32"
|
||||
value={planFilter}
|
||||
onChange={(e) => setPlanFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部套餐</option>
|
||||
<option value="FREE">免费版</option>
|
||||
<option value="PRO">专业版</option>
|
||||
<option value="ENTERPRISE">企业版</option>
|
||||
</Select>
|
||||
<Button variant="secondary" onClick={fetchOrgs} className="bg-slate-800 text-slate-200 border border-slate-700 hover:bg-slate-700">
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400 text-xs">
|
||||
<th className="text-left px-4 py-3 font-medium">企业名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium">套餐</th>
|
||||
<th className="text-left px-4 py-3 font-medium">城市</th>
|
||||
<th className="text-right px-4 py-3 font-medium">员工数</th>
|
||||
<th className="text-right px-4 py-3 font-medium">用户数</th>
|
||||
<th className="text-right px-4 py-3 font-medium">合同数</th>
|
||||
<th className="text-left px-4 py-3 font-medium">注册时间</th>
|
||||
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">加载中...</td></tr>
|
||||
) : orgs.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">暂无数据</td></tr>
|
||||
) : orgs.map((org) => (
|
||||
<tr key={org.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-white font-medium">{org.name}</div>
|
||||
<div className="text-xs text-slate-500">{org.contactName || '-'} {org.contactPhone || ''}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PLAN_COLORS[org.plan] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{PLAN_LABELS[org.plan] || org.plan}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{org.city || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-300">{org.employeeCount}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-300">{org.userCount}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-300">{org.contractCount}</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs">{new Date(org.createdAt).toLocaleDateString('zh-CN')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => setEditOrg(org)}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-amber-400 hover:bg-slate-800"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteOrg(org)}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
||||
<span className="text-xs text-slate-400">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)} className="bg-slate-800 text-slate-200 border border-slate-700">上一页</Button>
|
||||
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)} className="bg-slate-800 text-slate-200 border border-slate-700">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editOrg && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={() => setEditOrg(null)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">编辑企业</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-300">企业名称</Label>
|
||||
<Input className="bg-slate-800 border-slate-700 text-white" value={editOrg.name} onChange={(e) => setEditOrg({ ...editOrg, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">套餐</Label>
|
||||
<Select className="bg-slate-800 border-slate-700 text-white" value={editOrg.plan} onChange={(e) => setEditOrg({ ...editOrg, plan: e.target.value })}>
|
||||
<option value="FREE">免费版</option>
|
||||
<option value="PRO">专业版</option>
|
||||
<option value="ENTERPRISE">企业版</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">员工上限</Label>
|
||||
<Input type="number" className="bg-slate-800 border-slate-700 text-white" value={editOrg.maxEmployees} onChange={(e) => setEditOrg({ ...editOrg, maxEmployees: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">城市</Label>
|
||||
<Input className="bg-slate-800 border-slate-700 text-white" value={editOrg.city || ''} onChange={(e) => setEditOrg({ ...editOrg, city: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button className="bg-amber-400 text-slate-950 hover:bg-amber-300 flex-1" onClick={handleSaveEdit}>保存</Button>
|
||||
<Button variant="secondary" className="bg-slate-800 text-slate-200 border border-slate-700" onClick={() => setEditOrg(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{deleteOrg && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={() => setDeleteOrg(null)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-950 flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">确认删除</h2>
|
||||
<p className="text-xs text-slate-400">此操作不可撤销,将级联删除所有数据</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 mb-4">
|
||||
确定要删除企业「<span className="text-white font-medium">{deleteOrg.name}</span>」吗?
|
||||
该企业下有 {deleteOrg.employeeCount} 名员工、{deleteOrg.contractCount} 份合同、{deleteOrg.payslipCount} 条工资条,都将被永久删除。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" className="flex-1" onClick={handleDelete}>确认删除</Button>
|
||||
<Button variant="secondary" className="bg-slate-800 text-slate-200 border border-slate-700" onClick={() => setDeleteOrg(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员', ADMIN: '管理员', HR: 'HR', VIEWER: '只读',
|
||||
}
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
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<UserItem[]>([])
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">用户管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">共 {total} 个用户</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索姓名、手机号..."
|
||||
className="bg-slate-900 border-slate-700 text-white placeholder:text-slate-500 pl-10"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchUsers()}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className="bg-slate-900 border-slate-700 text-white w-40"
|
||||
value={orgFilter}
|
||||
onChange={(e) => setOrgFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部企业</option>
|
||||
{orgs.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</Select>
|
||||
<Button variant="secondary" onClick={fetchUsers} className="bg-slate-800 text-slate-200 border border-slate-700 hover:bg-slate-700">
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400 text-xs">
|
||||
<th className="text-left px-4 py-3 font-medium">姓名</th>
|
||||
<th className="text-left px-4 py-3 font-medium">手机号</th>
|
||||
<th className="text-left px-4 py-3 font-medium">角色</th>
|
||||
<th className="text-left px-4 py-3 font-medium">所属企业</th>
|
||||
<th className="text-left px-4 py-3 font-medium">状态</th>
|
||||
<th className="text-left px-4 py-3 font-medium">最后登录</th>
|
||||
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-slate-400">加载中...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-slate-400">暂无数据</td></tr>
|
||||
) : users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
|
||||
<td className="px-4 py-3 text-white font-medium">{user.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{user.phone}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${ROLE_COLORS[user.role] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{ROLE_LABELS[user.role] || user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{user.orgName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.disabled
|
||||
? <span className="text-xs text-red-400">已禁用</span>
|
||||
: <span className="text-xs text-emerald-400">正常</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(user)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||
user.disabled
|
||||
? 'text-emerald-400 hover:bg-slate-800'
|
||||
: 'text-red-400 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{user.disabled ? <><CheckCircle className="w-3 h-3" /> 启用</> : <><Ban className="w-3 h-3" /> 禁用</>}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
||||
<span className="text-xs text-slate-400">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)} className="bg-slate-800 text-slate-200 border border-slate-700">上一页</Button>
|
||||
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)} className="bg-slate-800 text-slate-200 border border-slate-700">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user