This commit is contained in:
EL 2026-03-13 06:10:32 +08:00
parent 2c1bbd565f
commit 394793fa28
35 changed files with 5452 additions and 31 deletions

View File

@ -0,0 +1,37 @@
# 前端接口接入说明(`tyt-admin`
## 1. 本次接入范围
- 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。
## 2. 接口契约对齐点
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCardHash`
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
## 3. 角色权限提示
- 任务接口权限:
- `DOCTOR`:发布、取消
- `ENGINEER`:接收、完成
- 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
## 4. 本地运行
`tyt-admin` 目录执行:
```bash
pnpm install
pnpm dev
```

View File

@ -13,6 +13,15 @@
- `HOSPITAL_ADMIN`:可查本院全部患者 - `HOSPITAL_ADMIN`:可查本院全部患者
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId` - `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
## 2.1 B 端 CRUD
- `GET /b/patients`:按角色查询可见患者
- `GET /b/patients/doctors`:查询当前角色可见的医生候选(用于患者表单)
- `POST /b/patients`:创建患者
- `GET /b/patients/:id`:查询患者详情
- `PATCH /b/patients/:id`:更新患者
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409
说明: 说明:
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后, 患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。 可见范围会按医生当前组织归属自动变化,无需迁移患者数据。

View File

@ -49,7 +49,8 @@ export const MESSAGES = {
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院', DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
GROUP_DEPARTMENT_REQUIRED: '绑定小组时必须同时传入科室', GROUP_DEPARTMENT_REQUIRED: '绑定小组时必须同时传入科室',
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室', GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生允许调整科室/小组归属', DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
MULTI_ACCOUNT_REQUIRE_HOSPITAL: MULTI_ACCOUNT_REQUIRE_HOSPITAL:
'检测到多个同手机号账号,请传 hospitalId 指定登录医院', '检测到多个同手机号账号,请传 hospitalId 指定登录医院',
}, },
@ -70,9 +71,14 @@ export const MESSAGES = {
}, },
PATIENT: { PATIENT: {
NOT_FOUND: '患者不存在或无权限访问',
ROLE_FORBIDDEN: '当前角色无权限查询患者列表', ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
GROUP_REQUIRED: '组长查询需携带 groupId', GROUP_REQUIRED: '组长查询需携带 groupId',
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId', DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
DOCTOR_NOT_FOUND: '归属医生不存在',
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生角色',
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生',
DELETE_CONFLICT: '患者存在关联设备,无法删除',
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填', PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希', LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',

View File

@ -1,5 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import {
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js'; import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -7,6 +24,8 @@ import { RolesGuard } from '../../auth/roles.guard.js';
import { Roles } from '../../auth/roles.decorator.js'; import { Roles } from '../../auth/roles.decorator.js';
import { Role } from '../../generated/prisma/enums.js'; import { Role } from '../../generated/prisma/enums.js';
import { BPatientsService } from './b-patients.service.js'; import { BPatientsService } from './b-patients.service.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
/** /**
* B * B
@ -18,6 +37,32 @@ import { BPatientsService } from './b-patients.service.js';
export class BPatientsController { export class BPatientsController {
constructor(private readonly patientsService: BPatientsService) {} constructor(private readonly patientsService: BPatientsService) {}
/**
* /
*/
@Get('doctors')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询当前角色可见医生列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
})
findVisibleDoctors(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
}
/** /**
* *
*/ */
@ -44,4 +89,81 @@ export class BPatientsController {
return this.patientsService.findVisiblePatients(actor, requestedHospitalId); return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
} }
/**
*
*/
@Post()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '创建患者' })
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) {
return this.patientsService.createPatient(actor, dto);
}
/**
*
*/
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者详情' })
@ApiParam({ name: 'id', description: '患者 ID' })
findPatientById(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.findPatientById(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者信息' })
@ApiParam({ name: 'id', description: '患者 ID' })
updatePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePatientDto,
) {
return this.patientsService.updatePatient(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '删除患者' })
@ApiParam({ name: 'id', description: '患者 ID' })
removePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.removePatient(actor, id);
}
} }

View File

@ -1,27 +1,318 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client.js';
import { Role } from '../../generated/prisma/enums.js'; import { Role } from '../../generated/prisma/enums.js';
import { PrismaService } from '../../prisma.service.js'; import { PrismaService } from '../../prisma.service.js';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { MESSAGES } from '../../common/messages.js'; import { MESSAGES } from '../../common/messages.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
/** /**
* B * B CRUD
*/ */
@Injectable() @Injectable()
export class BPatientsService { export class BPatientsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* B *
*/ */
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) { async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId);
// 患者仅绑定 doctorId/hospitalId角色可见性通过关联 doctor 的当前组织归属反查。 return this.prisma.patient.findMany({
where,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where: Prisma.UserWhereInput = {
role: Role.DOCTOR,
hospitalId,
};
switch (actor.role) {
case Role.DOCTOR:
where.id = actor.id;
break;
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
where.groupId = actor.groupId;
break;
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
where.departmentId = actor.departmentId;
break;
case Role.HOSPITAL_ADMIN:
case Role.SYSTEM_ADMIN:
break;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return this.prisma.user.findMany({
where,
select: {
id: true,
name: true,
phone: true,
hospitalId: true,
departmentId: true,
groupId: true,
role: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
return this.prisma.patient.create({
data: {
name: this.normalizeRequiredString(dto.name, 'name'),
phone: this.normalizePhone(dto.phone),
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
hospitalId: doctor.hospitalId!,
doctorId: doctor.id,
},
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
}
/**
*
*/
async findPatientById(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
return patient;
}
/**
*
*/
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
const data: Prisma.PatientUpdateInput = {};
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.phone !== undefined) {
data.phone = this.normalizePhone(dto.phone);
}
if (dto.idCardHash !== undefined) {
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
}
if (dto.doctorId !== undefined) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
data.doctor = { connect: { id: doctor.id } };
data.hospital = { connect: { id: doctor.hospitalId! } };
}
return this.prisma.patient.update({
where: { id: patient.id },
data,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
}
/**
*
*/
async removePatient(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
try {
return await this.prisma.patient.delete({
where: { id: patient.id },
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2003'
) {
throw new ConflictException(MESSAGES.PATIENT.DELETE_CONFLICT);
}
throw error;
}
}
/**
*
*/
private async findPatientWithScope(id: number) {
const patientId = Number(id);
if (!Number.isInteger(patientId)) {
throw new BadRequestException('id 必须为整数');
}
const patient = await this.prisma.patient.findUnique({
where: { id: patientId },
include: {
hospital: { select: { id: true, name: true } },
doctor: {
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
},
devices: true,
},
});
if (!patient) {
throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND);
}
return patient;
}
/**
*
*/
private assertPatientScope(
actor: ActorContext,
patient: {
hospitalId: number;
doctorId: number;
doctor: { departmentId: number | null; groupId: number | null };
},
) {
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return;
case Role.HOSPITAL_ADMIN:
if (!actor.hospitalId || actor.hospitalId !== patient.hospitalId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.DIRECTOR:
if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.LEADER:
if (!actor.groupId || patient.doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.DOCTOR:
if (patient.doctorId !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
}
/**
*
*/
private async resolveWritableDoctor(actor: ActorContext, doctorId: number) {
const normalizedDoctorId = Number(doctorId);
if (!Number.isInteger(normalizedDoctorId)) {
throw new BadRequestException('doctorId 必须为整数');
}
const doctor = await this.prisma.user.findUnique({
where: { id: normalizedDoctorId },
select: {
id: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
});
if (!doctor) {
throw new NotFoundException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
}
if (doctor.role !== Role.DOCTOR) {
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_ROLE_REQUIRED);
}
if (!doctor.hospitalId) {
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
}
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return doctor;
case Role.HOSPITAL_ADMIN:
if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.DIRECTOR:
if (!actor.departmentId || doctor.departmentId !== actor.departmentId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.LEADER:
if (!actor.groupId || doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.DOCTOR:
if (doctor.id !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
}
/**
*
*/
private buildVisiblePatientWhere(actor: ActorContext, hospitalId: number) {
const where: Record<string, unknown> = { hospitalId }; const where: Record<string, unknown> = { hospitalId };
switch (actor.role) { switch (actor.role) {
case Role.DOCTOR: case Role.DOCTOR:
@ -51,16 +342,7 @@ export class BPatientsService {
default: default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
} }
return where;
return this.prisma.patient.findMany({
where,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
orderBy: { id: 'desc' },
});
} }
/** /**
@ -87,4 +369,23 @@ export class BPatientsService {
return actor.hospitalId; return actor.hospitalId;
} }
private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
return trimmed;
}
private normalizePhone(phone: unknown) {
const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) {
throw new BadRequestException('phone 必须是合法手机号');
}
return normalized;
}
} }

View File

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsString,
Matches,
Min,
} from 'class-validator';
/**
* DTOB 使
*/
export class CreatePatientDto {
@ApiProperty({ description: '患者姓名', example: '张三' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号', example: '13800002001' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiProperty({
description: '身份证哈希(前端传加密后值)',
example: 'id-card-hash-demo',
})
@IsString({ message: 'idCardHash 必须是字符串' })
idCardHash!: string;
@ApiProperty({ description: '归属医生 ID', example: 10001 })
@Type(() => Number)
@IsInt({ message: 'doctorId 必须是整数' })
@Min(1, { message: 'doctorId 必须大于 0' })
doctorId!: number;
}

View File

@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePatientDto } from './create-patient.dto.js';
/**
* DTO
*/
export class UpdatePatientDto extends PartialType(CreatePatientDto) {}

View File

@ -8,6 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { compare, hash } from 'bcrypt'; import { compare, hash } from 'bcrypt';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { Prisma } from '../generated/prisma/client.js';
import { CreateUserDto } from './dto/create-user.dto.js'; import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js'; import { UpdateUserDto } from './dto/update-user.dto.js';
import { Role } from '../generated/prisma/enums.js'; import { Role } from '../generated/prisma/enums.js';
@ -236,7 +237,12 @@ export class UsersService {
const assigningDepartmentOrGroup = const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) || (updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
(updateUserDto.groupId !== undefined && nextGroupId != null); (updateUserDto.groupId !== undefined && nextGroupId != null);
if (assigningDepartmentOrGroup && nextRole !== Role.DOCTOR) { if (
assigningDepartmentOrGroup &&
nextRole !== Role.DOCTOR &&
nextRole !== Role.DIRECTOR &&
nextRole !== Role.LEADER
) {
throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE); throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE);
} }
@ -306,10 +312,20 @@ export class UsersService {
const userId = this.normalizeRequiredInt(id, 'id'); const userId = this.normalizeRequiredInt(id, 'id');
await this.findOne(userId); await this.findOne(userId);
return this.prisma.user.delete({ try {
return await this.prisma.user.delete({
where: { id: userId }, where: { id: userId },
select: SAFE_USER_SELECT, select: SAFE_USER_SELECT,
}); });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2003'
) {
throw new ConflictException(MESSAGES.USER.DELETE_CONFLICT);
}
throw error;
}
} }
/** /**

View File

@ -207,7 +207,7 @@ describe('UsersController + BUsersController (e2e)', () => {
groupId: ctx.fixtures.groupA1Id, groupId: ctx.fixtures.groupA1Id,
}); });
expectErrorEnvelope(response, 400, '仅医生允许调整科室/小组归属'); expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
}); });
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => { it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
@ -246,6 +246,14 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.id).toBe(created.id); expect(response.body.data.id).toBe(created.id);
}); });
it('失败:存在关联患者/任务时返回 409', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
});
it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => { it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`) .delete(`/users/${ctx.fixtures.users.doctorAId}`)

View File

@ -11,6 +11,44 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
} }
} }

View File

@ -9,11 +9,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.5", "element-plus": "^2.13.5",
"vue": "^3.5.30" "nprogress": "^0.2.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"sass": "^1.98.0",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0", "unplugin-vue-components": "^31.0.0",
"vite": "^8.0.0" "vite": "^8.0.0"

678
tyt-admin/pnpm-lock.yaml generated
View File

