web
This commit is contained in:
parent
2c1bbd565f
commit
394793fa28
37
docs/frontend-api-integration.md
Normal file
37
docs/frontend-api-integration.md
Normal 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
|
||||
```
|
||||
@ -13,6 +13,15 @@
|
||||
- `HOSPITAL_ADMIN`:可查本院全部患者
|
||||
- `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`,不直接绑定小组/科室。医生调组或调科后,
|
||||
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
|
||||
|
||||
@ -49,7 +49,8 @@ export const MESSAGES = {
|
||||
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
|
||||
GROUP_DEPARTMENT_REQUIRED: '绑定小组时必须同时传入科室',
|
||||
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
||||
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生允许调整科室/小组归属',
|
||||
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
|
||||
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
|
||||
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
|
||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||
},
|
||||
@ -70,9 +71,14 @@ export const MESSAGES = {
|
||||
},
|
||||
|
||||
PATIENT: {
|
||||
NOT_FOUND: '患者不存在或无权限访问',
|
||||
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
|
||||
GROUP_REQUIRED: '组长查询需携带 groupId',
|
||||
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
|
||||
DOCTOR_NOT_FOUND: '归属医生不存在',
|
||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生角色',
|
||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生',
|
||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
|
||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||
|
||||
@ -1,5 +1,22 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
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 { CurrentActor } from '../../auth/current-actor.decorator.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 { Role } from '../../generated/prisma/enums.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 端患者控制器:院内可见性隔离查询。
|
||||
@ -18,6 +37,32 @@ import { BPatientsService } from './b-patients.service.js';
|
||||
export class BPatientsController {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建患者。
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,318 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma/client.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import type { ActorContext } from '../../common/actor-context.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()
|
||||
export class BPatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* B 端查询:根据角色自动限制可见患者范围。
|
||||
* 查询当前角色可见患者列表。
|
||||
*/
|
||||
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
|
||||
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 };
|
||||
switch (actor.role) {
|
||||
case Role.DOCTOR:
|
||||
@ -51,16 +342,7 @@ export class BPatientsService {
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
|
||||
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' },
|
||||
});
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,4 +369,23 @@ export class BPatientsService {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
35
src/patients/dto/create-patient.dto.ts
Normal file
35
src/patients/dto/create-patient.dto.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 患者创建 DTO:B 端新增患者使用。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
7
src/patients/dto/update-patient.dto.ts
Normal file
7
src/patients/dto/update-patient.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePatientDto } from './create-patient.dto.js';
|
||||
|
||||
/**
|
||||
* 患者更新 DTO。
|
||||
*/
|
||||
export class UpdatePatientDto extends PartialType(CreatePatientDto) {}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { compare, hash } from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
@ -236,7 +237,12 @@ export class UsersService {
|
||||
const assigningDepartmentOrGroup =
|
||||
(updateUserDto.departmentId !== undefined && nextDepartmentId != 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);
|
||||
}
|
||||
|
||||
@ -306,10 +312,20 @@ export class UsersService {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
await this.findOne(userId);
|
||||
|
||||
return this.prisma.user.delete({
|
||||
try {
|
||||
return await this.prisma.user.delete({
|
||||
where: { id: userId },
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2003'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.USER.DELETE_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -207,7 +207,7 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, '仅医生允许调整科室/小组归属');
|
||||
expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
@ -246,6 +246,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
|
||||
38
tyt-admin/components.d.ts
vendored
38
tyt-admin/components.d.ts
vendored
@ -11,6 +11,44 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,11 +9,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"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": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"sass": "^1.98.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0"
|
||||
|
||||
678
tyt-admin/pnpm-lock.yaml
generated
678
tyt-admin/pnpm-lock.yaml
generated
@ -8,16 +8,34 @@ importers:
|
||||
|
||||
.:
|
||||
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:
|
||||
specifier: ^2.13.5
|
||||
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:
|
||||
specifier: ^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:
|
||||
'@vitejs/plugin-vue':
|
||||
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:
|
||||
specifier: ^21.0.0
|
||||
version: 21.0.0(@vueuse/core@12.0.0)
|
||||
@ -26,10 +44,14 @@ importers:
|
||||
version: 31.0.0(vue@3.5.30)
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
version: 8.0.0(sass@1.98.0)(yaml@2.8.2)
|
||||
|
||||
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':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -100,6 +122,94 @@ packages:
|
||||
'@oxc-project/types@0.115.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==}
|
||||
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
|
||||
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':
|
||||
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
|
||||
|
||||
@ -238,6 +357,24 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.30':
|
||||
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':
|
||||
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
|
||||
|
||||
@ -269,29 +406,70 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
confbox@0.2.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
dayjs@1.11.20:
|
||||
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:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
element-plus@2.13.5:
|
||||
resolution: {integrity: sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==}
|
||||
peerDependencies:
|
||||
@ -301,6 +479,22 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -323,14 +517,82 @@ packages:
|
||||
picomatch:
|
||||
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:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@ -422,29 +684,63 @@ packages:
|
||||
lodash@4.17.23:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
normalize-wheel-es@1.2.0:
|
||||
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
|
||||
|
||||
nprogress@0.2.0:
|
||||
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -452,6 +748,15 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
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:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@ -462,18 +767,33 @@ packages:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
quansync@0.2.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rolldown@1.0.0-rc.9:
|
||||
resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
sass@1.98.0:
|
||||
resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
scule@1.3.0:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
@ -481,9 +801,17 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -528,6 +856,10 @@ packages:
|
||||
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
|
||||
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:
|
||||
resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -571,6 +903,21 @@ packages:
|
||||
yaml:
|
||||
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:
|
||||
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
|
||||
peerDependencies:
|
||||
@ -582,8 +929,21 @@ packages:
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
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-validator-identifier@7.28.5': {}
|
||||
@ -660,6 +1020,67 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
optional: true
|
||||
|
||||
@ -728,10 +1149,20 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@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/compiler-core@3.5.30':
|
||||
@ -764,6 +1195,37 @@ snapshots:
|
||||
'@vue/compiler-dom': 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':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.30
|
||||
@ -807,22 +1269,69 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@ctrl/tinycolor': 4.2.0
|
||||
@ -845,6 +1354,21 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
@ -859,11 +1383,71 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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:
|
||||
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: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@ -929,12 +1513,26 @@ snapshots:
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
math-intrinsics@1.1.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:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
@ -942,18 +1540,34 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
normalize-wheel-es@1.2.0: {}
|
||||
|
||||
nprogress@0.2.0: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@ -972,10 +1586,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rolldown@1.0.0-rc.9:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.115.0
|
||||
@ -997,14 +1617,28 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-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: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
superjson@2.2.6:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@ -1068,7 +1702,13 @@ snapshots:
|
||||
picomatch: 4.0.3
|
||||
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:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@ -1078,6 +1718,32 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
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:
|
||||
dependencies:
|
||||
@ -1088,3 +1754,5 @@ snapshots:
|
||||
'@vue/shared': 3.5.30
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
@ -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>
|
||||
|
||||
68
tyt-admin/src/api/organization.js
Normal file
68
tyt-admin/src/api/organization.js
Normal 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}`);
|
||||
};
|
||||
29
tyt-admin/src/api/patients.js
Normal file
29
tyt-admin/src/api/patients.js
Normal 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 });
|
||||
};
|
||||
66
tyt-admin/src/api/request.js
Normal file
66
tyt-admin/src/api/request.js
Normal 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;
|
||||
17
tyt-admin/src/api/tasks.js
Normal file
17
tyt-admin/src/api/tasks.js
Normal 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);
|
||||
};
|
||||
60
tyt-admin/src/api/users.js
Normal file
60
tyt-admin/src/api/users.js
Normal 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 });
|
||||
};
|
||||
170
tyt-admin/src/layouts/AdminLayout.vue
Normal file
170
tyt-admin/src/layouts/AdminLayout.vue
Normal 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>
|
||||
@ -1,4 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import './styles/index.scss'
|
||||
import 'element-plus/dist/index.css'
|
||||
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')
|
||||
|
||||
111
tyt-admin/src/router/index.js
Normal file
111
tyt-admin/src/router/index.js
Normal 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;
|
||||
5
tyt-admin/src/store/index.js
Normal file
5
tyt-admin/src/store/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
export default pinia;
|
||||
58
tyt-admin/src/store/user.js
Normal file
58
tyt-admin/src/store/user.js
Normal 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');
|
||||
},
|
||||
},
|
||||
});
|
||||
17
tyt-admin/src/styles/index.scss
Normal file
17
tyt-admin/src/styles/index.scss
Normal 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;
|
||||
}
|
||||
172
tyt-admin/src/views/Dashboard.vue
Normal file
172
tyt-admin/src/views/Dashboard.vue
Normal 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>
|
||||
123
tyt-admin/src/views/Login.vue
Normal file
123
tyt-admin/src/views/Login.vue
Normal 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>
|
||||
6
tyt-admin/src/views/NotFound.vue
Normal file
6
tyt-admin/src/views/NotFound.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<h2>404 - 页面未找到</h2>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
328
tyt-admin/src/views/organization/Departments.vue
Normal file
328
tyt-admin/src/views/organization/Departments.vue
Normal 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>
|
||||
397
tyt-admin/src/views/organization/Groups.vue
Normal file
397
tyt-admin/src/views/organization/Groups.vue
Normal 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>
|
||||
217
tyt-admin/src/views/organization/Hospitals.vue
Normal file
217
tyt-admin/src/views/organization/Hospitals.vue
Normal 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>
|
||||
731
tyt-admin/src/views/organization/OrgTree.vue
Normal file
731
tyt-admin/src/views/organization/OrgTree.vue
Normal 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>
|
||||
486
tyt-admin/src/views/patients/Patients.vue
Normal file
486
tyt-admin/src/views/patients/Patients.vue
Normal 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>
|
||||
358
tyt-admin/src/views/tasks/Tasks.vue
Normal file
358
tyt-admin/src/views/tasks/Tasks.vue
Normal 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>
|
||||
706
tyt-admin/src/views/users/Users.vue
Normal file
706
tyt-admin/src/views/users/Users.vue
Normal 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>
|
||||
@ -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/, ''),
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user