diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md new file mode 100644 index 0000000..14481e4 --- /dev/null +++ b/docs/frontend-api-integration.md @@ -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 +``` diff --git a/docs/patients.md b/docs/patients.md index 461eda7..6371770 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -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`,不直接绑定小组/科室。医生调组或调科后, 可见范围会按医生当前组织归属自动变化,无需迁移患者数据。 diff --git a/src/common/messages.ts b/src/common/messages.ts index d518d72..7e5af34 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -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', diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 2f57801..2723cdf 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -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); + } } diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index 9614d85..b2e0f7a 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -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 = { 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; + } } diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts new file mode 100644 index 0000000..f926ffd --- /dev/null +++ b/src/patients/dto/create-patient.dto.ts @@ -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; +} diff --git a/src/patients/dto/update-patient.dto.ts b/src/patients/dto/update-patient.dto.ts new file mode 100644 index 0000000..404aec8 --- /dev/null +++ b/src/patients/dto/update-patient.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePatientDto } from './create-patient.dto.js'; + +/** + * 患者更新 DTO。 + */ +export class UpdatePatientDto extends PartialType(CreatePatientDto) {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index f9320cd..1e9b268 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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({ - where: { id: userId }, - select: SAFE_USER_SELECT, - }); + 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; + } } /** diff --git a/test/e2e/specs/users.e2e-spec.ts b/test/e2e/specs/users.e2e-spec.ts index 63c4105..9bfe25d 100644 --- a/test/e2e/specs/users.e2e-spec.ts +++ b/test/e2e/specs/users.e2e-spec.ts @@ -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}`) diff --git a/tyt-admin/components.d.ts b/tyt-admin/components.d.ts index fddca02..d728200 100644 --- a/tyt-admin/components.d.ts +++ b/tyt-admin/components.d.ts @@ -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'] } } diff --git a/tyt-admin/package.json b/tyt-admin/package.json index 7ed9e5f..2371bc4 100644 --- a/tyt-admin/package.json +++ b/tyt-admin/package.json @@ -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" diff --git a/tyt-admin/pnpm-lock.yaml b/tyt-admin/pnpm-lock.yaml index 4c16faa..70fbef0 100644 --- a/tyt-admin/pnpm-lock.yaml +++ b/tyt-admin/pnpm-lock.yaml @@ -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: {} diff --git a/tyt-admin/src/App.vue b/tyt-admin/src/App.vue index 4578dfb..1c4dac6 100644 --- a/tyt-admin/src/App.vue +++ b/tyt-admin/src/App.vue @@ -1,3 +1,23 @@ - + - + + + diff --git a/tyt-admin/src/api/organization.js b/tyt-admin/src/api/organization.js new file mode 100644 index 0000000..c38dcdb --- /dev/null +++ b/tyt-admin/src/api/organization.js @@ -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}`); +}; diff --git a/tyt-admin/src/api/patients.js b/tyt-admin/src/api/patients.js new file mode 100644 index 0000000..952dd40 --- /dev/null +++ b/tyt-admin/src/api/patients.js @@ -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 }); +}; diff --git a/tyt-admin/src/api/request.js b/tyt-admin/src/api/request.js new file mode 100644 index 0000000..a75b8ec --- /dev/null +++ b/tyt-admin/src/api/request.js @@ -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; diff --git a/tyt-admin/src/api/tasks.js b/tyt-admin/src/api/tasks.js new file mode 100644 index 0000000..2b0960a --- /dev/null +++ b/tyt-admin/src/api/tasks.js @@ -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); +}; diff --git a/tyt-admin/src/api/users.js b/tyt-admin/src/api/users.js new file mode 100644 index 0000000..058a4b3 --- /dev/null +++ b/tyt-admin/src/api/users.js @@ -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 }); +}; diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..20e1539 --- /dev/null +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/tyt-admin/src/main.js b/tyt-admin/src/main.js index 01433bc..49b0800 100644 --- a/tyt-admin/src/main.js +++ b/tyt-admin/src/main.js @@ -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') diff --git a/tyt-admin/src/router/index.js b/tyt-admin/src/router/index.js new file mode 100644 index 0000000..4a9d5dd --- /dev/null +++ b/tyt-admin/src/router/index.js @@ -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; diff --git a/tyt-admin/src/store/index.js b/tyt-admin/src/store/index.js new file mode 100644 index 0000000..609dfdb --- /dev/null +++ b/tyt-admin/src/store/index.js @@ -0,0 +1,5 @@ +import { createPinia } from 'pinia'; + +const pinia = createPinia(); + +export default pinia; diff --git a/tyt-admin/src/store/user.js b/tyt-admin/src/store/user.js new file mode 100644 index 0000000..7db76b3 --- /dev/null +++ b/tyt-admin/src/store/user.js @@ -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'); + }, + }, +}); diff --git a/tyt-admin/src/styles/index.scss b/tyt-admin/src/styles/index.scss new file mode 100644 index 0000000..ca1bf67 --- /dev/null +++ b/tyt-admin/src/styles/index.scss @@ -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; +} diff --git a/tyt-admin/src/views/Dashboard.vue b/tyt-admin/src/views/Dashboard.vue new file mode 100644 index 0000000..c8eef01 --- /dev/null +++ b/tyt-admin/src/views/Dashboard.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/tyt-admin/src/views/Login.vue b/tyt-admin/src/views/Login.vue new file mode 100644 index 0000000..cda40ab --- /dev/null +++ b/tyt-admin/src/views/Login.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/tyt-admin/src/views/NotFound.vue b/tyt-admin/src/views/NotFound.vue new file mode 100644 index 0000000..80e6d84 --- /dev/null +++ b/tyt-admin/src/views/NotFound.vue @@ -0,0 +1,6 @@ + diff --git a/tyt-admin/src/views/organization/Departments.vue b/tyt-admin/src/views/organization/Departments.vue new file mode 100644 index 0000000..011cf8a --- /dev/null +++ b/tyt-admin/src/views/organization/Departments.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/tyt-admin/src/views/organization/Groups.vue b/tyt-admin/src/views/organization/Groups.vue new file mode 100644 index 0000000..8ab0b99 --- /dev/null +++ b/tyt-admin/src/views/organization/Groups.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/tyt-admin/src/views/organization/Hospitals.vue b/tyt-admin/src/views/organization/Hospitals.vue new file mode 100644 index 0000000..fd025f8 --- /dev/null +++ b/tyt-admin/src/views/organization/Hospitals.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/tyt-admin/src/views/organization/OrgTree.vue b/tyt-admin/src/views/organization/OrgTree.vue new file mode 100644 index 0000000..ef602bb --- /dev/null +++ b/tyt-admin/src/views/organization/OrgTree.vue @@ -0,0 +1,731 @@ + + + + + diff --git a/tyt-admin/src/views/patients/Patients.vue b/tyt-admin/src/views/patients/Patients.vue new file mode 100644 index 0000000..a72b77e --- /dev/null +++ b/tyt-admin/src/views/patients/Patients.vue @@ -0,0 +1,486 @@ + + + + + diff --git a/tyt-admin/src/views/tasks/Tasks.vue b/tyt-admin/src/views/tasks/Tasks.vue new file mode 100644 index 0000000..c509313 --- /dev/null +++ b/tyt-admin/src/views/tasks/Tasks.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/tyt-admin/src/views/users/Users.vue b/tyt-admin/src/views/users/Users.vue new file mode 100644 index 0000000..0eab9a7 --- /dev/null +++ b/tyt-admin/src/views/users/Users.vue @@ -0,0 +1,706 @@ + + + + + diff --git a/tyt-admin/vite.config.js b/tyt-admin/vite.config.js index ddf1d46..97d18b1 100644 --- a/tyt-admin/vite.config.js +++ b/tyt-admin/vite.config.js @@ -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/, ''), + }, + } + } })