@ -8,16 +8,34 @@ importers:
.: .:
dependencies: dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.30)
axios:
specifier: ^1.13.6
version: 1.13.6
element-plus: element-plus:
specifier: ^2.13.5 specifier: ^2.13.5
version: 2.13.5(vue@3.5.30) version: 2.13.5(vue@3.5.30)
nprogress:
specifier: ^0.2.0
version: 0.2.0
pinia:
specifier: ^3.0.4
version: 3.0.4(vue@3.5.30)
vue: vue:
specifier: ^3.5.30 specifier: ^3.5.30
version: 3.5.30 version: 3.5.30
vue-router:
specifier: ^5.0.3
version: 5.0.3(@vue/compiler-sfc@3.5.30)(pinia@3.0.4(vue@3.5.30))(vue@3.5.30)
devDependencies: devDependencies:
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.5 specifier: ^6.0.5
version: 6.0.5(vite@8.0.0)(vue@3.5.30) version: 6.0.5(vite@8.0.0(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30)
sass:
specifier: ^1.98.0
version: 1.98.0
unplugin-auto-import: unplugin-auto-import:
specifier: ^21.0.0 specifier: ^21.0.0
version: 21.0.0(@vueuse/core@12.0.0) version: 21.0.0(@vueuse/core@12.0.0)
@ -26,10 +44,14 @@ importers:
version: 31.0.0(vue@3.5.30) version: 31.0.0(vue@3.5.30)
vite: vite:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0(sass@1.98.0)(yaml@2.8.2)
packages: packages:
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -100,6 +122,94 @@ packages:
'@oxc-project/types@0.115.0': '@oxc-project/types@0.115.0':
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@rolldown/binding-android-arm64@1.0.0-rc.9': '@rolldown/binding-android-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -226,6 +336,15 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vue-macros/common@3.1.2':
resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==}
engines: {node: '>=20.19.0'}
peerDependencies:
vue: ^2.7.0 || ^3.2.25
peerDependenciesMeta:
vue:
optional: true
'@vue/compiler-core@3.5.30': '@vue/compiler-core@3.5.30':
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
@ -238,6 +357,24 @@ packages:
'@vue/compiler-ssr@3.5.30': '@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-api@8.0.7':
resolution: {integrity: sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-kit@8.0.7':
resolution: {integrity: sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/devtools-shared@8.0.7':
resolution: {integrity: sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==}
'@vue/reactivity@3.5.30': '@vue/reactivity@3.5.30':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
@ -269,29 +406,70 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
ast-walker-scope@0.8.3:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
async-validator@4.2.5: async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.6:
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0: chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
confbox@0.1.8: confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4: confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.20: dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
element-plus@2.13.5: element-plus@2.13.5:
resolution: {integrity: sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==} resolution: {integrity: sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==}
peerDependencies: peerDependencies:
@ -301,6 +479,22 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
escape-string-regexp@5.0.0: escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -323,14 +517,82 @@ packages:
picomatch: picomatch:
optional: true optional: true
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
immutable@5.1.5:
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
js-tokens@9.0.1: js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -422,29 +684,63 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
memoize-one@6.0.0: memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.8.1: mlly@1.8.1:
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
normalize-wheel-es@1.2.0: normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
obug@2.1.1: obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -452,6 +748,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -462,18 +767,33 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0: readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rolldown@1.0.0-rc.9: rolldown@1.0.0-rc.9:
resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
sass@1.98.0:
resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==}
engines: {node: '>=14.0.0'}
hasBin: true
scule@1.3.0: scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@ -481,9 +801,17 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -528,6 +856,10 @@ packages:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
unplugin@3.0.0:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
vite@8.0.0: vite@8.0.0:
resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==} resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -571,6 +903,21 @@ packages:
yaml: yaml:
optional: true optional: true
vue-router@5.0.3:
resolution: {integrity: sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==}
peerDependencies:
'@pinia/colada': '>=0.21.2'
'@vue/compiler-sfc': ^3.5.17
pinia: ^3.0.4
vue: ^3.5.0
peerDependenciesMeta:
'@pinia/colada':
optional: true
'@vue/compiler-sfc':
optional: true
pinia:
optional: true
vue@3.5.30: vue@3.5.30:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies: peerDependencies:
@ -582,8 +929,21 @@ packages:
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots: snapshots:
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
@ -660,6 +1020,67 @@ snapshots:
'@oxc-project/types@0.115.0': {} '@oxc-project/types@0.115.0': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.9': '@rolldown/binding-android-arm64@1.0.0-rc.9':
optional: true optional: true
@ -728,10 +1149,20 @@ snapshots:
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@6.0.5(vite@8.0.0)(vue@3.5.30)': '@vitejs/plugin-vue@6.0.5(vite@8.0.0(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30)':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2 '@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.0.0 vite: 8.0.0(sass@1.98.0)(yaml@2.8.2)
vue: 3.5.30
'@vue-macros/common@3.1.2(vue@3.5.30)':
dependencies:
'@vue/compiler-sfc': 3.5.30
ast-kit: 2.2.0
local-pkg: 1.1.2
magic-string-ast: 1.0.3
unplugin-utils: 0.3.1
optionalDependencies:
vue: 3.5.30 vue: 3.5.30
'@vue/compiler-core@3.5.30': '@vue/compiler-core@3.5.30':
@ -764,6 +1195,37 @@ snapshots:
'@vue/compiler-dom': 3.5.30 '@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.30 '@vue/shared': 3.5.30
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-api@8.0.7':
dependencies:
'@vue/devtools-kit': 8.0.7
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-kit@8.0.7':
dependencies:
'@vue/devtools-shared': 8.0.7
birpc: 2.9.0
hookable: 5.5.3
perfect-debounce: 2.1.0
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.0.7': {}
'@vue/reactivity@3.5.30': '@vue/reactivity@3.5.30':
dependencies: dependencies:
'@vue/shared': 3.5.30 '@vue/shared': 3.5.30
@ -807,22 +1269,69 @@ snapshots:
acorn@8.16.0: {} acorn@8.16.0: {}
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.0
pathe: 2.0.3
ast-walker-scope@0.8.3:
dependencies:
'@babel/parser': 7.29.0
ast-kit: 2.2.0
async-validator@4.2.5: {} async-validator@4.2.5: {}
asynckit@0.4.0: {}
axios@1.13.6:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
birpc@2.9.0: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0: chokidar@5.0.0:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
confbox@0.1.8: {} confbox@0.1.8: {}
confbox@0.2.4: {} confbox@0.2.4: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.2.3: {} csstype@3.2.3: {}
dayjs@1.11.20: {} dayjs@1.11.20: {}
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
element-plus@2.13.5(vue@3.5.30): element-plus@2.13.5(vue@3.5.30):
dependencies: dependencies:
'@ctrl/tinycolor': 4.2.0 '@ctrl/tinycolor': 4.2.0
@ -845,6 +1354,21 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
@ -859,11 +1383,71 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
gopd@1.2.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
hookable@5.5.3: {}
immutable@5.1.5: {}
is-extglob@2.1.1:
optional: true
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
optional: true
is-what@5.5.0: {}
js-tokens@9.0.1: {} js-tokens@9.0.1: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@ -929,12 +1513,26 @@ snapshots:
lodash@4.17.23: {} lodash@4.17.23: {}
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.21
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
memoize-one@6.0.0: {} memoize-one@6.0.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mitt@3.0.1: {}
mlly@1.8.1: mlly@1.8.1:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@ -942,18 +1540,34 @@ snapshots:
pkg-types: 1.3.1 pkg-types: 1.3.1
ufo: 1.6.3 ufo: 1.6.3
muggle-string@0.4.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
node-addon-api@7.1.1:
optional: true
normalize-wheel-es@1.2.0: {} normalize-wheel-es@1.2.0: {}
nprogress@0.2.0: {}
obug@2.1.1: {} obug@2.1.1: {}
pathe@2.0.3: {} pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
pinia@3.0.4(vue@3.5.30):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.30
pkg-types@1.3.1: pkg-types@1.3.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8
@ -972,10 +1586,16 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
proxy-from-env@1.1.0: {}
quansync@0.2.11: {} quansync@0.2.11: {}
readdirp@4.1.2: {}
readdirp@5.0.0: {} readdirp@5.0.0: {}
rfdc@1.4.1: {}
rolldown@1.0.0-rc.9: rolldown@1.0.0-rc.9:
dependencies: dependencies:
'@oxc-project/types': 0.115.0 '@oxc-project/types': 0.115.0
@ -997,14 +1617,28 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9
sass@1.98.0:
dependencies:
chokidar: 4.0.3
immutable: 5.1.5
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.6
scule@1.3.0: {} scule@1.3.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
strip-literal@3.1.0: strip-literal@3.1.0:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@ -1068,7 +1702,13 @@ snapshots:
picomatch: 4.0.3 picomatch: 4.0.3
webpack-virtual-modules: 0.6.2 webpack-virtual-modules: 0.6.2
vite@8.0.0: unplugin@3.0.0:
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
vite@8.0.0(sass@1.98.0)(yaml@2.8.2):
dependencies: dependencies:
'@oxc-project/runtime': 0.115.0 '@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0 lightningcss: 1.32.0
@ -1078,6 +1718,32 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
sass: 1.98.0
yaml: 2.8.2
vue-router@5.0.3(@vue/compiler-sfc@3.5.30)(pinia@3.0.4(vue@3.5.30))(vue@3.5.30):
dependencies:
'@babel/generator': 7.29.1
'@vue-macros/common': 3.1.2(vue@3.5.30)
'@vue/devtools-api': 8.0.7
ast-walker-scope: 0.8.3
chokidar: 5.0.0
json5: 2.2.3
local-pkg: 1.1.2
magic-string: 0.30.21
mlly: 1.8.1
muggle-string: 0.4.1
pathe: 2.0.3
picomatch: 4.0.3
scule: 1.3.0
tinyglobby: 0.2.15
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.30
yaml: 2.8.2
optionalDependencies:
'@vue/compiler-sfc': 3.5.30
pinia: 3.0.4(vue@3.5.30)
vue@3.5.30: vue@3.5.30:
dependencies: dependencies:
@ -1088,3 +1754,5 @@ snapshots:
'@vue/shared': 3.5.30 '@vue/shared': 3.5.30
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}
yaml@2.8.2: {}

View File

@ -1,3 +1,23 @@
<script setup></script> <template>
<el-config-provider :locale="locale">
<router-view />
</el-config-provider>
</template>
<template></template> <script setup>
import { ref } from 'vue';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
const locale = ref(zhCn);
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f0f2f5;
}
#app {
height: 100vh;
}
</style>

View File

@ -0,0 +1,68 @@
import request from './request';
const normalizeQuery = (params = {}) => {
if (!params || typeof params !== 'object') {
return params;
}
const nextParams = { ...params };
if (nextParams.pageSize !== undefined) {
const parsed = Number(nextParams.pageSize);
if (Number.isFinite(parsed)) {
nextParams.pageSize = Math.min(100, Math.max(1, Math.trunc(parsed)));
}
}
return nextParams;
};
// ==================== 医院管理 ====================
export const getHospitals = (params) => {
return request.get('/b/organization/hospitals', { params: normalizeQuery(params) });
};
export const createHospital = (data) => {
return request.post('/b/organization/hospitals', data);
};
export const updateHospital = (id, data) => {
return request.patch(`/b/organization/hospitals/${id}`, data);
};
export const deleteHospital = (id) => {
return request.delete(`/b/organization/hospitals/${id}`);
};
// ==================== 科室管理 ====================
export const getDepartments = (params) => {
return request.get('/b/organization/departments', { params: normalizeQuery(params) });
};
export const createDepartment = (data) => {
return request.post('/b/organization/departments', data);
};
export const updateDepartment = (id, data) => {
return request.patch(`/b/organization/departments/${id}`, data);
};
export const deleteDepartment = (id) => {
return request.delete(`/b/organization/departments/${id}`);
};
// ==================== 小组管理 ====================
export const getGroups = (params) => {
return request.get('/b/organization/groups', { params: normalizeQuery(params) });
};
export const createGroup = (data) => {
return request.post('/b/organization/groups', data);
};
export const updateGroup = (id, data) => {
return request.patch(`/b/organization/groups/${id}`, data);
};
export const deleteGroup = (id) => {
return request.delete(`/b/organization/groups/${id}`);
};

View File

@ -0,0 +1,29 @@
import request from './request';
export const getPatients = (params) => {
return request.get('/b/patients', { params });
};
export const getPatientDoctors = (params) => {
return request.get('/b/patients/doctors', { params });
};
export const getPatientById = (id) => {
return request.get(`/b/patients/${id}`);
};
export const createPatient = (data) => {
return request.post('/b/patients', data);
};
export const updatePatient = (id, data) => {
return request.patch(`/b/patients/${id}`, data);
};
export const deletePatient = (id) => {
return request.delete(`/b/patients/${id}`);
};
export const getPatientLifecycle = (params) => {
return request.get('/c/patients/lifecycle', { params });
};

View File

@ -0,0 +1,66 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { useUserStore } from '../store/user';
import router from '../router';
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // Use /api as default proxy prefix
timeout: 10000,
});
// Request Interceptor
service.interceptors.request.use(
(config) => {
const userStore = useUserStore();
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response Interceptor
service.interceptors.response.use(
(response) => {
const res = response.data;
// Backend standard format: { code: number, msg: string, data: any }
// Accept code 0 or 2xx as success
if (res.code === 0 || (res.code >= 200 && res.code < 300)) {
return res.data;
} else {
// If backend returns code !== 0/2xx but HTTP status is 200
ElMessage.error(res.msg || '请求失败');
return Promise.reject(new Error(res.msg || 'Error'));
}
},
(error) => {
const userStore = useUserStore();
let message = error.message;
if (error.response) {
const { status, data } = error.response;
// Backend error response format: { code: number, msg: string, data: null }
message = data?.msg || message;
if (status === 401) {
// Token expired or invalid
userStore.logout();
router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`);
ElMessage.error(message || '登录状态已过期,请重新登录');
return Promise.reject(new Error('Unauthorized'));
} else if (status === 403) {
ElMessage.error(message || '没有权限执行该操作');
} else {
ElMessage.error(message || '请求失败');
}
} else {
ElMessage.error(message || '网络连接异常');
}
return Promise.reject(error);
}
);
export default service;

View File

@ -0,0 +1,17 @@
import request from './request';
export const publishTask = (data) => {
return request.post('/b/tasks/publish', data);
};
export const acceptTask = (data) => {
return request.post('/b/tasks/accept', data);
};
export const completeTask = (data) => {
return request.post('/b/tasks/complete', data);
};
export const cancelTask = (data) => {
return request.post('/b/tasks/cancel', data);
};

View File

@ -0,0 +1,60 @@
import request from './request';
const normalizePagedResult = (items, params = {}) => {
const allItems = Array.isArray(items) ? items : [];
const keyword = `${params.keyword ?? ''}`.trim();
const role = params.role;
const hasPaging = params.page != null || params.pageSize != null;
const page = Math.max(1, Number(params.page) || 1);
const pageSize = hasPaging
? Math.max(1, Number(params.pageSize) || 10)
: allItems.length || 10;
let filtered = allItems;
if (role) {
filtered = filtered.filter((item) => item.role === role);
}
if (keyword) {
filtered = filtered.filter((item) => {
const name = `${item.name ?? ''}`;
const phone = `${item.phone ?? ''}`;
return name.includes(keyword) || phone.includes(keyword);
});
}
const total = filtered.length;
const start = (page - 1) * pageSize;
const list = hasPaging ? filtered.slice(start, start + pageSize) : filtered;
return { list, total, page, pageSize };
};
export const getUsers = async (params = {}) => {
const data = await request.get('/users');
if (Array.isArray(data)) {
return normalizePagedResult(data, params);
}
if (data && Array.isArray(data.list)) {
return data;
}
return normalizePagedResult([], params);
};
export const createUser = (data) => {
return request.post('/users', data);
};
export const updateUser = (id, data) => {
return request.patch(`/users/${id}`, data);
};
export const deleteUser = (id) => {
return request.delete(`/users/${id}`);
};
export const assignEngineerHospital = (id, hospitalId) => {
return request.patch(`/b/users/${id}/assign-engineer-hospital`, { hospitalId });
};

View File

@ -0,0 +1,170 @@
<template>
<el-container class="admin-layout">
<el-aside width="200px" class="aside">
<div class="logo">调压通管理后台</div>
<el-menu
:default-active="activeMenu"
class="el-menu-vertical"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/dashboard">
<el-icon><DataLine /></el-icon>
<span>首页</span>
</el-menu-item>
<template v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-sub-menu index="/organization">
<template #title>
<el-icon><OfficeBuilding /></el-icon>
<span>组织架构</span>
</template>
<el-menu-item index="/organization/tree">结构图视图</el-menu-item>
<el-menu-item index="/organization/hospitals">医院管理</el-menu-item>
<el-menu-item index="/organization/departments">科室管理</el-menu-item>
<el-menu-item index="/organization/groups">小组管理</el-menu-item>
</el-sub-menu>
</template>
<template v-else>
<el-menu-item index="/organization/tree">
<el-icon><Share /></el-icon>
<span>组织架构图</span>
</el-menu-item>
<el-menu-item index="/organization/departments">
<el-icon><OfficeBuilding /></el-icon>
<span>科室管理</span>
</el-menu-item>
<el-menu-item index="/organization/groups">
<el-icon><Connection /></el-icon>
<span>小组管理</span>
</el-menu-item>
</template>
<el-menu-item index="/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/tasks">
<el-icon><List /></el-icon>
<span>任务管理</span>
</el-menu-item>
<el-menu-item index="/patients">
<el-icon><Avatar /></el-icon>
<span>患者管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<!-- Breadcrumbs can go here -->
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link user-info">
{{ userStore.userInfo?.name || '用户' }} ({{ userStore.role }})
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '../store/user';
import { DataLine, OfficeBuilding, User, List, Avatar, ArrowDown, Connection, Share } from '@element-plus/icons-vue';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const activeMenu = computed(() => {
return route.path;
});
const handleCommand = (command) => {
if (command === 'logout') {
userStore.logout();
router.push('/login');
}
};
</script>
<style scoped>
.admin-layout {
height: 100vh;
width: 100vw;
}
.aside {
background-color: #304156;
color: white;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
font-size: 20px;
font-weight: bold;
border-bottom: 1px solid #1f2d3d;
}
.el-menu-vertical {
border-right: none;
}
.header {
background-color: white;
border-bottom: 1px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.user-info {
cursor: pointer;
display: flex;
align-items: center;
}
.main {
background-color: #f0f2f5;
padding: 20px;
}
/* fade-transform transition */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all .3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@ -1,4 +1,18 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import './styles/index.scss'
import 'element-plus/dist/index.css'
import App from './App.vue' import App from './App.vue'
import router from './router'
import pinia from './store'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
createApp(App).mount('#app') const app = createApp(App)
// Register Element Plus Icons globally
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,111 @@
import { createRouter, createWebHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { useUserStore } from '../store/user';
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { title: '登录' },
},
{
path: '/',
component: () => import('../layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: '首页', requiresAuth: true },
},
// Placeholder routes for modules
{
path: 'organization/tree',
name: 'OrgTree',
component: () => import('../views/organization/OrgTree.vue'),
meta: { title: '组织架构图', requiresAuth: true },
},
{
path: 'organization/hospitals',
name: 'Hospitals',
component: () => import('../views/organization/Hospitals.vue'),
meta: { title: '医院管理', requiresAuth: true },
},
{
path: 'organization/departments',
name: 'Departments',
component: () => import('../views/organization/Departments.vue'),
meta: { title: '科室管理', requiresAuth: true },
},
{
path: 'organization/groups',
name: 'Groups',
component: () => import('../views/organization/Groups.vue'),
meta: { title: '小组管理', requiresAuth: true },
},
{
path: 'users',
name: 'Users',
component: () => import('../views/users/Users.vue'),
meta: { title: '用户管理', requiresAuth: true },
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('../views/tasks/Tasks.vue'),
meta: { title: '任务管理', requiresAuth: true },
},
{
path: 'patients',
name: 'Patients',
component: () => import('../views/patients/Patients.vue'),
meta: { title: '患者管理', requiresAuth: true },
}
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue'),
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
router.beforeEach(async (to, from) => {
NProgress.start();
document.title = `${to.meta.title || '管理后台'} - 调压通`;
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn;
if (to.meta.requiresAuth && !isLoggedIn) {
return `/login?redirect=${encodeURIComponent(to.fullPath)}`;
} else if (to.path === '/login' && isLoggedIn) {
return '/';
} else {
// Attempt to fetch user info if not present but token exists
if (isLoggedIn && !userStore.userInfo) {
try {
await userStore.fetchUserInfo();
return true;
} catch (error) {
return '/login';
}
} else {
return true;
}
}
});
router.afterEach(() => {
NProgress.done();
});
export default router;

View File

@ -0,0 +1,5 @@
import { createPinia } from 'pinia';
const pinia = createPinia();
export default pinia;

View File

@ -0,0 +1,58 @@
import { defineStore } from 'pinia';
import request from '../api/request';
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('tyt_token') || '',
userInfo: JSON.parse(localStorage.getItem('tyt_user_info') || 'null'),
}),
getters: {
isLoggedIn: (state) => !!state.token,
role: (state) => state.userInfo?.role || '',
},
actions: {
setToken(token) {
this.token = token;
localStorage.setItem('tyt_token', token);
},
setUserInfo(info) {
this.userInfo = info;
localStorage.setItem('tyt_user_info', JSON.stringify(info));
},
async login(loginForm) {
const payload = { ...loginForm };
if (!payload.hospitalId) {
delete payload.hospitalId;
}
const data = await request.post('/auth/login', payload);
// The backend should return the token in data.accessToken
if (data && data.accessToken) {
this.setToken(data.accessToken);
// Backend also returns actor and user info directly in login response
if (data.user) {
this.setUserInfo(data.user);
} else {
await this.fetchUserInfo();
}
return true;
}
return false;
},
async fetchUserInfo() {
try {
const userInfo = await request.get('/auth/me');
this.setUserInfo(userInfo);
return userInfo;
} catch (error) {
this.logout();
throw error;
}
},
logout() {
this.token = '';
this.userInfo = null;
localStorage.removeItem('tyt_token');
localStorage.removeItem('tyt_user_info');
},
},
});

View File

@ -0,0 +1,17 @@
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f0f2f5;
height: 100%;
width: 100%;
}
#app {
height: 100%;
width: 100%;
}
* {
box-sizing: border-box;
}

View File

@ -0,0 +1,172 @@
<template>
<div class="dashboard">
<el-card shadow="never" class="welcome-card">
<h2>欢迎使用调压通管理后台</h2>
<p>当前角色{{ userStore.role || '未登录' }}</p>
</el-card>
<el-card
v-if="isSystemAdmin"
shadow="never"
class="filter-card"
>
<el-form inline>
<el-form-item label="患者统计医院">
<el-select
v-model="selectedHospitalId"
placeholder="请选择医院"
style="width: 280px;"
@change="fetchDashboardData"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="16" v-loading="loading">
<el-col :xs="24" :sm="12" :lg="6" v-for="item in statCards" :key="item.key">
<el-card shadow="hover" class="stat-card">
<div class="stat-title">{{ item.title }}</div>
<div class="stat-value">{{ item.value }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="tips-card">
<template #header>
<span>接口接入说明</span>
</template>
<el-alert
type="info"
:closable="false"
title="任务列表接口当前未提供,任务页已接入发布/接收/完成/取消四个真实接口。"
/>
</el-card>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useUserStore } from '../store/user';
import { getHospitals, getDepartments, getGroups } from '../api/organization';
import { getUsers } from '../api/users';
import { getPatients } from '../api/patients';
const userStore = useUserStore();
const loading = ref(false);
const hospitals = ref([]);
const selectedHospitalId = ref(null);
const stats = ref({
hospitals: '-',
departments: '-',
groups: '-',
users: '-',
patients: '-',
});
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
const canViewOrg = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
const canViewPatients = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes(
userStore.role,
),
);
const statCards = computed(() => [
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
{ key: 'departments', title: '科室总数', value: stats.value.departments },
{ key: 'groups', title: '小组总数', value: stats.value.groups },
{ key: 'users', title: '用户总数', value: stats.value.users },
{ key: 'patients', title: '可见患者数', value: stats.value.patients },
]);
const fetchHospitalsForFilter = async () => {
if (!isSystemAdmin.value) {
return;
}
const res = await getHospitals({ page: 1, pageSize: 100 });
hospitals.value = res.list || [];
if (!selectedHospitalId.value && hospitals.value.length > 0) {
selectedHospitalId.value = hospitals.value[0].id;
}
};
const fetchDashboardData = async () => {
loading.value = true;
try {
if (canViewOrg.value) {
const [hospitalRes, departmentRes, groupRes, usersRes] = await Promise.all([
getHospitals({ page: 1, pageSize: 1 }),
getDepartments({ page: 1, pageSize: 1 }),
getGroups({ page: 1, pageSize: 1 }),
getUsers({ page: 1, pageSize: 1 }),
]);
stats.value.hospitals = hospitalRes.total ?? 0;
stats.value.departments = departmentRes.total ?? 0;
stats.value.groups = groupRes.total ?? 0;
stats.value.users = usersRes.total ?? 0;
}
if (canViewPatients.value) {
const params = {};
if (isSystemAdmin.value && selectedHospitalId.value) {
params.hospitalId = selectedHospitalId.value;
}
if (isSystemAdmin.value && !selectedHospitalId.value) {
stats.value.patients = 0;
} else {
const patientRes = await getPatients(params);
stats.value.patients = Array.isArray(patientRes) ? patientRes.length : 0;
}
}
} finally {
loading.value = false;
}
};
onMounted(async () => {
await fetchHospitalsForFilter();
await fetchDashboardData();
});
</script>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
gap: 16px;
}
.welcome-card h2 {
margin: 0 0 10px;
}
.welcome-card p {
margin: 0;
}
.stat-card {
min-height: 120px;
}
.stat-title {
color: #909399;
margin-bottom: 12px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
color: #303133;
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2 class="login-title">调压通管理后台</h2>
</template>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" @keyup.enter="handleLogin">
<el-form-item prop="phone">
<el-input v-model="loginForm.phone" placeholder="请输入手机号" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password :prefix-icon="Lock" />
</el-form-item>
<el-form-item prop="role">
<el-select v-model="loginForm.role" placeholder="请选择登录角色" style="width: 100%;">
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
<el-option label="科室主任" value="DIRECTOR" />
<el-option label="医疗组长" value="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>
</el-form-item>
<el-form-item>
<el-input-number
v-model="loginForm.hospitalId"
:min="1"
:controls="false"
placeholder="医院 ID多账号场景建议填写"
style="width: 100%;"
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
title="若同一手机号在多个医院有同角色账号,请填写医院 ID。"
style="margin-bottom: 16px;"
/>
<el-form-item>
<el-button type="primary" class="login-btn" :loading="loading" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../store/user';
import { User, Lock } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const loginFormRef = ref(null);
const loading = ref(false);
const loginForm = reactive({
phone: '',
password: '',
role: 'SYSTEM_ADMIN', // Default role
hospitalId: null,
});
const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' }
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
};
const handleLogin = async () => {
if (!loginFormRef.value) return;
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const success = await userStore.login(loginForm);
if (success) {
ElMessage.success('登录成功');
const redirect = route.query.redirect || '/';
router.push(redirect);
} else {
ElMessage.error('登录失败,未获取到登录信息');
}
} catch (error) {
console.error('Login failed', error);
} finally {
loading.value = false;
}
}
});
};
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #f0f2f5;
}
.login-card {
width: 400px;
}
.login-title {
margin: 0;
text-align: center;
color: #303133;
}
.login-btn {
width: 100%;
}
</style>

View File

@ -0,0 +1,6 @@
<template>
<div class="not-found">
<h2>404 - 页面未找到</h2>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
</template>

View File

@ -0,0 +1,328 @@
<template>
<div class="departments-container">
<el-card>
<template #header>
<div class="card-header">
<span>科室管理 {{ currentHospitalName ? `(${currentHospitalName})` : '' }}</span>
<el-button v-if="currentHospitalName" @click="clearHospitalFilter" type="info" size="small">清除医院筛选</el-button>
</div>
</template>
<!-- Header / Actions -->
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentHospitalIdFromQuery">
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="fetchData">
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item label="科室名称">
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus">新增科室</el-button>
</el-form-item>
</el-form>
</div>
<!-- Table -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="科室名称" min-width="150" />
<el-table-column prop="hospital.name" label="所属医院" min-width="200" v-if="userStore.role === 'SYSTEM_ADMIN'" />
<el-table-column label="科室主任" min-width="180">
<template #default="{ row }">
{{ getDirectorDisplay(row.id) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="goToGroups(row)">管理小组</el-button>
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
<!-- Dialog for Create / Edit -->
<el-dialog :title="isEdit ? '编辑科室' : '新增科室'" v-model="dialogVisible" width="500px" @close="resetForm">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;">
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item label="科室名称" prop="name">
<el-input v-model="form.name" placeholder="请输入科室名称" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getDepartments, createDepartment, updateDepartment, deleteDepartment, getHospitals } from '../../api/organization';
import { getUsers } from '../../api/users';
import { useUserStore } from '../../store/user';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
// --- State ---
const loading = ref(false);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const directorNameMap = ref({});
const currentHospitalIdFromQuery = computed(() => {
return route.query.hospitalId ? parseInt(route.query.hospitalId) : null;
});
const currentHospitalName = computed(() => {
return route.query.hospitalName || '';
});
const searchForm = reactive({
keyword: '',
hospitalId: null
});
// Dialog State
const dialogVisible = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const currentId = ref(null);
const form = reactive({
hospitalId: null,
name: ''
});
const rules = computed(() => ({
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }]
}));
// --- Methods ---
const fetchHospitals = async () => {
try {
const res = await getHospitals({ pageSize: 100 }); // Fetch all or sufficiently large number for dropdown
hospitals.value = res.list || [];
} catch (error) {
console.error('Failed to fetch hospitals', error);
}
};
const fetchData = async () => {
loading.value = true;
try {
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
const [departmentRes, directorRes] = await Promise.all([
getDepartments({
page: page.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined,
hospitalId: activeHospitalId || undefined,
}),
getUsers({ role: 'DIRECTOR' }),
]);
const directorMap = {};
(directorRes.list || []).forEach((user) => {
if (!user.departmentId) {
return;
}
if (!directorMap[user.departmentId]) {
directorMap[user.departmentId] = [];
}
directorMap[user.departmentId].push(user.name);
});
directorNameMap.value = directorMap;
tableData.value = departmentRes.list || [];
total.value = departmentRes.total || 0;
} catch (error) {
console.error('Failed to fetch departments', error);
} finally {
loading.value = false;
}
};
const getDirectorDisplay = (departmentId) => {
const directors = directorNameMap.value[departmentId] || [];
return directors.length > 0 ? directors.join('、') : '未设置';
};
const resetSearch = () => {
searchForm.keyword = '';
if (!currentHospitalIdFromQuery.value) {
searchForm.hospitalId = null;
}
page.value = 1;
fetchData();
};
const clearHospitalFilter = () => {
router.replace({ path: '/organization/departments' });
};
const goToGroups = (row) => {
router.push({
path: '/organization/groups',
query: {
departmentId: row.id,
departmentName: row.name,
hospitalId: row.hospitalId
}
});
};
const openCreateDialog = () => {
isEdit.value = false;
currentId.value = null;
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
dialogVisible.value = true;
};
const openEditDialog = (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
form.hospitalId = row.hospitalId;
dialogVisible.value = true;
};
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
form.name = '';
form.hospitalId = null;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (isEdit.value) {
// Some backend update APIs don't allow changing hospitalId, but we'll send it if needed, or just name
await updateDepartment(currentId.value, { name: form.name });
ElMessage.success('更新成功');
} else {
await createDepartment(form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
fetchData();
} catch (error) {
console.error('Submit failed', error);
} finally {
submitLoading.value = false;
}
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除科室 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteDepartment(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
}).catch(() => {});
};
// --- Lifecycle ---
onMounted(async () => {
await fetchHospitals();
fetchData();
});
// Watch for route query changes to refetch if navigating from hospital list again
import { watch } from 'vue';
watch(
() => route.query,
() => {
page.value = 1;
fetchData();
}
);
</script>
<style scoped>
.departments-container {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,397 @@
<template>
<div class="groups-container">
<el-card>
<template #header>
<div class="card-header">
<span>小组管理 {{ currentDepartmentName ? `(${currentDepartmentName})` : '' }}</span>
<el-button v-if="currentDepartmentIdFromQuery" @click="clearDepartmentFilter" type="info" size="small">清除科室筛选</el-button>
</div>
</template>
<!-- Header / Actions -->
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentDepartmentIdFromQuery">
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="handleSearchHospitalChange">
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item label="所属科室" v-if="!currentDepartmentIdFromQuery">
<el-select v-model="searchForm.departmentId" placeholder="请选择科室" clearable @change="fetchData" :disabled="userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId">
<el-option
v-for="d in searchDepartments"
:key="d.id"
:label="d.name"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-form-item label="小组名称">
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus">新增小组</el-button>
</el-form-item>
</el-form>
</div>
<!-- Table -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="小组名称" min-width="150" />
<el-table-column prop="department.name" label="所属科室" min-width="150" />
<el-table-column prop="department.hospital.name" label="所属医院" min-width="150" v-if="userStore.role === 'SYSTEM_ADMIN'" />
<el-table-column label="小组组长" min-width="180">
<template #default="{ row }">
{{ getLeaderDisplay(row.id) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
<!-- Dialog for Create / Edit -->
<el-dialog :title="isEdit ? '编辑小组' : '新增小组'" v-model="dialogVisible" width="500px" @close="resetForm">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;" @change="handleFormHospitalChange">
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item label="所属科室" prop="departmentId">
<el-select v-model="form.departmentId" placeholder="请选择所属科室" style="width: 100%;" :disabled="userStore.role === 'SYSTEM_ADMIN' && !form.hospitalId">
<el-option
v-for="d in formDepartments"
:key="d.id"
:label="d.name"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-form-item label="小组名称" prop="name">
<el-input v-model="form.name" placeholder="请输入小组名称" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getGroups, createGroup, updateGroup, deleteGroup, getHospitals, getDepartments } from '../../api/organization';
import { getUsers } from '../../api/users';
import { useUserStore } from '../../store/user';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
// --- State ---
const loading = ref(false);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const leaderNameMap = ref({});
const searchDepartments = ref([]);
const formDepartments = ref([]);
const currentDepartmentIdFromQuery = computed(() => {
return route.query.departmentId ? parseInt(route.query.departmentId) : null;
});
const currentHospitalIdFromQuery = computed(() => {
return route.query.hospitalId ? parseInt(route.query.hospitalId) : null;
});
const currentDepartmentName = computed(() => {
return route.query.departmentName || '';
});
const searchForm = reactive({
keyword: '',
hospitalId: null,
departmentId: null
});
// Dialog State
const dialogVisible = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const currentId = ref(null);
const form = reactive({
hospitalId: null,
departmentId: null,
name: ''
});
const rules = computed(() => ({
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
departmentId: [{ required: true, message: '请选择所属科室', trigger: 'change' }],
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }]
}));
// --- Methods ---
const fetchHospitals = async () => {
try {
const res = await getHospitals({ pageSize: 100 });
hospitals.value = res.list || [];
} catch (error) {
console.error('Failed to fetch hospitals', error);
}
};
const handleSearchHospitalChange = async (hospitalId) => {
searchForm.departmentId = null;
searchDepartments.value = [];
if (hospitalId) {
try {
const res = await getDepartments({ hospitalId, pageSize: 100 });
searchDepartments.value = res.list || [];
} catch (error) {}
}
fetchData();
};
const handleFormHospitalChange = async (hospitalId) => {
form.departmentId = null;
formDepartments.value = [];
if (hospitalId) {
try {
const res = await getDepartments({ hospitalId, pageSize: 100 });
formDepartments.value = res.list || [];
} catch (error) {}
}
};
const fetchData = async () => {
loading.value = true;
try {
const activeDepartmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId;
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
const [groupRes, leaderRes] = await Promise.all([
getGroups({
page: page.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined,
departmentId: activeDepartmentId || undefined,
hospitalId: activeHospitalId || undefined,
}),
getUsers({ role: 'LEADER' }),
]);
const leaderMap = {};
(leaderRes.list || []).forEach((user) => {
if (!user.groupId) {
return;
}
if (!leaderMap[user.groupId]) {
leaderMap[user.groupId] = [];
}
leaderMap[user.groupId].push(user.name);
});
leaderNameMap.value = leaderMap;
tableData.value = groupRes.list || [];
total.value = groupRes.total || 0;
} catch (error) {
console.error('Failed to fetch groups', error);
} finally {
loading.value = false;
}
};
const getLeaderDisplay = (groupId) => {
const leaders = leaderNameMap.value[groupId] || [];
return leaders.length > 0 ? leaders.join('、') : '未设置';
};
const resetSearch = () => {
searchForm.keyword = '';
if (!currentDepartmentIdFromQuery.value) {
searchForm.hospitalId = null;
searchForm.departmentId = null;
searchDepartments.value = [];
}
page.value = 1;
fetchData();
};
const clearDepartmentFilter = () => {
router.replace({ path: '/organization/groups' });
};
const openCreateDialog = async () => {
isEdit.value = false;
currentId.value = null;
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
if (userStore.role === 'SYSTEM_ADMIN' && form.hospitalId) {
await handleFormHospitalChange(form.hospitalId);
}
form.departmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId || null;
dialogVisible.value = true;
};
const openEditDialog = async (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
// Try to find the hospital ID from the nested relation, assuming row.department.hospitalId exists
// if not, we can rely on row.department?.hospital?.id
const hospitalId = row.department?.hospitalId || row.department?.hospital?.id;
form.hospitalId = hospitalId;
if (hospitalId) {
await handleFormHospitalChange(hospitalId);
}
form.departmentId = row.departmentId;
dialogVisible.value = true;
};
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
form.name = '';
form.hospitalId = null;
form.departmentId = null;
formDepartments.value = [];
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (isEdit.value) {
// Backend patch dto might just accept name. Sending departmentId may not be allowed or needed.
await updateGroup(currentId.value, { name: form.name });
ElMessage.success('更新成功');
} else {
await createGroup({ name: form.name, departmentId: form.departmentId });
ElMessage.success('创建成功');
}
dialogVisible.value = false;
fetchData();
} catch (error) {
console.error('Submit failed', error);
} finally {
submitLoading.value = false;
}
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除小组 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteGroup(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
}).catch(() => {});
};
// --- Lifecycle ---
onMounted(async () => {
if (userStore.role === 'SYSTEM_ADMIN') {
await fetchHospitals();
} else {
// For non-admin, just fetch the departments they have access to
try {
const res = await getDepartments({ pageSize: 100 });
searchDepartments.value = res.list || [];
formDepartments.value = res.list || [];
} catch (e) {}
}
fetchData();
});
watch(
() => route.query,
() => {
page.value = 1;
fetchData();
}
);
</script>
<style scoped>
.groups-container {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<div class="hospitals-container">
<el-card>
<!-- Header / Actions -->
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="医院名称">
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button v-if="userStore.role === 'SYSTEM_ADMIN'" type="success" @click="openCreateDialog" icon="Plus">新增医院</el-button>
</el-form-item>
</el-form>
</div>
<!-- Table -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="医院名称" min-width="200" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="goToDepartments(row)">管理科室</el-button>
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="userStore.role === 'SYSTEM_ADMIN'" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Pagination -->
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
<!-- Dialog for Create / Edit -->
<el-dialog :title="isEdit ? '编辑医院' : '新增医院'" v-model="dialogVisible" width="500px" @close="resetForm">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="医院名称" prop="name">
<el-input v-model="form.name" placeholder="请输入医院名称" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getHospitals, createHospital, updateHospital, deleteHospital } from '../../api/organization';
import { useUserStore } from '../../store/user';
const router = useRouter();
const userStore = useUserStore();
// --- State ---
const loading = ref(false);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const searchForm = reactive({
keyword: ''
});
// Dialog State
const dialogVisible = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const currentId = ref(null);
const form = reactive({
name: ''
});
const rules = {
name: [{ required: true, message: '请输入医院名称', trigger: 'blur' }]
};
// --- Methods ---
const fetchData = async () => {
loading.value = true;
try {
const res = await getHospitals({
page: page.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined
});
tableData.value = res.list || [];
total.value = res.total || 0;
} catch (error) {
console.error('Failed to fetch hospitals', error);
} finally {
loading.value = false;
}
};
const resetSearch = () => {
searchForm.keyword = '';
page.value = 1;
fetchData();
};
const openCreateDialog = () => {
isEdit.value = false;
currentId.value = null;
dialogVisible.value = true;
};
const openEditDialog = (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
dialogVisible.value = true;
};
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
form.name = '';
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (isEdit.value) {
await updateHospital(currentId.value, form);
ElMessage.success('更新成功');
} else {
await createHospital(form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
fetchData();
} catch (error) {
console.error('Submit failed', error);
} finally {
submitLoading.value = false;
}
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除医院 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteHospital(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
}).catch(() => {});
};
const goToDepartments = (row) => {
router.push({
path: '/organization/departments',
query: { hospitalId: row.id, hospitalName: row.name }
});
};
// --- Lifecycle ---
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.hospitals-container {
padding: 0;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,731 @@
<template>
<div class="org-tree-container">
<el-row :gutter="20">
<!-- Left Column: Tree -->
<el-col :span="12">
<el-card shadow="never" class="tree-card">
<template #header>
<div class="card-header">
<span class="header-title"><el-icon><Connection /></el-icon> 组织架构全景图</span>
<el-button type="primary" @click="fetchTreeData" icon="Refresh" round size="small">刷新</el-button>
</div>
</template>
<div class="tree-content" v-loading="loading">
<el-tree
:data="treeData"
:props="defaultProps"
node-key="key"
default-expand-all
:expand-on-click-node="false"
class="beautiful-tree"
@node-click="handleNodeClick"
highlight-current
>
<template #default="{ node, data }">
<div class="custom-tree-node" :class="`node-${data.type}`">
<div class="node-main">
<div class="node-icon-wrapper">
<el-icon v-if="data.type === 'hospital'"><OfficeBuilding /></el-icon>
<el-icon v-else-if="data.type === 'department'"><Filter /></el-icon>
<el-icon v-else-if="data.type === 'group'"><Connection /></el-icon>
<el-icon v-else-if="data.type === 'user'"><UserFilled /></el-icon>
</div>
<div class="node-text">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.type === 'department'" class="node-sub-label">
主任{{ data.directorDisplay || '未设置' }}
</span>
<span v-else-if="data.type === 'group'" class="node-sub-label">
组长{{ data.leaderDisplay || '未设置' }}
</span>
</div>
<el-tag
v-if="data.type === 'user'"
size="small"
:type="getRoleTagType(data.role)"
effect="light"
class="role-tag"
round
>
{{ getRoleName(data.role) }}
</el-tag>
</div>
<div class="node-actions" v-if="data.type !== 'user'">
<el-button
v-if="canAssignOwner && (data.type === 'department' || data.type === 'group')"
type="warning"
link
size="small"
@click.stop="openSetOwnerDialog(data)"
>
{{ data.type === 'department' ? '设主任' : '设组长' }}
</el-button>
<el-button type="info" link size="small" @click.stop="openEditDialog(data)" icon="EditPen">
编辑
</el-button>
<el-button v-if="(data.type === 'hospital' && userStore.role === 'SYSTEM_ADMIN') || data.type !== 'hospital'" type="danger" link size="small" @click.stop="handleDelete(data)" icon="Delete">
删除
</el-button>
</div>
</div>
</template>
</el-tree>
<el-empty v-if="!loading && treeData.length === 0" description="暂无组织架构数据" />
</div>
</el-card>
</el-col>
<!-- Right Column: Details & Actions -->
<el-col :span="12">
<el-card shadow="never" class="detail-card">
<template #header>
<div class="card-header">
<span class="header-title">
<el-icon><Menu /></el-icon>
{{ activeNode ? `下级列表 (${activeNode.name})` : '请在左侧选择节点' }}
</span>
<div v-if="activeNode && activeNode.type !== 'user'" class="header-actions">
<el-button
v-if="activeNode.type === 'hospital' && userStore.role === 'SYSTEM_ADMIN'"
type="primary"
size="small"
icon="Plus"
@click="openCreateDialog('department', activeNode.id)"
>
新增科室
</el-button>
<el-button
v-if="activeNode.type === 'department'"
type="success"
size="small"
icon="Plus"
@click="openCreateDialog('group', activeNode.id)"
>
新增小组
</el-button>
<el-button
v-if="activeNode.type === 'department' || activeNode.type === 'group'"
type="warning"
size="small"
icon="User"
@click="goToAddUser(activeNode)"
>
新增人员
</el-button>
<el-button
v-if="canAssignOwner && (activeNode.type === 'department' || activeNode.type === 'group')"
type="primary"
size="small"
@click="openSetOwnerDialog(activeNode)"
>
{{ activeNode.type === 'department' ? '设置主任' : '设置组长' }}
</el-button>
</div>
</div>
</template>
<div v-if="activeNode && activeNode.type !== 'user'" class="node-detail-panel">
<el-alert
v-if="activeNodeMeta"
:title="activeNodeMeta"
type="info"
:closable="false"
class="mb-12"
/>
<el-table :data="activeNode.children" border stripe style="width: 100%" max-height="600">
<el-table-column prop="name" label="名称" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getNodeTypeTag(row.type)">{{ getTypeName(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="负责人" width="180" align="center">
<template #default="{ row }">
<span v-if="row.type === 'department'">主任{{ row.directorDisplay || '未设置' }}</span>
<span v-else-if="row.type === 'group'">组长{{ row.leaderDisplay || '未设置' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="角色" width="120" align="center">
<template #default="{ row }">
<span v-if="row.type === 'user'">{{ getRoleName(row.role) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canAssignOwner && (row.type === 'department' || row.type === 'group')"
type="warning"
link
size="small"
@click="openSetOwnerDialog(row)"
>
{{ row.type === 'department' ? '设主任' : '设组长' }}
</el-button>
<el-button type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="row.type !== 'hospital' || userStore.role === 'SYSTEM_ADMIN'" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else-if="activeNode && activeNode.type === 'user'" description="人员节点无下级列表" />
<el-empty v-else description="点击左侧架构树查看下级列表" />
</el-card>
</el-col>
</el-row>
<!-- Dialog for Create / Edit -->
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="450px" @close="resetForm" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" @submit.prevent>
<el-form-item :label="formLabel" prop="name">
<el-input v-model="form.name" :placeholder="`请输入${formLabel}`" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="ownerDialogTitle"
v-model="ownerDialogVisible"
width="500px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item :label="ownerDialogLabel">
<el-select
v-model="selectedOwnerUserId"
filterable
placeholder="请选择人员"
style="width: 100%;"
>
<el-option
v-for="user in ownerCandidates"
:key="user.id"
:label="`${user.name}${getRoleName(user.role)}`"
:value="user.id"
/>
</el-select>
</el-form-item>
</el-form>
<el-alert
type="info"
:closable="false"
title="仅主任/组长角色可分配;设置后不会自动取消其他同级负责人,请按需在用户管理页调整。"
/>
<template #footer>
<div class="dialog-footer">
<el-button @click="ownerDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="ownerSubmitLoading" @click="handleSetOwner">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getHospitals, getDepartments, getGroups, createDepartment, updateDepartment, deleteDepartment, createGroup, updateGroup, deleteGroup, updateHospital, deleteHospital } from '../../api/organization';
import { getUsers, updateUser } from '../../api/users';
import { useUserStore } from '../../store/user';
import { OfficeBuilding, Filter, Connection, UserFilled, Refresh, Plus, EditPen, Delete, Menu, User } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false);
const treeData = ref([]);
const activeNode = ref(null);
const allUsers = ref([]);
const ownerCandidates = ref([]);
const ownerDialogVisible = ref(false);
const ownerSubmitLoading = ref(false);
const ownerTargetNode = ref(null);
const selectedOwnerUserId = ref(null);
const defaultProps = {
children: 'children',
label: 'name',
};
const roleMap = {
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '医疗组长',
DOCTOR: '医生',
ENGINEER: '工程师'
};
const getRoleName = (role) => roleMap[role] || role;
const getRoleTagType = (role) => {
if (role === 'HOSPITAL_ADMIN') return 'danger';
if (role === 'DIRECTOR') return 'warning';
if (role === 'LEADER') return 'success';
if (role === 'DOCTOR') return 'primary';
return 'info';
};
const getTypeName = (type) => {
const map = { hospital: '医院', department: '科室', group: '小组', user: '人员' };
return map[type] || type;
};
const getNodeTypeTag = (type) => {
const map = { hospital: 'primary', department: 'success', group: 'warning', user: 'info' };
return map[type] || 'info';
};
const canAssignOwner = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
const activeNodeMeta = computed(() => {
if (!activeNode.value) {
return '';
}
if (activeNode.value.type === 'department') {
return `当前科室主任:${activeNode.value.directorDisplay || '未设置'}`;
}
if (activeNode.value.type === 'group') {
return `当前小组组长:${activeNode.value.leaderDisplay || '未设置'}`;
}
return '';
});
const ownerDialogTitle = computed(() => {
if (!ownerTargetNode.value) {
return '设置负责人';
}
return ownerTargetNode.value.type === 'department'
? `设置科室主任(${ownerTargetNode.value.name}`
: `设置小组组长(${ownerTargetNode.value.name}`;
});
const ownerDialogLabel = computed(() => {
if (!ownerTargetNode.value) {
return '负责人';
}
return ownerTargetNode.value.type === 'department' ? '科室主任' : '小组组长';
});
const fetchTreeData = async () => {
loading.value = true;
activeNode.value = null; // Reset active node on refresh
try {
const hospRes = await getHospitals({ pageSize: 100 });
const hospitals = hospRes.list || [];
const [deptRes, groupRes, userRes] = await Promise.all([
getDepartments({ pageSize: 100 }),
getGroups({ pageSize: 100 }),
getUsers()
]);
const departments = deptRes.list || [];
const groups = groupRes.list || [];
const users = userRes.list || [];
allUsers.value = users;
const directorNameMap = {};
const leaderNameMap = {};
users.forEach((user) => {
if (user.role === 'DIRECTOR' && user.departmentId) {
if (!directorNameMap[user.departmentId]) {
directorNameMap[user.departmentId] = [];
}
directorNameMap[user.departmentId].push(user.name);
}
if (user.role === 'LEADER' && user.groupId) {
if (!leaderNameMap[user.groupId]) {
leaderNameMap[user.groupId] = [];
}
leaderNameMap[user.groupId].push(user.name);
}
});
const tree = hospitals.map(h => {
const hDepts = departments.filter(d => d.hospitalId === h.id);
const deptNodes = hDepts.map(d => {
const dGroups = groups.filter(g => g.departmentId === d.id);
const directorDisplay =
(directorNameMap[d.id] || []).join('、') || '未设置';
const groupNodes = dGroups.map(g => {
const gUsers = users.filter(u => u.groupId === g.id);
const leaderDisplay =
(leaderNameMap[g.id] || []).join('、') || '未设置';
const userNodes = gUsers.map(u => ({
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
hospitalId: h.id, departmentId: d.id, groupId: g.id
}));
return {
key: `g_${g.id}`, id: g.id, name: g.name, type: 'group',
departmentId: d.id, hospitalId: h.id, leaderDisplay, children: userNodes
};
});
const dUsers = users.filter(u => u.departmentId === d.id && !u.groupId);
const dUserNodes = dUsers.map(u => ({
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
hospitalId: h.id, departmentId: d.id
}));
return {
key: `d_${d.id}`, id: d.id, name: d.name, type: 'department',
hospitalId: h.id, directorDisplay, children: [...groupNodes, ...dUserNodes]
};
});
const hUsers = users.filter(u => u.hospitalId === h.id && !u.departmentId);
const hUserNodes = hUsers.map(u => ({
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
hospitalId: h.id
}));
return {
key: `h_${h.id}`, id: h.id, name: h.name, type: 'hospital', children: [...deptNodes, ...hUserNodes]
};
});
treeData.value = tree;
} catch (error) {
console.error('Failed to fetch tree data', error);
ElMessage.error('获取组织架构树失败');
} finally {
loading.value = false;
}
};
const handleNodeClick = (data) => {
activeNode.value = data;
};
const openSetOwnerDialog = (node) => {
if (!canAssignOwner.value) {
return;
}
if (node.type !== 'department' && node.type !== 'group') {
return;
}
ownerTargetNode.value = node;
selectedOwnerUserId.value = null;
if (node.type === 'department') {
ownerCandidates.value = allUsers.value.filter((user) =>
user.hospitalId === node.hospitalId
&& user.departmentId === node.id
&& ['DIRECTOR', 'LEADER'].includes(user.role),
);
} else {
ownerCandidates.value = allUsers.value.filter((user) =>
user.hospitalId === node.hospitalId
&& user.departmentId === node.departmentId
&& user.groupId === node.id
&& user.role === 'LEADER',
);
}
if (ownerCandidates.value.length === 0) {
ElMessage.warning('当前节点下没有可设置候选(仅支持主任/组长角色)');
return;
}
const currentOwner = ownerCandidates.value.find((user) =>
node.type === 'department' ? user.role === 'DIRECTOR' : user.role === 'LEADER',
);
selectedOwnerUserId.value = currentOwner?.id ?? ownerCandidates.value[0].id;
ownerDialogVisible.value = true;
};
const handleSetOwner = async () => {
if (!ownerTargetNode.value || !selectedOwnerUserId.value) {
ElMessage.warning('请选择人员');
return;
}
const targetUser = ownerCandidates.value.find(
(user) => user.id === selectedOwnerUserId.value,
);
if (!targetUser) {
ElMessage.warning('候选人员不存在');
return;
}
const isDepartment = ownerTargetNode.value.type === 'department';
const payload = isDepartment
? {
role: 'DIRECTOR',
// DOCTOR /
//
groupId: null,
}
: {
role: 'LEADER',
};
ownerSubmitLoading.value = true;
try {
await updateUser(targetUser.id, payload);
ElMessage.success(isDepartment ? '设置主任成功' : '设置组长成功');
ownerDialogVisible.value = false;
await fetchTreeData();
} finally {
ownerSubmitLoading.value = false;
}
};
const goToAddUser = (nodeData) => {
// We navigate to the Users list page. In a full implementation, you could
// pass query params to pre-fill a creation form or open a dialog directly.
router.push({
path: '/users',
query: {
action: 'create',
hospitalId: nodeData.hospitalId,
departmentId: nodeData.type === 'department' ? nodeData.id : nodeData.departmentId,
}
});
};
// --- Dialog Logic ---
const dialogVisible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const dialogType = ref('');
const dialogMode = ref('');
const parentId = ref(null);
const currentId = ref(null);
const form = reactive({ name: '' });
const rules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] };
const dialogTitle = computed(() => {
const typeName = dialogType.value === 'hospital' ? '医院' : (dialogType.value === 'department' ? '科室' : '小组');
return dialogMode.value === 'create' ? `新增${typeName}` : `编辑${typeName}`;
});
const formLabel = computed(() => dialogType.value === 'hospital' ? '医院名称' : (dialogType.value === 'department' ? '科室名称' : '小组名称'));
const openCreateDialog = (type, pId) => { dialogType.value = type; dialogMode.value = 'create'; parentId.value = pId; currentId.value = null; dialogVisible.value = true; };
const openEditDialog = (data) => { dialogType.value = data.type; dialogMode.value = 'edit'; currentId.value = data.id; form.name = data.name; dialogVisible.value = true; };
const resetForm = () => { if (formRef.value) formRef.value.resetFields(); form.name = ''; };
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (dialogMode.value === 'create') {
if (dialogType.value === 'department') await createDepartment({ name: form.name, hospitalId: parentId.value });
else if (dialogType.value === 'group') await createGroup({ name: form.name, departmentId: parentId.value });
ElMessage.success('创建成功');
} else {
if (dialogType.value === 'hospital') await updateHospital(currentId.value, { name: form.name });
else if (dialogType.value === 'department') await updateDepartment(currentId.value, { name: form.name });
else if (dialogType.value === 'group') await updateGroup(currentId.value, { name: form.name });
ElMessage.success('更新成功');
// Update activeNode locally if it's the one edited
if (activeNode.value && activeNode.value.id === currentId.value && activeNode.value.type === dialogType.value) {
activeNode.value.name = form.name;
}
}
dialogVisible.value = false;
fetchTreeData();
} catch (error) { console.error(error); } finally { submitLoading.value = false; }
}
});
};
const handleDelete = (data) => {
const typeName = data.type === 'hospital' ? '医院' : (data.type === 'department' ? '科室' : '小组');
ElMessageBox.confirm(`确定要删除${typeName} "${data.name}" 吗?`, '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
if (data.type === 'hospital') await deleteHospital(data.id);
else if (data.type === 'department') await deleteDepartment(data.id);
else if (data.type === 'group') await deleteGroup(data.id);
ElMessage.success('删除成功');
if (activeNode.value && activeNode.value.key === data.key) {
activeNode.value = null; // Clear active node if deleted
}
fetchTreeData();
} catch (error) { console.error(error); }
}).catch(() => {});
};
onMounted(() => { fetchTreeData(); });
</script>
<style scoped>
.org-tree-container {
padding: 20px;
}
.tree-card, .detail-card {
border-radius: 8px;
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
color: #303133;
}
.tree-content {
flex: 1;
overflow-y: auto;
padding: 10px 0;
}
.node-detail-panel {
padding: 10px;
}
.action-title {
margin-top: 20px;
margin-bottom: 15px;
color: #606266;
border-bottom: 1px solid #ebeef5;
padding-bottom: 10px;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.mb-4 {
margin-bottom: 16px;
}
/* Beautiful Tree Styling */
.beautiful-tree {
background: transparent;
}
:deep(.el-tree-node__content) {
height: auto;
padding: 8px 0;
margin-bottom: 4px;
border-radius: 6px;
transition: all 0.3s;
}
:deep(.el-tree-node__content:hover) {
background-color: #f5f7fa;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: #e6f1fc;
}
/* Node specific styles */
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.node-main {
display: flex;
align-items: center;
}
.node-text {
display: flex;
flex-direction: column;
}
.node-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-right: 8px;
font-size: 16px;
}
.node-hospital .node-icon-wrapper { color: #409EFF; }
.node-department .node-icon-wrapper { color: #67C23A; }
.node-group .node-icon-wrapper { color: #E6A23C; }
.node-user .node-icon-wrapper { color: #909399; font-size: 14px; }
.node-label {
font-size: 14px;
color: #303133;
}
.node-user .node-label {
color: #606266;
}
.node-sub-label {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.role-tag {
margin-left: 10px;
}
.mb-12 {
margin-bottom: 12px;
}
/* Hover Actions */
.node-actions {
opacity: 0;
transform: translateX(10px);
transition: all 0.2s ease;
}
:deep(.el-tree-node__content:hover) .node-actions {
opacity: 1;
transform: translateX(0);
}
</style>

View File

@ -0,0 +1,486 @@
<template>
<div class="patients-container">
<el-card>
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item
label="目标医院"
v-if="userStore.role === 'SYSTEM_ADMIN'"
>
<el-select
v-model="searchForm.hospitalId"
placeholder="系统管理员必须选择医院"
clearable
style="width: 240px;"
@change="handleSearchHospitalChange"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="患者关键词">
<el-input
v-model="searchForm.keyword"
placeholder="姓名或手机号"
clearable
/>
</el-form-item>
<el-form-item label="设备 SN">
<el-input
v-model="searchForm.deviceSn"
placeholder="按设备 SN 过滤"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search">查询</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus">新增患者</el-button>
</el-form-item>
</el-form>
</div>
<el-alert
v-if="userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId"
type="warning"
:closable="false"
title="系统管理员查询患者时必须先选择医院。"
style="margin-bottom: 16px;"
/>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="idCardHash" label="证件哈希" min-width="200" />
<el-table-column label="归属医院" min-width="160">
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="归属医生" min-width="140">
<template #default="{ row }">
{{ row.doctor?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="设备数" width="100" align="center">
<template #default="{ row }">
{{ row.devices?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="设备 SN" min-width="220">
<template #default="{ row }">
{{ formatDeviceSn(row.devices) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openRecordDialog(row)">
详情
</el-button>
<el-button size="small" @click="openEditDialog(row)">
编辑
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="applyFiltersAndPagination"
@current-change="applyFiltersAndPagination"
/>
</div>
</el-card>
<el-dialog
:title="isEdit ? '编辑患者' : '新增患者'"
v-model="dialogVisible"
width="560px"
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
<el-form-item label="患者姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入患者姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="证件哈希" prop="idCardHash">
<el-input v-model="form.idCardHash" placeholder="请输入证件哈希" />
</el-form-item>
<el-form-item label="归属医生" prop="doctorId">
<el-select
v-model="form.doctorId"
filterable
placeholder="请选择归属医生"
style="width: 100%;"
:disabled="userStore.role === 'DOCTOR'"
>
<el-option
v-for="doctor in doctorOptions"
:key="doctor.id"
:label="`${doctor.name}${doctor.phone}`"
:value="doctor.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</div>
</template>
</el-dialog>
<el-dialog title="调压记录详情" v-model="recordDialogVisible" width="860px">
<el-descriptions :column="4" border class="mb-16">
<el-descriptions-item label="患者">{{ currentPatientName || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ recordSummary.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="证件哈希">{{ recordSummary.idCardHash || '-' }}</el-descriptions-item>
<el-descriptions-item label="记录数">{{ recordList.length }}</el-descriptions-item>
</el-descriptions>
<el-table :data="recordList" v-loading="recordLoading" border stripe max-height="520">
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ new Date(row.occurredAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="设备 SN" min-width="150">
<template #default="{ row }">
{{ row.device?.snCode || '-' }}
</template>
</el-table-column>
<el-table-column label="任务状态" width="120">
<template #default="{ row }">
{{ row.task?.status || '-' }}
</template>
</el-table-column>
<el-table-column label="压力变更" min-width="140">
<template #default="{ row }">
{{ row.taskItem?.oldPressure ?? '-' }} -> {{ row.taskItem?.targetPressure ?? '-' }}
</template>
</el-table-column>
<el-table-column label="医院" min-width="140">
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!recordLoading && recordList.length === 0"
description="暂无调压记录"
/>
<template #footer>
<div class="dialog-footer">
<el-button @click="recordDialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
getPatients,
getPatientDoctors,
getPatientById,
createPatient,
updatePatient,
deletePatient,
getPatientLifecycle,
} from '../../api/patients';
import { getHospitals } from '../../api/organization';
import { useUserStore } from '../../store/user';
const userStore = useUserStore();
const loading = ref(false);
const allPatients = ref([]);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const searchForm = reactive({
keyword: '',
deviceSn: '',
hospitalId: null,
});
const dialogVisible = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const currentEditId = ref(null);
const doctorOptions = ref([]);
const form = reactive({
name: '',
phone: '',
idCardHash: '',
doctorId: null,
});
const rules = {
name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
idCardHash: [{ required: true, message: '请输入证件哈希', trigger: 'blur' }],
doctorId: [{ required: true, message: '请选择归属医生', trigger: 'change' }],
};
const recordDialogVisible = ref(false);
const recordLoading = ref(false);
const currentPatientName = ref('');
const recordSummary = reactive({
phone: '',
idCardHash: '',
});
const recordList = ref([]);
const formatDeviceSn = (devices = []) => {
if (!Array.isArray(devices) || devices.length === 0) {
return '-';
}
return devices.map((item) => item.snCode).join('');
};
const applyFiltersAndPagination = () => {
const keyword = searchForm.keyword.trim();
const deviceSn = searchForm.deviceSn.trim();
const filtered = allPatients.value.filter((patient) => {
const hitKeyword = !keyword
|| patient.name?.includes(keyword)
|| patient.phone?.includes(keyword);
const hitDevice = !deviceSn
|| (patient.devices || []).some((device) => device.snCode?.includes(deviceSn));
return hitKeyword && hitDevice;
});
total.value = filtered.length;
const start = (page.value - 1) * pageSize.value;
tableData.value = filtered.slice(start, start + pageSize.value);
};
const fetchHospitalsForAdmin = async () => {
if (userStore.role !== 'SYSTEM_ADMIN') {
return;
}
const res = await getHospitals({ page: 1, pageSize: 100 });
hospitals.value = res.list || [];
if (!searchForm.hospitalId && hospitals.value.length > 0) {
searchForm.hospitalId = hospitals.value[0].id;
}
};
const fetchDoctorOptions = async () => {
const params = {};
if (userStore.role === 'SYSTEM_ADMIN') {
if (!searchForm.hospitalId) {
doctorOptions.value = [];
return;
}
params.hospitalId = searchForm.hospitalId;
}
const res = await getPatientDoctors(params);
doctorOptions.value = Array.isArray(res) ? res : [];
};
const fetchData = async () => {
if (userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId) {
allPatients.value = [];
tableData.value = [];
total.value = 0;
return;
}
loading.value = true;
try {
const params = {};
if (userStore.role === 'SYSTEM_ADMIN') {
params.hospitalId = searchForm.hospitalId;
}
const res = await getPatients(params);
allPatients.value = Array.isArray(res) ? res : [];
applyFiltersAndPagination();
} finally {
loading.value = false;
}
};
const handleSearchHospitalChange = async () => {
page.value = 1;
await fetchDoctorOptions();
await fetchData();
};
const handleSearch = () => {
page.value = 1;
fetchData();
};
const resetSearch = () => {
searchForm.keyword = '';
searchForm.deviceSn = '';
page.value = 1;
fetchData();
};
const resetForm = () => {
formRef.value?.resetFields();
form.name = '';
form.phone = '';
form.idCardHash = '';
form.doctorId = null;
currentEditId.value = null;
};
const openCreateDialog = async () => {
isEdit.value = false;
resetForm();
await fetchDoctorOptions();
if (userStore.role === 'DOCTOR') {
form.doctorId = userStore.userInfo?.id || null;
}
dialogVisible.value = true;
};
const openEditDialog = async (row) => {
isEdit.value = true;
await fetchDoctorOptions();
const detail = await getPatientById(row.id);
currentEditId.value = detail.id;
form.name = detail.name;
form.phone = detail.phone;
form.idCardHash = detail.idCardHash;
form.doctorId = detail.doctorId;
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitLoading.value = true;
try {
const payload = {
name: form.name,
phone: form.phone,
idCardHash: form.idCardHash,
doctorId: form.doctorId,
};
if (isEdit.value) {
await updatePatient(currentEditId.value, payload);
ElMessage.success('更新成功');
} else {
await createPatient(payload);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
await fetchData();
} finally {
submitLoading.value = false;
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除患者 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(async () => {
await deletePatient(row.id);
ElMessage.success('删除成功');
await fetchData();
})
.catch(() => {});
};
const openRecordDialog = async (row) => {
if (!row.phone || !row.idCardHash) {
ElMessage.warning('缺少 phone 或 idCardHash无法查询调压记录');
return;
}
recordDialogVisible.value = true;
recordLoading.value = true;
currentPatientName.value = row.name || '';
recordSummary.phone = '';
recordSummary.idCardHash = '';
recordList.value = [];
try {
const res = await getPatientLifecycle({
phone: row.phone,
idCardHash: row.idCardHash,
});
recordSummary.phone = res.phone || '';
recordSummary.idCardHash = res.idCardHash || '';
const fullList = Array.isArray(res.lifecycle) ? res.lifecycle : [];
recordList.value = fullList.filter((item) => item.patient?.id === row.id);
} finally {
recordLoading.value = false;
}
};
onMounted(async () => {
await fetchHospitalsForAdmin();
await fetchDoctorOptions();
await fetchData();
});
</script>
<style scoped>
.patients-container {
padding: 0;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.mb-16 {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,358 @@
<template>
<div class="tasks-container">
<el-row :gutter="16">
<el-col :xs="24" :lg="12">
<el-card v-if="canPublishOrCancel" shadow="never">
<template #header>
<div class="card-header">
<span>发布任务DOCTOR</span>
</div>
</template>
<el-form :model="publishForm" label-width="100px">
<el-form-item label="工程师 ID">
<el-input-number
v-model="publishForm.engineerId"
:min="1"
:controls="false"
placeholder="可选"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="调压明细">
<div class="items-panel">
<div
v-for="(item, index) in publishForm.items"
:key="index"
class="item-row"
>
<el-input-number
v-model="item.deviceId"
:min="1"
:controls="false"
placeholder="设备 ID"
style="width: 48%;"
/>
<el-input-number
v-model="item.targetPressure"
:min="1"
:controls="false"
placeholder="目标压力"
style="width: 48%;"
/>
<el-button
type="danger"
plain
@click="removeItem(index)"
:disabled="publishForm.items.length === 1"
>
删除
</el-button>
</div>
<el-button type="primary" plain @click="addItem">
新增明细
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading.publish"
@click="handlePublish"
>
发布任务
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="canPublishOrCancel" shadow="never" class="mt-16">
<template #header>
<span>取消任务DOCTOR</span>
</template>
<el-form :model="cancelForm" label-width="100px">
<el-form-item label="任务 ID">
<el-input-number
v-model="cancelForm.taskId"
:min="1"
:controls="false"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="取消原因">
<el-input
v-model="cancelForm.reason"
maxlength="100"
show-word-limit
placeholder="可选,不填则使用默认原因"
/>
</el-form-item>
<el-form-item>
<el-button
type="danger"
:loading="loading.cancel"
@click="handleCancel"
>
取消任务
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card v-if="canAcceptOrComplete" shadow="never">
<template #header>
<span>接收任务ENGINEER</span>
</template>
<el-form :model="acceptForm" label-width="100px">
<el-form-item label="任务 ID">
<el-input-number
v-model="acceptForm.taskId"
:min="1"
:controls="false"
style="width: 100%;"
/>
</el-form-item>
<el-form-item>
<el-button
type="success"
:loading="loading.accept"
@click="handleAccept"
>
接收任务
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="canAcceptOrComplete" shadow="never" class="mt-16">
<template #header>
<span>完成任务ENGINEER</span>
</template>
<el-form :model="completeForm" label-width="100px">
<el-form-item label="任务 ID">
<el-input-number
v-model="completeForm.taskId"
:min="1"
:controls="false"
style="width: 100%;"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading.complete"
@click="handleComplete"
>
完成任务
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt-16">
<template #header>
<span>最近操作结果</span>
</template>
<el-empty v-if="results.length === 0" description="暂无操作记录" />
<el-timeline v-else>
<el-timeline-item
v-for="item in results"
:key="item.key"
:timestamp="item.time"
>
<strong>{{ item.action }}</strong>
<p>任务 ID{{ item.taskId }}</p>
<p>状态{{ item.status }}</p>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
</el-row>
<el-alert
v-if="!canPublishOrCancel && !canAcceptOrComplete"
type="warning"
:closable="false"
title="当前角色没有任务状态流转权限。仅医生可发布/取消,工程师可接收/完成。"
class="mt-16"
/>
</div>
</template>
<script setup>
import { reactive, ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { useUserStore } from '../../store/user';
import {
publishTask,
acceptTask,
completeTask,
cancelTask,
} from '../../api/tasks';
const userStore = useUserStore();
const canPublishOrCancel = computed(() => userStore.role === 'DOCTOR');
const canAcceptOrComplete = computed(() => userStore.role === 'ENGINEER');
const loading = reactive({
publish: false,
accept: false,
complete: false,
cancel: false,
});
const publishForm = reactive({
engineerId: null,
items: [
{
deviceId: null,
targetPressure: null,
},
],
});
const acceptForm = reactive({
taskId: null,
});
const completeForm = reactive({
taskId: null,
});
const cancelForm = reactive({
taskId: null,
reason: '',
});
const results = ref([]);
const addItem = () => {
publishForm.items.push({ deviceId: null, targetPressure: null });
};
const removeItem = (index) => {
publishForm.items.splice(index, 1);
};
const addResult = (action, data) => {
results.value.unshift({
key: `${action}-${Date.now()}-${Math.random()}`,
action,
taskId: data?.id ?? data?.taskId ?? '-',
status: data?.status ?? '未知',
time: new Date().toLocaleString(),
});
if (results.value.length > 10) {
results.value = results.value.slice(0, 10);
}
};
const handlePublish = async () => {
const validItems = publishForm.items
.map((item) => ({
deviceId: Number(item.deviceId),
targetPressure: Number(item.targetPressure),
}))
.filter((item) => Number.isInteger(item.deviceId) && Number.isInteger(item.targetPressure));
if (validItems.length === 0) {
ElMessage.warning('请至少填写一条有效调压明细');
return;
}
loading.publish = true;
try {
const payload = {
items: validItems,
};
if (Number.isInteger(publishForm.engineerId)) {
payload.engineerId = Number(publishForm.engineerId);
}
const data = await publishTask(payload);
ElMessage.success('发布任务成功');
addResult('发布任务', data);
} finally {
loading.publish = false;
}
};
const handleAccept = async () => {
if (!Number.isInteger(acceptForm.taskId)) {
ElMessage.warning('请输入有效的任务 ID');
return;
}
loading.accept = true;
try {
const data = await acceptTask({ taskId: Number(acceptForm.taskId) });
ElMessage.success('接收任务成功');
addResult('接收任务', data);
} finally {
loading.accept = false;
}
};
const handleComplete = async () => {
if (!Number.isInteger(completeForm.taskId)) {
ElMessage.warning('请输入有效的任务 ID');
return;
}
loading.complete = true;
try {
const data = await completeTask({ taskId: Number(completeForm.taskId) });
ElMessage.success('完成任务成功');
addResult('完成任务', data);
} finally {
loading.complete = false;
}
};
const handleCancel = async () => {
if (!Number.isInteger(cancelForm.taskId)) {
ElMessage.warning('请输入有效的任务 ID');
return;
}
loading.cancel = true;
try {
const data = await cancelTask({
taskId: Number(cancelForm.taskId),
reason: cancelForm.reason || '后台手动取消',
});
ElMessage.success('取消任务成功');
addResult('取消任务', data);
} finally {
loading.cancel = false;
}
};
</script>
<style scoped>
.tasks-container {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.items-panel {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
}
.mt-16 {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,706 @@
<template>
<div class="users-container">
<el-card>
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="姓名或手机号"
clearable
/>
</el-form-item>
<el-form-item label="角色">
<el-select
v-model="searchForm.role"
placeholder="请选择角色"
clearable
>
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
<el-option label="科室主任" value="DIRECTOR" />
<el-option label="医疗组长" value="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search">
查询
</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus">
新增用户
</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="150" />
<el-table-column label="角色" min-width="120">
<template #default="{ row }">
<el-tag :type="getRoleTagType(row.role)">
{{ getRoleName(row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属医院" min-width="150">
<template #default="{ row }">
{{ resolveHospitalName(row.hospitalId) }}
</template>
</el-table-column>
<el-table-column label="所属科室" min-width="150">
<template #default="{ row }">
{{ resolveDepartmentName(row.departmentId) }}
</template>
</el-table-column>
<el-table-column label="所属小组" min-width="150">
<template #default="{ row }">
{{ resolveGroupName(row.groupId) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.role === 'ENGINEER' && userStore.role === 'SYSTEM_ADMIN'"
size="small"
type="warning"
@click="openAssignDialog(row)"
>
分配医院
</el-button>
<el-button size="small" type="primary" @click="openEditDialog(row)">
编辑
</el-button>
<el-button
v-if="userStore.role === 'SYSTEM_ADMIN'"
size="small"
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
<el-dialog
:title="isEdit ? '编辑用户' : '新增用户'"
v-model="dialogVisible"
width="620px"
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入密码(至少 8 位)"
/>
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" placeholder="请选择角色" style="width: 100%;">
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
<el-option label="科室主任" value="DIRECTOR" />
<el-option label="医疗组长" value="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>
</el-form-item>
<el-form-item label="所属医院" prop="hospitalId" v-if="needHospital">
<el-select
v-model="form.hospitalId"
placeholder="请选择医院"
style="width: 100%;"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="所属科室" prop="departmentId" v-if="needDepartment">
<el-select
v-model="form.departmentId"
placeholder="请选择科室"
style="width: 100%;"
>
<el-option
v-for="department in formDepartments"
:key="department.id"
:label="department.name"
:value="department.id"
/>
</el-select>
</el-form-item>
<el-form-item label="所属小组" prop="groupId" v-if="needGroup">
<el-select
v-model="form.groupId"
placeholder="请选择小组"
style="width: 100%;"
>
<el-option
v-for="group in formGroups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
title="分配工程师医院"
v-model="assignDialogVisible"
width="500px"
>
<el-form label-width="100px">
<el-form-item label="工程师">
<el-input :value="currentAssignUser?.name" disabled />
</el-form-item>
<el-form-item label="医院" required>
<el-select
v-model="assignHospitalId"
placeholder="请选择医院"
style="width: 100%;"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleAssignSubmit" :loading="submitLoading">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
getUsers,
createUser,
updateUser,
deleteUser,
assignEngineerHospital,
} from '../../api/users';
import {
getHospitals,
getDepartments,
getGroups,
} from '../../api/organization';
import { useUserStore } from '../../store/user';
const route = useRoute();
const userStore = useUserStore();
const loading = ref(false);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const departments = ref([]);
const groups = ref([]);
const searchForm = reactive({
keyword: '',
role: '',
});
const dialogVisible = ref(false);
const isEdit = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const currentId = ref(null);
const formDepartments = ref([]);
const formGroups = ref([]);
const form = reactive({
name: '',
phone: '',
password: '',
role: '',
hospitalId: null,
departmentId: null,
groupId: null,
});
const assignDialogVisible = ref(false);
const currentAssignUser = ref(null);
const assignHospitalId = ref(null);
const needHospital = computed(() => form.role && form.role !== 'SYSTEM_ADMIN');
const needDepartment = computed(() =>
['DIRECTOR', 'LEADER', 'DOCTOR'].includes(form.role),
);
const needGroup = computed(() =>
['LEADER', 'DOCTOR'].includes(form.role),
);
const rules = computed(() => ({
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
password: isEdit.value
? []
: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' },
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
hospitalId: needHospital.value
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
: [],
departmentId: needDepartment.value
? [{ required: true, message: '请选择所属科室', trigger: 'change' }]
: [],
groupId: needGroup.value
? [{ required: true, message: '请选择所属小组', trigger: 'change' }]
: [],
}));
const roleMap = {
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '医疗组长',
DOCTOR: '医生',
ENGINEER: '工程师',
};
const getRoleName = (role) => roleMap[role] || role;
const getRoleTagType = (role) => {
if (role === 'SYSTEM_ADMIN') return 'danger';
if (role === 'HOSPITAL_ADMIN') return 'warning';
if (role === 'ENGINEER') return 'success';
return 'info';
};
const hospitalMap = computed(() =>
new Map(hospitals.value.map((item) => [item.id, item.name])),
);
const departmentMap = computed(() =>
new Map(departments.value.map((item) => [item.id, item.name])),
);
const groupMap = computed(() =>
new Map(groups.value.map((item) => [item.id, item.name])),
);
const resolveHospitalName = (id) => {
if (!id) return '-';
return hospitalMap.value.get(id) || `#${id}`;
};
const resolveDepartmentName = (id) => {
if (!id) return '-';
return departmentMap.value.get(id) || `#${id}`;
};
const resolveGroupName = (id) => {
if (!id) return '-';
return groupMap.value.get(id) || `#${id}`;
};
const fetchCommonData = async () => {
const [hospitalRes, departmentRes, groupRes] = await Promise.all([
getHospitals({ page: 1, pageSize: 100 }),
getDepartments({ page: 1, pageSize: 100 }),
getGroups({ page: 1, pageSize: 100 }),
]);
hospitals.value = hospitalRes.list || [];
departments.value = departmentRes.list || [];
groups.value = groupRes.list || [];
};
const fetchDepartmentsForForm = async (hospitalId) => {
form.departmentId = null;
form.groupId = null;
formGroups.value = [];
if (!hospitalId) {
formDepartments.value = [];
return;
}
const res = await getDepartments({
hospitalId,
page: 1,
pageSize: 100,
});
formDepartments.value = res.list || [];
};
const fetchGroupsForForm = async (departmentId) => {
form.groupId = null;
if (!departmentId) {
formGroups.value = [];
return;
}
const res = await getGroups({
departmentId,
page: 1,
pageSize: 100,
});
formGroups.value = res.list || [];
};
const fetchData = async () => {
loading.value = true;
try {
const res = await getUsers({
page: page.value,
pageSize: pageSize.value,
role: searchForm.role || undefined,
keyword: searchForm.keyword || undefined,
});
tableData.value = res.list || [];
total.value = res.total || 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchData();
};
const resetSearch = () => {
searchForm.keyword = '';
searchForm.role = '';
page.value = 1;
fetchData();
};
const openCreateDialog = async () => {
isEdit.value = false;
currentId.value = null;
dialogVisible.value = true;
if (userStore.role === 'HOSPITAL_ADMIN') {
form.hospitalId = userStore.userInfo?.hospitalId || null;
if (form.hospitalId) {
await fetchDepartmentsForForm(form.hospitalId);
}
}
};
const openEditDialog = async (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
form.phone = row.phone;
form.password = '';
form.role = row.role;
form.hospitalId = row.hospitalId;
form.departmentId = row.departmentId;
form.groupId = row.groupId;
if (form.hospitalId) {
await fetchDepartmentsForForm(form.hospitalId);
form.departmentId = row.departmentId;
} else {
formDepartments.value = [];
}
if (form.departmentId) {
await fetchGroupsForForm(form.departmentId);
form.groupId = row.groupId;
} else {
formGroups.value = [];
}
dialogVisible.value = true;
};
const resetForm = () => {
formRef.value?.resetFields();
form.name = '';
form.phone = '';
form.password = '';
form.role = '';
form.hospitalId = null;
form.departmentId = null;
form.groupId = null;
formDepartments.value = [];
formGroups.value = [];
};
const buildSubmitPayload = () => {
const payload = {
name: form.name,
phone: form.phone,
role: form.role,
hospitalId: null,
departmentId: null,
groupId: null,
};
if (!isEdit.value) {
payload.password = form.password;
if (form.role === 'SYSTEM_ADMIN') {
return payload;
}
payload.hospitalId = form.hospitalId;
if (form.role === 'DIRECTOR') {
payload.departmentId = form.departmentId;
return payload;
}
if (form.role === 'LEADER' || form.role === 'DOCTOR') {
payload.departmentId = form.departmentId;
payload.groupId = form.groupId;
return payload;
}
return payload;
}
if (form.role === 'SYSTEM_ADMIN') {
return payload;
}
payload.hospitalId = form.hospitalId;
if (form.role === 'DOCTOR') {
payload.departmentId = form.departmentId;
payload.groupId = form.groupId;
return payload;
}
if (form.role === 'DIRECTOR') {
payload.departmentId = form.departmentId;
payload.groupId = null;
return payload;
}
if (form.role === 'LEADER') {
payload.departmentId = form.departmentId;
payload.groupId = form.groupId;
return payload;
}
delete payload.departmentId;
delete payload.groupId;
return payload;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitLoading.value = true;
try {
const payload = buildSubmitPayload();
if (isEdit.value) {
await updateUser(currentId.value, payload);
ElMessage.success('更新成功');
} else {
await createUser(payload);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
await fetchCommonData();
await fetchData();
} finally {
submitLoading.value = false;
}
});
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除用户 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(async () => {
await deleteUser(row.id);
ElMessage.success('删除成功');
await fetchData();
})
.catch(() => {});
};
const openAssignDialog = (row) => {
currentAssignUser.value = row;
assignHospitalId.value = row.hospitalId || null;
assignDialogVisible.value = true;
};
const handleAssignSubmit = async () => {
if (!Number.isInteger(assignHospitalId.value)) {
ElMessage.warning('请选择医院');
return;
}
submitLoading.value = true;
try {
await assignEngineerHospital(currentAssignUser.value.id, assignHospitalId.value);
ElMessage.success('分配成功');
assignDialogVisible.value = false;
await fetchCommonData();
await fetchData();
} finally {
submitLoading.value = false;
}
};
watch(
() => form.role,
async (role) => {
if (!role) {
form.hospitalId = null;
form.departmentId = null;
form.groupId = null;
formDepartments.value = [];
formGroups.value = [];
return;
}
if (role === 'SYSTEM_ADMIN') {
form.hospitalId = null;
form.departmentId = null;
form.groupId = null;
formDepartments.value = [];
formGroups.value = [];
return;
}
if (!needDepartment.value) {
form.departmentId = null;
form.groupId = null;
formGroups.value = [];
}
if (!needGroup.value) {
form.groupId = null;
}
},
);
watch(
() => form.hospitalId,
async (hospitalId, oldHospitalId) => {
if (!dialogVisible.value || hospitalId === oldHospitalId) {
return;
}
if (!needDepartment.value) {
return;
}
await fetchDepartmentsForForm(hospitalId);
},
);
watch(
() => form.departmentId,
async (departmentId, oldDepartmentId) => {
if (!dialogVisible.value || departmentId === oldDepartmentId) {
return;
}
if (!needGroup.value) {
formGroups.value = [];
return;
}
await fetchGroupsForForm(departmentId);
},
);
onMounted(async () => {
await fetchCommonData();
await fetchData();
if (route.query.action === 'create') {
await openCreateDialog();
if (route.query.hospitalId) {
form.hospitalId = Number(route.query.hospitalId);
await fetchDepartmentsForForm(form.hospitalId);
}
if (route.query.departmentId) {
form.departmentId = Number(route.query.departmentId);
await fetchGroupsForForm(form.departmentId);
}
}
});
</script>
<style scoped>
.users-container {
padding: 0;
}
.header-actions {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -16,4 +16,13 @@ export default defineConfig({
}), }),
], ],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // Assuming NestJS runs on 3000
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
}
}
}) })