Browse Source

feat(@six/health-remedy): 中医特色治疗第一版

张田田 4 tháng trước cách đây
mục cha
commit
88d968ce14
54 tập tin đã thay đổi với 4442 bổ sung114 xóa
  1. 1 1
      apps/health-remedy/.env
  2. 2 2
      apps/health-remedy/.env.development
  3. 3 3
      apps/health-remedy/.env.production
  4. 59 10
      apps/health-remedy/public/database/menu.json
  5. 2 0
      apps/health-remedy/src/api/index.ts
  6. 1 3
      apps/health-remedy/src/api/method/access.ts
  7. 51 0
      apps/health-remedy/src/api/method/operate.ts
  8. 65 0
      apps/health-remedy/src/api/method/register.ts
  9. 147 7
      apps/health-remedy/src/api/method/system.ts
  10. 157 0
      apps/health-remedy/src/api/method/treatment.ts
  11. 2 1
      apps/health-remedy/src/api/model/index.ts
  12. 9 2
      apps/health-remedy/src/api/model/menu.ts
  13. 45 0
      apps/health-remedy/src/api/model/operate.ts
  14. 32 0
      apps/health-remedy/src/api/model/organization.ts
  15. 36 0
      apps/health-remedy/src/api/model/patient.ts
  16. 32 0
      apps/health-remedy/src/api/model/project.ts
  17. 59 0
      apps/health-remedy/src/api/model/register.ts
  18. 23 0
      apps/health-remedy/src/api/model/tab.ts
  19. 74 0
      apps/health-remedy/src/api/model/treatmentDetail.ts
  20. 14 2
      apps/health-remedy/src/api/model/user.ts
  21. 27 1
      apps/health-remedy/src/layouts/basic.vue
  22. 1 1
      apps/health-remedy/src/locales/langs/zh-CN/authentication.json
  23. 24 0
      apps/health-remedy/src/locales/langs/zh-CN/operate.json
  24. 30 0
      apps/health-remedy/src/locales/langs/zh-CN/register.json
  25. 33 6
      apps/health-remedy/src/locales/langs/zh-CN/system.json
  26. 49 0
      apps/health-remedy/src/locales/langs/zh-CN/treatment.json
  27. 4 0
      apps/health-remedy/src/preferences.ts
  28. 0 1
      apps/health-remedy/src/router/access.ts
  29. 117 0
      apps/health-remedy/src/views/operate/record/data.ts
  30. 89 0
      apps/health-remedy/src/views/operate/record/list.vue
  31. 93 0
      apps/health-remedy/src/views/operate/record/modules/form.vue
  32. 113 0
      apps/health-remedy/src/views/register/register/data.ts
  33. 90 0
      apps/health-remedy/src/views/register/register/list.vue
  34. 229 0
      apps/health-remedy/src/views/register/register/modules/form.vue
  35. 110 0
      apps/health-remedy/src/views/system/organization/data.ts
  36. 113 0
      apps/health-remedy/src/views/system/organization/list.vue
  37. 86 0
      apps/health-remedy/src/views/system/organization/modules/form.vue
  38. 67 0
      apps/health-remedy/src/views/system/project/data.ts
  39. 61 0
      apps/health-remedy/src/views/system/project/list.vue
  40. 5 0
      apps/health-remedy/src/views/system/project/modules/form.vue
  41. 39 41
      apps/health-remedy/src/views/system/role/data.ts
  42. 8 8
      apps/health-remedy/src/views/system/role/list.vue
  43. 6 1
      apps/health-remedy/src/views/system/role/modules/form.vue
  44. 74 10
      apps/health-remedy/src/views/system/user/data.ts
  45. 71 11
      apps/health-remedy/src/views/system/user/list.vue
  46. 380 0
      apps/health-remedy/src/views/treatment/treatment/components/PatientList.vue
  47. 840 0
      apps/health-remedy/src/views/treatment/treatment/components/TreatmentDetail.vue
  48. 307 0
      apps/health-remedy/src/views/treatment/treatment/components/TreatmentTabs.vue
  49. 115 0
      apps/health-remedy/src/views/treatment/treatment/data.ts
  50. 342 0
      apps/health-remedy/src/views/treatment/treatment/list.vue
  51. 92 0
      apps/health-remedy/src/views/treatment/treatment/modules/form.vue
  52. 2 2
      apps/health-remedy/vite.config.mts
  53. 10 1
      packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue
  54. 1 0
      packages/locales/src/langs/zh-CN/common.json

+ 1 - 1
apps/health-remedy/.env

@@ -1,5 +1,5 @@
 # 应用标题
-VITE_APP_TITLE=中医智能辅助诊疗系统
+VITE_APP_TITLE=中医特色治疗智能辅助系统
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
 VITE_APP_NAMESPACE=@six.admin/health_remedy

+ 2 - 2
apps/health-remedy/.env.development

@@ -5,8 +5,8 @@ VITE_BASE=/
 
 # 接口地址
 VITE_GLOB_API_URL=
-# 智能诊接口地址
-VITE_GLOB_API_HEALTH_REMEDY=/dz
+# 智能诊接口地址
+VITE_GLOB_API_HEALTH_REMEDY=/wf
 
 # 是否打开 devtools,true 为打开,false 为关闭
 VITE_DEVTOOLS=true

+ 3 - 3
apps/health-remedy/.env.production

@@ -1,9 +1,9 @@
-VITE_BASE=/dz/p/
+VITE_BASE=/wf/p/
 
 # 接口地址
 VITE_GLOB_API_URL=
-# 智能诊接口地址
-VITE_GLOB_API_HOSPITAL_GUIDE=/dz
+# 智能诊接口地址
+VITE_GLOB_API_HEALTH_REMEDY=/wf
 
 # 是否开启压缩,可以设置为 none, brotli, gzip
 VITE_COMPRESS=brotli,gzip

+ 59 - 10
apps/health-remedy/public/database/menu.json

@@ -1,23 +1,53 @@
 [
+  // {
+  //   "meta": {
+  //     "icon": "charm:organisation",
+  //     "order": 2,
+  //     "title": "business.dept.title"
+  //   },
+  //   "name": "BusinessDepartment",
+  //   "path": "/business/dept",
+  //   "component": "/business/department/list"
+  // },
+  // {
+  //   "meta": {
+  //     "icon": "mdi:account",
+  //     "order": 3,
+  //     "title": "business.doctor.title"
+  //   },
+  //   "name": "BusinessDoctor",
+  //   "path": "/business/doctor",
+  //   "component": "/business/doctor/list"
+  // },
   {
     "meta": {
-      "icon": "charm:organisation",
-      "order": 2,
-      "title": "business.dept.title"
+      "icon": "mdi:account",
+      "order": 3,
+      "title": "register.register.title"
     },
-    "name": "BusinessDepartment",
-    "path": "/business/dept",
-    "component": "/business/department/list"
+    "name": "RegisterRegister",
+    "path": "/register/register",
+    "component": "/register/register/list"
   },
   {
     "meta": {
       "icon": "mdi:account",
       "order": 3,
-      "title": "business.doctor.title"
+      "title": "operate.record.title"
     },
-    "name": "BusinessDoctor",
-    "path": "/business/doctor",
-    "component": "/business/doctor/list"
+    "name": "OperateRecord",
+    "path": "/operate/record",
+    "component": "/operate/record/list"
+  },
+  {
+    "meta": {
+      "icon": "mdi:account",
+      "order": 3,
+      "title": "treatment.treatment.title"
+    },
+    "name": "TreatmentTreatment",
+    "path": "/treatment/treatment",
+    "component": "/treatment/treatment/list"
   },
   {
     "meta": {
@@ -45,7 +75,26 @@
           "title": "system.user.title"
         },
         "component": "/system/user/list"
+      },
+      {
+        "path": "/system/organization",
+        "name": "SystemOrganization",
+        "meta": {
+          "icon": "charm:organisation",
+          "title": "system.organization.title"
+        },
+        "component": "/system/organization/list"
+      },
+      {
+        "path": "/system/project",
+        "name": "SystemProject",
+        "meta": {
+          "icon": "charm:organisation",
+          "title": "system.project.title"
+        },
+        "component": "/system/project/list"
       }
+      
     ]
   }
 ]

+ 2 - 0
apps/health-remedy/src/api/index.ts

@@ -14,6 +14,8 @@ export * from './method/system';
 
 export const http = createRequestClient({
   id: import.meta.env.VITE_APP_NAMESPACE?.split('/').pop() ?? 'health-remedy',
+  // 本地的开发地址
+  // baseURL: 'http://192.168.1.16:8039',
   transform(body, method) {
     /* prettier-ignore */
     if (body === null || typeof body !== 'object') return { code: 0, data: body, message: 'ok' };

+ 1 - 3
apps/health-remedy/src/api/method/access.ts

@@ -28,13 +28,12 @@ export function loginMethod(data: AccessModel.LoginParams) {
 
 export function getAccessMenuMethod(permissions?: string[]) {
   return http.post<SystemModel.Menu[], TransformData[]>(
-    `/admin/right_RoleMgr/allMenu`,
+    `/admin/menu/allMenu`,
     void 0,
     {
       transform(data) {
         const menus = fromMenus(data);
         if (!permissions?.length) return menus;
-
         const forEach = (
           permissions: Set<string>,
           menus: SystemModel.Menu[],
@@ -57,7 +56,6 @@ export function getAccessMenuMethod(permissions?: string[]) {
           }
           return parentMenu;
         };
-
         return forEach(new Set(permissions), menus);
       },
     },

+ 51 - 0
apps/health-remedy/src/api/method/operate.ts

@@ -0,0 +1,51 @@
+import type { TransformData, TransformList, TransformRecord } from '#/api';
+
+import { http } from '#/api';
+import { fromRecord } from '#/api/model/operate';
+
+export namespace OperateModel {
+  export interface Record extends TransformRecord {
+    [key: string]: any;
+
+    id: string;
+    name: string;
+    itemName: string;
+    acupoint: string;
+    description: string;
+    operateUserName: string;
+    operateDate: string;
+    operationRemark: string;
+    photoUrl: string;
+    issueInstitutionName: string;
+    issueDoctorName: string;
+    issueDate: string;
+    treatmentDescription: string;
+    treatmentTime: string;
+    remark?: string;
+    status: 0 | 1;
+  }
+}
+// 获取机构列表
+export function listRecordsMethod(page = 1, size = 20, query?: TransformData) {
+  return http.post<TransformList<OperateModel.Record>, TransformList>(
+    `/basis/operate/listPage`,
+    query,
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromRecord(item)) };
+      },
+    },
+  );
+}
+// 获取记录详情
+export function getRecordDetailMethod(id: string) {
+  return http.Post<OperateModel.Record, TransformData>(
+    `/basis/operate/operateDetail?id=${id}`,
+    {
+      transform(data) {
+        return fromRecord(data);
+      },
+    },
+  );
+}

+ 65 - 0
apps/health-remedy/src/api/method/register.ts

@@ -0,0 +1,65 @@
+import type { TransformData, TransformList, TransformRecord } from '#/api';
+
+import { http } from '#/api';
+import { fromRegister } from '#/api/model/register';
+
+export namespace RegisterModel {
+  export interface Register extends TransformRecord {
+    [key: string]: any;
+    id: string;
+    name: string;
+    itemName: string;
+    itemCode: string;
+    itemState: number;
+    totalNum: number;
+    completeNum: number;
+    describe: string;
+    issueDate: string;
+    issueDoctorId: string;
+    issueDoctorName: string;
+    issueInstitutionId: string;
+    issueInstitutionName: string;
+    progressStatus: 0 | 1;
+    finishTime: string;
+    nextDate: string;
+    patientId: string;
+    phone: string;
+    sex: string;
+    writeoff: number;
+  }
+}
+// 获取登记列表
+export function listRegisterMethod(page = 1, size = 20, query?: TransformData) {
+  return http.post<TransformList<RegisterModel.Register>, TransformList>(
+    `/basis/item/registerList`,
+    query,
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromRegister(item)) };
+      },
+    },
+  );
+}
+// 获取可核销项目
+export function getWriteOffListMethod(id: string) {
+  return http.Post<RegisterModel.Register, TransformData>(
+    `/basis/item/writeOffList?id=${id}`,
+    {
+      transform(data) {
+        return fromRegister(data);
+      },
+    },
+  );
+}
+// 核销项目
+export function writeOffItemMethod(ids: string[]) {
+  return http.Post<RegisterModel.Register, TransformData>(
+    `/basis/item/writeOff?ids=${ids}`,
+    {
+      transform(data) {
+        return fromRegister(data);
+      },
+    },
+  );
+}

+ 147 - 7
apps/health-remedy/src/api/method/system.ts

@@ -3,10 +3,31 @@ import type { RouteMeta } from 'vue-router';
 import type { TransformData, TransformList, TransformRecord } from '#/api';
 
 import { http } from '#/api';
-import { fromRole, fromUser, toRole, toUser } from '#/api/model';
+import {
+  fromOrganization,
+  fromProject,
+  fromRole,
+  fromUser,
+  toOrganization,
+  toProject,
+  toRole,
+  toUser,
+} from '#/api/model';
 import { fromMenus } from '#/api/model/menu';
 
 export namespace SystemModel {
+  export interface Project extends TransformRecord {
+    id: string;
+    institutionName?: string;
+    createDate?: string;
+    updateDate?: string;
+    institutionCode?: string;
+    institutionId?: string;
+    itemName: string;
+    itemCode?: string;
+    sourceCode?: string;
+    sourceName?: string;
+  }
   export interface Role extends TransformRecord {
     [key: string]: any;
 
@@ -25,8 +46,25 @@ export namespace SystemModel {
     worker?: string;
     mobile?: string;
     roles?: Array<Role | string>;
-
+    sititutionId?: string;
+    pid?: string;
     password?: string;
+    stateSel?: 0 | 1;
+    status?: 0 | 1;
+    roleNames?: string;
+    createUser?: string;
+    hospitalName?: string;
+  }
+
+  export interface Organization extends TransformRecord {
+    id: string;
+    name: string;
+    code?: string;
+    superior?: string;
+    createTime?: string;
+    createUser?: string;
+    parentInstitutionId?: string;
+    parentinstitutionSelsourceName?: string;
   }
 
   export interface Menu {
@@ -40,6 +78,36 @@ export namespace SystemModel {
     children?: Menu[];
   }
 }
+// 获取机构列表
+export function listOrganizationsMethod(
+  page = 1,
+  size = 20,
+  query?: TransformData,
+) {
+  return http.post<TransformList<SystemModel.Organization>, TransformList>(
+    `/basis/medicalinstitutionsMgr/listPain`,
+    toOrganization(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromOrganization(item)) };
+      },
+    },
+  );
+}
+// 获取项目管理列表
+export function listProjectsMethod(page = 1, size = 20, query?: TransformData) {
+  return http.post<TransformList<SystemModel.Project>, TransformList>(
+    `/basis/institution/listPage`,
+    toProject(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromProject(item)) };
+      },
+    },
+  );
+}
 
 export function listRolesMethod(page = 1, size = 20, query?: TransformData) {
   return http.post<TransformList<SystemModel.Role>, TransformList>(
@@ -64,7 +132,50 @@ export function optionsRoleMethod() {
     },
   );
 }
-
+// 获取来源平台
+export function listSourcePlatformMethod() {
+  return http.Post<SystemModel.Project[], TransformData[]>(
+    `/basis/institution/sourceList`,
+    {
+      transform(data) {
+        return data.map((item) => fromProject(item));
+      },
+    },
+  );
+}
+// 获取全部机构
+export function listOrganizationsMethodAll() {
+  return http.Post<SystemModel.Organization[], TransformData[]>(
+    `/basis/medicalinstitutionsMgr/list`,
+    {
+      transform(data) {
+        return data.map((item) => fromOrganization(item));
+      },
+    },
+  );
+}
+// 获取机构(属树形结构)
+export function listUsersInstitutionMethodTree() {
+  return http.Post<SystemModel.User[], TransformData[]>(
+    `/basis/medicalinstitutionsMgr/treeList`,
+    {
+      transform(data) {
+        return data.map((item) => fromUser(item));
+      },
+    },
+  );
+}
+// 编辑机构
+export function editOrganizationMethod(
+  data: Partial<SystemModel.Organization>,
+) {
+  return http.post(
+    data.id
+      ? `/basis/medicalinstitutionsMgr/update`
+      : `/basis/medicalinstitutionsMgr/Add`,
+    toOrganization(data),
+  );
+}
 export function editRoleMethod(data: Partial<SystemModel.Role>) {
   return http.post(
     data.id ? `/admin/right_RoleMgr/update` : `/admin/right_RoleMgr/Add`,
@@ -90,6 +201,7 @@ export function deleteRolesMethod(params: Pick<SystemModel.User, 'id'>[]) {
   });
 }
 
+// 获取用户列表
 export function listUsersMethod(page = 1, size = 20, query?: SystemModel.User) {
   return http.post<TransformList<SystemModel.User>, TransformList>(
     `/portal/userMgr/listPain`,
@@ -102,6 +214,14 @@ export function listUsersMethod(page = 1, size = 20, query?: SystemModel.User) {
     },
   );
 }
+// 编辑或新增用户
+// 编辑项目
+export function editProjectMethod(data: Partial<SystemModel.Project>) {
+  return http.post(
+    data.id ? `/basis/institution/update` : `/basis/institution/Add`,
+    toProject(data),
+  );
+}
 
 export function editUserMethod(data: Partial<SystemModel.User>) {
   return http.post(
@@ -109,7 +229,13 @@ export function editUserMethod(data: Partial<SystemModel.User>) {
     toUser(data),
   );
 }
-
+// 用户状态更改
+export function updateUserStatusMethod(
+  pid: string,
+  { status }: { status: 0 | 1 },
+) {
+  return http.Post(`/portal/userMgr/updateState`, { pid, stateSel: status });
+}
 export function getUserMethod(id: string) {
   return http.get<SystemModel.User, TransformData>(`/portal/userMgr/${id}`, {
     transform(data) {
@@ -118,19 +244,33 @@ export function getUserMethod(id: string) {
   });
 }
 
-export function deleteUserMethod(data: Pick<SystemModel.User, 'id'>) {
+export function deleteUserMethod(data: Pick<SystemModel.User, 'pid'>) {
+  console.warn('data', data);
   return deleteUsersMethod([data]);
 }
 
-export function deleteUsersMethod(params: Pick<SystemModel.User, 'id'>[]) {
+export function deleteUsersMethod(params: Pick<SystemModel.User, 'pid'>[]) {
   return http.post(`/portal/userMgr/BatchDelete`, void 0, {
+    params: { ids: params.map((item) => item.pid).join(',') },
+  });
+}
+// 删除机构
+export function deleteOrganizationMethod(
+  data: Pick<SystemModel.Organization, 'id'>,
+) {
+  return deleteOrganizationsMethod([data]);
+}
+export function deleteOrganizationsMethod(
+  params: Pick<SystemModel.Organization, 'id'>[],
+) {
+  return http.post(`/basis/medicalinstitutionsMgr/BatchDelete`, void 0, {
     params: { ids: params.map((item) => item.id).join(',') },
   });
 }
 
 export function getMenusMethod() {
   return http.post<SystemModel.Menu[], TransformData[]>(
-    `/admin/right_RoleMgr/allMenu`,
+    `/admin/menu/allMenu`,
     void 0,
     {
       transform(data) {

+ 157 - 0
apps/health-remedy/src/api/method/treatment.ts

@@ -0,0 +1,157 @@
+import type { TransformData, TransformList } from '#/api';
+
+import { http } from '#/api';
+import { fromPatient, toPatient } from '#/api/model/patient';
+import { fromTreatmentTab } from '#/api/model/tab';
+import { fromTreatmentDetail } from '#/api/model/treatmentDetail';
+
+export namespace TreatmentModel {
+  export interface TreatmentTab {
+    itemName: string;
+    patientNum: number;
+  }
+  export interface Patient {
+    patientId: string;
+    name: string;
+    sex: string;
+    age: number;
+    phone: string;
+    itemVOS: [
+      {
+        id: string;
+        itemCode: string;
+        itemName: string;
+        itemState: number;
+      },
+    ];
+  }
+  export interface TreatmentDetail {
+    id: string;
+    currentOperate: {
+      id: string;
+      treatmentTime: string;
+    };
+    acupoints: [
+      {
+        acupointCode: string;
+        acupointName: string;
+        acuType: string;
+        id: string;
+      },
+    ];
+    behavior: string;
+    completeNum: number;
+    constitution: string;
+    createTime: string;
+    describe: string;
+    diagnosis: string;
+    finishTime: string;
+    frequency: string;
+    isDeleted: number;
+    issueDate: string;
+    issueDoctorId: string;
+    issueDoctorName: string;
+    issueInstitutionId: string;
+    issueInstitutionName: string;
+    itemCode: string;
+    itemName: string;
+    itemState: number;
+    nextDate: string;
+    operates: [
+      {
+        id: string;
+        isDelete: number;
+        operateDate: string;
+        operateUserId: string;
+        operateUserName: string;
+        recordId: string;
+        remark: string;
+        treatmentImageUrl: string;
+        treatmentTime: string;
+      },
+    ];
+    patientId: string;
+    planCode: string;
+    planType: string;
+    progressStatus: number;
+    totalNum: number;
+    updateTime: string;
+    writeOffDate: string;
+    writeoff: string;
+    treatmentTime: number;
+  }
+}
+
+// 获取未治疗项目统计
+export function listUntreatedProjectsMethod() {
+  return http.Post<TreatmentModel.TreatmentTab, TransformData>(
+    `/basis/treatment/waitTreatmentCount`,
+    {
+      transform(data: any) {
+        return fromTreatmentTab(data);
+      },
+    },
+  );
+}
+// 获取未治疗患者列表
+export function listPatientsMethod(page = 1, size = 20, query?: TransformData) {
+  return http.Post<TransformList<TreatmentModel.Patient>, TransformList>(
+    `/basis/treatment/waitTreatmentList`,
+    toPatient(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromPatient(item)) };
+      },
+    },
+  );
+}
+// 获取治疗项目详情
+export function getTreatmentDetailMethod(id: string) {
+  return http.Post<TreatmentModel.TreatmentDetail, TransformData>(
+    `/basis/treatment/treatmentDetail?id=${id}`,
+    {
+      transform(data: any) {
+        return fromTreatmentDetail(data);
+      },
+    },
+  );
+}
+// 保存治疗
+export function saveTreatmentMethod(
+  data: Partial<TreatmentModel.TreatmentDetail>,
+) {
+  return http.post(`/basis/treatment/saveStartTreatment`, data);
+}
+// 获取穴位详情
+export function getAcupointDetailMethod(id: string) {
+  return http.Post<any, any>(
+    `/knowledge/acuPoint/detail`,
+    { id },
+    {
+      // meta: { notParseResponseBody: true },
+      transform(data: any) {
+        return data;
+      },
+    },
+  );
+}
+// 获取经络详情
+export function getMeridianDetailMethod(id: string) {
+  return http.Post<any, TransformData>(
+    `/knowledge/acuMeridian/detail`,
+    { id },
+    {
+      // meta: { notParseResponseBody: true },
+      transform(data: any) {
+        return data;
+      },
+    },
+  );
+}
+// 治疗结束
+export function endTreatmentMethod(ids: string[]) {
+  return http.Post<any, TransformData>(
+    `/basis/treatment/endTreatment?ids=${ids}`,
+  );
+}

+ 2 - 1
apps/health-remedy/src/api/model/index.ts

@@ -2,9 +2,10 @@ import type { TransformData, TransformRecord } from '#/api';
 
 export * from './department';
 export * from './doctor';
+export * from './organization';
+export * from './project';
 export * from './role';
 export * from './user';
-
 export function fromRow(data?: TransformData): TransformRecord {
   const createUser = data?.createUser;
   const createTime = data?.createTime ?? data?.createDate;

+ 9 - 2
apps/health-remedy/src/api/model/menu.ts

@@ -10,7 +10,13 @@ export function fromMenus(menus: TransformData[]): SystemModel.Menu[] {
     ? menus
         .map((menu: TransformData) => {
           menu.meta ??= {};
-          menu.meta.order ??= menu?.orderNum ?? -1;
+          // 使用后端的 orderNum 优先;其可能为字符串,这里转为数字;没有则回退到已有 meta.order;仍无则给很大默认值
+          const computedOrder = Number(
+            menu?.orderNum ?? (menu.meta as any)?.order ?? 999_999,
+          );
+          menu.meta.order = Number.isFinite(computedOrder)
+            ? computedOrder
+            : 999_999;
           return {
             type: getType(menu),
             id: menu.id ?? menu.meta.id,
@@ -22,7 +28,8 @@ export function fromMenus(menus: TransformData[]): SystemModel.Menu[] {
             children: fromMenus(menu.children),
           } satisfies SystemModel.Menu;
         })
-        .sort((a, b) => (a.meta.order ?? -1) - (b.meta.order ?? -1))
+        // 未设置排序的项默认放到最后
+        .sort((a, b) => (a.meta.order ?? 999_999) - (b.meta.order ?? 999_999))
     : [];
 }
 

+ 45 - 0
apps/health-remedy/src/api/model/operate.ts

@@ -0,0 +1,45 @@
+import type { TransformData } from '#/api';
+import type { OperateModel } from '#/api/method/operate';
+
+import { fromRow } from '#/api/model';
+
+export function fromRecord(data?: TransformData): OperateModel.Record {
+  return {
+    ...fromRow(data),
+    id: data?.id,
+    recordId: data?.recordId,
+    name: data?.name,
+    itemName: data?.itemName,
+    acupointName: data?.acupointName,
+    describe: data?.describe,
+    operateUserName: data?.operateUserName,
+    operateDate: data?.operateDate,
+    treatmentImageUrl: data?.treatmentImageUrl,
+    treatmentTime: data?.treatmentTime,
+    issueInstitutionName: data?.issueInstitutionName,
+    issueDoctorName: data?.issueDoctorName,
+    issueDate: data?.issueDate,
+    remark: data?.remark,
+    status: data?.status,
+  };
+}
+
+export function toRecord(data?: Partial<OperateModel.Record>): TransformData {
+  return {
+    id: data?.id,
+    recordId: data?.recordId,
+    name: data?.name,
+    itemName: data?.itemName,
+    describe: data?.describe,
+    operateUserName: data?.operateUserName,
+    operateDate: data?.operateDate,
+    treatmentImageUrl: data?.treatmentImageUrl,
+    issueInstitutionName: data?.issueInstitutionName,
+    issueDoctorName: data?.issueDoctorName,
+    issueDate: data?.issueDate,
+    treatmentTime: data?.treatmentTime,
+    remark: data?.remark,
+    status: data?.status,
+    acupointName: data?.acupointName,
+  };
+}

+ 32 - 0
apps/health-remedy/src/api/model/organization.ts

@@ -0,0 +1,32 @@
+import type { SystemModel, TransformData } from '#/api';
+
+import { fromRow } from '#/api/model';
+
+export function fromOrganization(
+  data?: TransformData,
+): SystemModel.Organization {
+  return {
+    ...fromRow(data),
+    id: data?.pid,
+    name: data?.name,
+    code: data?.code,
+    parentInstitutionId: data?.parentInstitutionId,
+    parentinstitutionSelsourceName: data?.parentinstitutionSelsourceName,
+    createTime: data?.createTime,
+    createUser: data?.createUser,
+  };
+}
+
+export function toOrganization(
+  data?: Partial<SystemModel.Organization>,
+): TransformData {
+  return {
+    pid: data?.id,
+    name: data?.name,
+    code: data?.code,
+    parentInstitutionId: data?.parentInstitutionId,
+    parentinstitutionSelsourceName: data?.parentinstitutionSelsourceName,
+    createTime: data?.createTime,
+    createUser: data?.createUser,
+  };
+}

+ 36 - 0
apps/health-remedy/src/api/model/patient.ts

@@ -0,0 +1,36 @@
+import type { TransformData } from '#/api';
+import type { TreatmentModel } from '#/api/method/treatment';
+
+export function fromPatient(data?: TransformData): TreatmentModel.Patient {
+  return {
+    patientId: data?.patientId,
+    name: data?.name,
+    sex: data?.sex,
+    age: data?.age,
+    phone: data?.phone,
+    itemVOS: data?.itemVOS?.map((item: any) => ({
+      id: item?.id,
+      itemCode: item?.itemCode,
+      itemName: item?.itemName,
+      itemState: item?.itemState,
+    })),
+  };
+}
+
+export function toPatient(
+  data?: Partial<TreatmentModel.Patient>,
+): TransformData {
+  return {
+    patientId: data?.patientId,
+    name: data?.name,
+    sex: data?.sex,
+    age: data?.age,
+    phone: data?.phone,
+    itemVOS: data?.itemVOS?.map((item: any) => ({
+      id: item?.id,
+      itemCode: item?.itemCode,
+      itemName: item?.itemName,
+      itemState: item?.itemState,
+    })),
+  };
+}

+ 32 - 0
apps/health-remedy/src/api/model/project.ts

@@ -0,0 +1,32 @@
+import type { SystemModel, TransformData } from '#/api';
+
+import { fromRow } from '#/api/model';
+
+export function fromProject(data?: TransformData): SystemModel.Project {
+  return {
+    ...fromRow(data),
+    id: data?.id,
+    itemName: data?.itemName,
+    sourceName: data?.sourceName,
+    createDate: data?.createDate,
+    updateDate: data?.updateDate,
+    institutionName: data?.institutionName,
+    institutionId: data?.institutionId,
+    institutionCode: data?.institutionCode,
+    sourceCode: data?.sourceCode,
+  };
+}
+
+export function toProject(data?: Partial<SystemModel.Project>): TransformData {
+  return {
+    pid: data?.id,
+    itemName: data?.itemName,
+    sourceName: data?.sourceName,
+    createDate: data?.createDate,
+    updateDate: data?.updateDate,
+    institutionName: data?.institutionName,
+    institutionId: data?.institutionId,
+    institutionCode: data?.institutionCode,
+    sourceCode: data?.sourceCode,
+  };
+}

+ 59 - 0
apps/health-remedy/src/api/model/register.ts

@@ -0,0 +1,59 @@
+import type { TransformData } from '#/api';
+import type { RegisterModel } from '#/api/method/register';
+
+import { fromRow } from '#/api/model';
+
+export function fromRegister(data?: TransformData): RegisterModel.Register {
+  return {
+    ...fromRow(data),
+    id: data?.id,
+    name: data?.name,
+    itemName: data?.itemName,
+    itemCode: data?.itemCode,
+    itemState: data?.itemState,
+    totalNum: data?.totalNum,
+    completeNum: data?.completeNum,
+    describe: data?.describe,
+    issueDate: data?.issueDate,
+    issueDoctorId: data?.issueDoctorId,
+    issueDoctorName: data?.issueDoctorName,
+    issueInstitutionId: data?.issueInstitutionId,
+    issueInstitutionName: data?.issueInstitutionName,
+    progressStatus: data?.progressStatus,
+    finishTime: data?.finishTime,
+    nextDate: data?.nextDate,
+    patientId: data?.patientId,
+    phone: data?.phone,
+    sex: data?.sex,
+    age: data?.age,
+    writeoff: data?.writeoff,
+  };
+}
+
+export function toRegister(
+  data?: Partial<RegisterModel.Register>,
+): TransformData {
+  return {
+    id: data?.id,
+    name: data?.name,
+    itemName: data?.itemName,
+    itemCode: data?.itemCode,
+    itemState: data?.itemState,
+    totalNum: data?.totalNum,
+    completeNum: data?.completeNum,
+    describe: data?.describe,
+    issueDate: data?.issueDate,
+    issueDoctorId: data?.issueDoctorId,
+    issueDoctorName: data?.issueDoctorName,
+    issueInstitutionId: data?.issueInstitutionId,
+    issueInstitutionName: data?.issueInstitutionName,
+    progressStatus: data?.progressStatus,
+    finishTime: data?.finishTime,
+    nextDate: data?.nextDate,
+    patientId: data?.patientId,
+    phone: data?.phone,
+    sex: data?.sex,
+    age: data?.age,
+    writeoff: data?.writeoff,
+  };
+}

+ 23 - 0
apps/health-remedy/src/api/model/tab.ts

@@ -0,0 +1,23 @@
+import type { TransformData } from '#/api';
+import type { TreatmentModel } from '#/api/method/treatment';
+
+import { fromRow } from '#/api/model';
+
+export function fromTreatmentTab(
+  data?: TransformData,
+): TreatmentModel.TreatmentTab {
+  return {
+    ...fromRow(data),
+    itemName: data?.itemName,
+    patientNum: data?.patientNum,
+  };
+}
+
+export function toTreatmentTab(
+  data?: Partial<TreatmentModel.TreatmentTab>,
+): TransformData {
+  return {
+    itemName: data?.itemName,
+    patientNum: data?.patientNum,
+  };
+}

+ 74 - 0
apps/health-remedy/src/api/model/treatmentDetail.ts

@@ -0,0 +1,74 @@
+import type { TransformData } from '#/api';
+import type { TreatmentModel } from '#/api/method/treatment';
+
+export function fromTreatmentDetail(
+  data?: TransformData,
+): TreatmentModel.TreatmentDetail {
+  return {
+    id: data?.id,
+    acupoints: data?.acupoints,
+    behavior: data?.behavior,
+    completeNum: data?.completeNum,
+    constitution: data?.constitution,
+    createTime: data?.createTime,
+    describe: data?.describe,
+    diagnosis: data?.diagnosis,
+    finishTime: data?.finishTime,
+    isDeleted: data?.isDeleted,
+    issueDate: data?.issueDate,
+    issueDoctorId: data?.issueDoctorId,
+    issueDoctorName: data?.issueDoctorName,
+    issueInstitutionId: data?.issueInstitutionId,
+    issueInstitutionName: data?.issueInstitutionName,
+    frequency: data?.frequency,
+    itemCode: data?.itemCode,
+    itemName: data?.itemName,
+    itemState: data?.itemState,
+    nextDate: data?.nextDate,
+    operates: data?.operates,
+    patientId: data?.patientId,
+    planCode: data?.planCode,
+    planType: data?.planType,
+    progressStatus: data?.progressStatus,
+    totalNum: data?.totalNum,
+    updateTime: data?.updateTime,
+    writeOffDate: data?.writeOffDate,
+    writeoff: data?.writeoff,
+  };
+}
+
+export function toTreatmentDetail(
+  data?: Partial<TreatmentModel.TreatmentDetail>,
+): TransformData {
+  return {
+    id: data?.id,
+    acupoints: data?.acupoints,
+    behavior: data?.behavior,
+    completeNum: data?.completeNum,
+    constitution: data?.constitution,
+    createTime: data?.createTime,
+    describe: data?.describe,
+    diagnosis: data?.diagnosis,
+    frequency: data?.frequency,
+    itemCode: data?.itemCode,
+    itemName: data?.itemName,
+    itemState: data?.itemState,
+    finishTime: data?.finishTime,
+    isDeleted: data?.isDeleted,
+    issueDate: data?.issueDate,
+    issueDoctorId: data?.issueDoctorId,
+    issueDoctorName: data?.issueDoctorName,
+    issueInstitutionId: data?.issueInstitutionId,
+    issueInstitutionName: data?.issueInstitutionName,
+    nextDate: data?.nextDate,
+    operates: data?.operates,
+    patientId: data?.patientId,
+    planCode: data?.planCode,
+    planType: data?.planType,
+    progressStatus: data?.progressStatus,
+    totalNum: data?.totalNum,
+    updateTime: data?.updateTime,
+    writeOffDate: data?.writeOffDate,
+    writeoff: data?.writeoff,
+  };
+}

+ 14 - 2
apps/health-remedy/src/api/model/user.ts

@@ -5,12 +5,19 @@ import { fromRole, fromRow, toRole } from '#/api/model';
 export function fromUser(data?: TransformData): SystemModel.User {
   return {
     ...fromRow(data),
-    id: data?.pid,
+    id: data?.id,
     access: data?.userid,
     name: data?.username,
     worker: data?.jobnumber,
     mobile: data?.mobile,
+    pid: data?.pid,
     roles: data?.roles?.map((item: TransformData) => fromRole(item)) ?? [],
+    sititutionId: data?.sititutionId,
+    status: data?.stateSel === 0 || data?.stateSel === '0' ? 0 : 1,
+    hospitalName: data?.hospitalName,
+    roleNames:
+      data?.roles?.map((item: TransformData) => item.rolename).join(',') ?? '',
+    createUser: data?.createUser,
   };
 }
 
@@ -20,13 +27,18 @@ export function toUser(data?: Partial<SystemModel.User>): TransformData {
       typeof item === 'string' ? { pid: item } : toRole(item),
     ) ?? [];
   return {
-    pid: data?.id,
+    pid: data?.pid,
     userid: data?.access,
     username: data?.name,
     password: data?.password,
     jobnumber: data?.worker,
     mobile: data?.mobile,
+    hospitalName: data?.hospitalName,
     roles: roles.length > 0 ? roles : void 0,
     roleIds: roles.map((item) => item.pid).join(',') || void 0,
+    sititutionId: data?.sititutionId,
+    // 查询时:当 status 为空/undefined 时传 null;创建/编辑时为 0/1 则直传
+    stateSel:
+      data?.status === 0 || data?.status === 1 ? (data.status as 0 | 1) : null,
   };
 }

+ 27 - 1
apps/health-remedy/src/layouts/basic.vue

@@ -2,6 +2,7 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
 import { useWatermark } from '@vben/hooks';
@@ -10,6 +11,7 @@ import { preferences } from '@vben/preferences';
 import { useAccessStore, useUserStore } from '@vben/stores';
 
 import LoginForm from '#/core/authentication/login.vue';
+import { $t } from '#/locales';
 import { useAuthStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([]);
@@ -17,6 +19,7 @@ const notifications = ref<NotificationItem[]>([]);
 const userStore = useUserStore();
 const authStore = useAuthStore();
 const accessStore = useAccessStore();
+const router = useRouter();
 const { destroyWatermark, updateWatermark } = useWatermark();
 
 computed(() => notifications.value.some((item) => !item.isRead));
@@ -36,6 +39,29 @@ async function handleLogout() {
   await authStore.logout(false);
 }
 
+const dropdownMenus = computed(() => [
+  {
+    text: $t('system.title'),
+    icon: 'ion:settings-outline',
+    handler: () => router.push('/system'),
+  },
+  {
+    text: $t('system.role.title'),
+    icon: 'mdi:account-group',
+    handler: () => router.push('/system/role'),
+  },
+  {
+    text: $t('system.user.title'),
+    icon: 'charm:organisation',
+    handler: () => router.push('/system/user'),
+  },
+  {
+    text: $t('system.organization.title'),
+    icon: 'charm:organisation',
+    handler: () => router.push('/system/organization'),
+  },
+]);
+
 watch(
   () => preferences.app.watermark,
   async (enable) => {
@@ -59,7 +85,7 @@ watch(
       <UserDropdown
         :avatar
         :text
-        :menus="[]"
+        :menus="dropdownMenus"
         :description="userStore.userInfo?.desc"
         @logout="handleLogout"
       />

+ 1 - 1
apps/health-remedy/src/locales/langs/zh-CN/authentication.json

@@ -1,5 +1,5 @@
 {
   "welcomeBack": "欢迎回来",
-  "pageTitle": "中医智能导诊管理系统",
+  "pageTitle": "中医特色治疗智能辅助系统",
   "pageDesc": "智能、高效、管理"
 }

+ 24 - 0
apps/health-remedy/src/locales/langs/zh-CN/operate.json

@@ -0,0 +1,24 @@
+{
+  "title": "操作记录",
+  "record": {
+    "_": "记录",
+    "title": "查看",
+    "list": "记录列表",
+    "name": "项目名称",
+    "operationUser": "操作人",
+    "operationTime": "操作时间",
+    "operationRemark": "治疗备注",
+    "treatmentTime": "治疗时间",
+    "treatmentPhoto": "治疗照片",
+    "treatmentDescription": "治疗说明",
+    "acupoint": "穴位",
+    "description": "说明",
+    "patientName": "患者姓名",
+    "openInstitution": "开具机构",
+    "openTime": "开具时间",
+    "openDoctor": "开具医生",
+    "minutes": "分钟",
+    "startTime": "请选择开始时间",
+    "endTime": "请选择结束时间"
+  }
+}

+ 30 - 0
apps/health-remedy/src/locales/langs/zh-CN/register.json

@@ -0,0 +1,30 @@
+{
+  "title": "登记",
+  "register": {
+    "_": "记录",
+    "title": "登记维护",
+    "list": "登记列表",
+    "name": "姓名",
+    "gender": "性别",
+    "age": "年龄",
+    "phone": "手机号码",
+    "status": "状态",
+    "openTime": "开具时间",
+    "projectName": "项目名称",
+    "totalTimes": "累计次数",
+    "status1": "进行中",
+    "status2": "已完成",
+    "title1": "患者以下项目为",
+    "title2": "请选择今天要做的项目",
+    "verify": "核销",
+    "cancel": "取消",
+    "verifyError": "请选择要核销的项目",
+    "confirm": "确定",
+    "tips": "提醒用户打开核销码",
+    "verifySuccess": "核销成功",
+    "verifyProject": "核销以下项目",
+    "nextTime": "下一次时间",
+    "checked": "选中",
+    "scan": "请扫码"
+  }
+}

+ 33 - 6
apps/health-remedy/src/locales/langs/zh-CN/system.json

@@ -6,19 +6,46 @@
     "list": "角色列表",
     "name": "角色名称",
     "code": "角色标识",
-    "status": "状态",
+    "status": "是否启用",
+    "enabledStatus": "启用状态",
     "remark": "备注",
-    "setPermissions": "授权"
+    "setPermissions": "菜单权限",
+    "dataAuthority": "数据权限",
+    "oneself": "本人",
+    "oneInstitution": "本机构"
   },
   "user": {
-    "_": "账号",
-    "title": "账号管理",
-    "list": "账号列表",
-    "access": "账号",
+    "_": "用户",
+    "title": "用户管理",
+    "list": "用户列表",
+    "access": "系统账号",
     "name": "姓名",
     "worker": "工号",
     "password": "密码",
     "mobile": "手机号码",
     "status": "状态"
+  },
+  "organization": {
+    "_": "机构",
+    "title": "机构管理",
+    "list": "机构列表",
+    "name": "机构名称",
+    "code": "机构编码",
+    "superior": "上级机构",
+    "createTime": "创建时间",
+    "createUser": "创建者",
+    "input": "请输入"
+  },
+  "project": {
+    "_": "项目",
+    "title": "项目管理",
+    "list": "项目列表",
+    "itemName": "项目名称",
+    "institutionName": "机构名称",
+    "institutionCode": "机构编码",
+    "sourcePlatform": "来源平台",
+    "password": "请输入密码",
+    "codeWord": "密码",
+    "passwordRule": "请输入8-18位数字、大写字母、小写字母最少两种组合的密码"
   }
 }

+ 49 - 0
apps/health-remedy/src/locales/langs/zh-CN/treatment.json

@@ -0,0 +1,49 @@
+{
+  "title": "治疗",
+  "patient": {
+    "_": "患者",
+    "noStart": "未开始",
+    "treatmenting": "治疗中",
+    "complete": "治疗完成",
+    "unknown": "未知",
+    "searchPlaceholder": "请输入姓名/手机号码",
+    "noPatientData": "暂无患者数据",
+    "age": "岁"
+  },
+  "detail": {
+    "_": "患者详情",
+    "operateDate": "操作时间",
+    "operateUser": "操作人",
+    "treatmentRemark": "治疗备注",
+    "treatmentPhoto": "治疗照片",
+    "sequence": "序号",
+    "acupointOrMeridian": "穴位/经络",
+    "input": "请输入",
+    "treatmentTime": "本次治疗时间",
+    "minutes": "分钟",
+    "startTreatment": "开始治疗",
+    "saveSuccess": "开始治疗",
+    "saveFailure": "保存失败",
+    "detail": "详情",
+    "getAcupointDetailFailure": "获取穴位详情失败",
+    "getMeridianDetailFailure": "获取经络详情失败",
+    "remainingTime": "剩余时间",
+    "schemeType": "方案类型",
+    "frequency": "频次",
+    "totalNum": "累计次数",
+    "diagnosis": "诊断",
+    "appearance": "表现",
+    "constitution": "体质",
+    "openOrganization": "开具机构",
+    "openDoctor": "开具医生",
+    "openTime": "开具时间",
+    "acupointName": "穴位名称",
+    "photo": "图示",
+    "start": "开始",
+    "endSuccess": "治疗完成",
+    "endFailure": "治疗失败,请稍后重试"
+  },
+  "tabs": {
+    "getUntreatedProjectsFailure": "获取未治疗的项目失败"
+  }
+}

+ 4 - 0
apps/health-remedy/src/preferences.ts

@@ -21,6 +21,9 @@ export const overridesPreferences = defineOverridesPreferences({
   logo: {
     source: import.meta.env.VITE_APP_LOGO || '',
   },
+  tabbar: {
+    enable: false,
+  },
   copyright: {
     enable: true,
     date: dayjs().format('YYYY'),
@@ -34,5 +37,6 @@ export const overridesPreferences = defineOverridesPreferences({
     globalSearch: false,
     refresh: false,
     notification: false,
+    themeToggle: false,
   },
 });

+ 0 - 1
apps/health-remedy/src/router/access.ts

@@ -21,7 +21,6 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
     BasicLayout,
     IFrameView,
   };
-
   return await generateAccessible(preferences.app.accessMode, {
     ...options,
     fetchMenuListAsync: async (permissions) => {

+ 117 - 0
apps/health-remedy/src/views/operate/record/data.ts

@@ -0,0 +1,117 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { OperateModel } from '#/api/method/operate';
+
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'itemName',
+      label: $t('operate.record.name'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'operateUserName',
+      label: $t('operate.record.operationUser'),
+    },
+    {
+      component: 'RangePicker',
+      fieldName: 'operateTimeRange',
+      label: $t('operate.record.operationTime'),
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+        placeholder: [
+          $t('operate.record.startTime'),
+          $t('operate.record.endTime'),
+        ],
+        onChange: (_dates: any, _dateStrings: string[]) => {
+          // 时间范围字段分离逻辑在list.vue的handleValuesChange中处理
+        },
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'startTime',
+      label: '',
+      componentProps: {
+        style: { display: 'none' },
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'endTime',
+      label: '',
+      componentProps: {
+        style: { display: 'none' },
+      },
+    },
+  ];
+}
+
+export function useUserTableColumns<T = OperateModel.Record>(
+  onActionClick?: OnActionClickFn<T>,
+): VxeTableGridOptions<T>['columns'] {
+  return [
+    {
+      field: 'itemName',
+      title: $t('operate.record.name'),
+      minWidth: 100,
+    },
+    {
+      field: 'operateDate',
+      title: $t('operate.record.operationTime'),
+      minWidth: 100,
+    },
+    {
+      field: 'operateUserName',
+      title: $t('operate.record.operationUser'),
+      minWidth: 100,
+    },
+    {
+      field: 'remark',
+      title: $t('operate.record.operationRemark'),
+      minWidth: 100,
+    },
+    {
+      field: 'name',
+      title: $t('operate.record.patientName'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueInstitutionName',
+      title: $t('operate.record.openInstitution'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueDoctorName',
+      title: $t('operate.record.openDoctor'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueDate',
+      title: $t('operate.record.openTime'),
+      minWidth: 100,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        name: 'CellOperation',
+        options: [{ code: 'view', text: '查看' }],
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('operate.record._'),
+          onClick: onActionClick,
+        },
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 100,
+    },
+  ];
+}

+ 89 - 0
apps/health-remedy/src/views/operate/record/list.vue

@@ -0,0 +1,89 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { listRecordsMethod } from '#/api/method/operate';
+
+import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useUserSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useUserTableColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          // 处理时间范围字段分离
+          const processedValues = { ...formValues };
+          if (
+            processedValues.operateTimeRange &&
+            Array.isArray(processedValues.operateTimeRange)
+          ) {
+            const [startTime, endTime] = processedValues.operateTimeRange;
+            processedValues.startTime = startTime;
+            processedValues.endTime = endTime;
+            delete processedValues.operateTimeRange; // 删除原始字段
+          }
+          return listRecordsMethod(
+            page.currentPage,
+            page.pageSize,
+            processedValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
+  } as VxeTableGridOptions<SystemModel.User>,
+});
+// 表格的操作 只处理查看功能
+function onActionClick(e: OnActionClickParams<SystemModel.User>) {
+  switch (e.code) {
+    case 'view': {
+      onViewHandle(e.row);
+      break;
+    }
+  }
+}
+// 刷新
+function onRefresh() {
+  gridApi.query();
+}
+
+// 查看
+function onViewHandle(row: SystemModel.User) {
+  formModalApi.setData(row ?? {}).open();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <Grid />
+  </Page>
+</template>

+ 93 - 0
apps/health-remedy/src/views/operate/record/modules/form.vue

@@ -0,0 +1,93 @@
+<script lang="ts" setup>
+import type { OperateModel } from '#/api/method/operate';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Divider, Image } from 'ant-design-vue';
+
+import { getRecordDetailMethod } from '#/api/method/operate';
+import { $t } from '#/locales';
+
+const detail = ref<OperateModel.Record>({} as OperateModel.Record);
+
+const title = computed(() => $t('operate.record.title'));
+
+const [Modal, modalApi] = useVbenModal({
+  showConfirmButton: false,
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<OperateModel.Record>();
+      if (data && data.id) {
+        const res = await getRecordDetailMethod(data.id);
+        detail.value = res || ({} as OperateModel.Record);
+      }
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="title">
+    <div class="select-text p-4">
+      <div class="space-y-4 rounded-b">
+        <div class="font-bold" v-if="detail.itemName">
+          {{ $t('operate.record.name') }}:
+          {{ detail.itemName || '-' }}
+        </div>
+        <div class="font-bold" v-if="detail.operateDate">
+          {{ $t('operate.record.operationTime') }}:
+          {{ detail.operateDate || '-' }}
+        </div>
+        <div v-if="detail.operateUserName">
+          {{ $t('operate.record.operationUser') }}:
+          {{ detail.operateUserName || '-' }}
+        </div>
+        <div v-if="detail.treatmentTime">
+          {{ $t('operate.record.treatmentTime') }}:
+          {{ detail.treatmentTime || '-' }}{{ $t('operate.record.minutes') }}
+        </div>
+        <div v-if="detail.remark">
+          {{ $t('operate.record.treatmentDescription') }}:
+          {{ detail.remark || '-' }}
+        </div>
+        <div class="flex items-start space-x-2" v-if="detail.treatmentImageUrl">
+          <span class="leading-8">
+            {{ $t('operate.record.treatmentPhoto') }}:
+          </span>
+          <Image
+            v-if="detail.treatmentImageUrl"
+            :src="detail.treatmentImageUrl"
+            alt=""
+            class="border object-cover"
+            style="width: 180px; height: 90px"
+          />
+          <span v-else class="leading-8">-</span>
+        </div>
+        <Divider />
+        <div v-if="detail.name">
+          {{ $t('operate.record.patientName') }}:
+          {{ detail.name || '-' }}
+        </div>
+        <div class="flex">
+          <div v-if="detail.issueDoctorName" class="mr-10">
+            {{ $t('operate.record.openDoctor') }}:
+            {{ detail.issueDoctorName || '-' }}
+          </div>
+          <div v-if="detail.issueDate">
+            {{ $t('operate.record.openTime') }}:{{ detail.issueDate || '-' }}
+          </div>
+        </div>
+        <div v-if="detail.acupointName">
+          {{ $t('operate.record.acupoint') }}:
+          {{ detail.acupointName?.join(',') || '-' }}
+        </div>
+        <div v-if="detail.describe">
+          {{ $t('operate.record.description') }}:
+          {{ detail.describe || '-' }}
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>

+ 113 - 0
apps/health-remedy/src/views/register/register/data.ts

@@ -0,0 +1,113 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { RegisterModel } from '#/api/method/register';
+
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('register.register.name'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'phone',
+      label: $t('register.register.phone'),
+    },
+    {
+      component: 'Select',
+      fieldName: 'progressStatus',
+      label: $t('register.register.status'),
+      componentProps: {
+        options: [
+          { label: $t('register.register.status1'), value: 1 },
+          { label: $t('register.register.status2'), value: 2 },
+        ],
+      },
+    },
+  ];
+}
+
+export function useUserTableColumns<T = RegisterModel.Register>(
+  onActionClick?: OnActionClickFn<T>,
+): VxeTableGridOptions<T>['columns'] {
+  return [
+    { type: 'seq', title: $t('table.column.seq'), width: 50 },
+    {
+      field: 'name',
+      title: $t('register.register.name'),
+      minWidth: 100,
+    },
+    {
+      field: 'sex',
+      title: $t('register.register.gender'),
+      minWidth: 100,
+    },
+    {
+      field: 'age',
+      title: $t('register.register.age'),
+      minWidth: 100,
+    },
+    {
+      field: 'phone',
+      title: $t('register.register.phone'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueDate',
+      title: $t('register.register.openTime'),
+      minWidth: 100,
+    },
+    {
+      field: 'itemName',
+      title: $t('register.register.projectName'),
+      minWidth: 100,
+    },
+    {
+      field: 'totalNum',
+      title: $t('register.register.totalTimes'),
+      minWidth: 100,
+      slots: {
+        default: 'totalNum',
+      },
+    },
+    {
+      field: 'progressStatus',
+      title: $t('register.register.status'),
+      minWidth: 100,
+      slots: {
+        default: 'progressStatus',
+      },
+    },
+    {
+      align: 'center',
+      cellRender: {
+        name: 'CellOperation',
+        options: [
+          {
+            code: 'register',
+            text: '登记',
+            show(row: any) {
+              return !(
+                Number(row?.writeoff) === 1 && Number(row?.itemState) !== 2
+              );
+            },
+          },
+        ],
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('register.register._'),
+          onClick: onActionClick,
+        },
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 100,
+    },
+  ];
+}

+ 90 - 0
apps/health-remedy/src/views/register/register/list.vue

@@ -0,0 +1,90 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { RegisterModel } from '#/api/method/register';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { listRegisterMethod } from '#/api/method/register';
+import { $t } from '#/locales';
+
+import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useUserSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useUserTableColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listRegisterMethod(
+            page.currentPage,
+            page.pageSize,
+            formValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
+  } as VxeTableGridOptions<RegisterModel.Register>,
+});
+// 表格的操作 登记功能
+function onActionClick(e: OnActionClickParams<RegisterModel.Register>) {
+  switch (e.code) {
+    case 'register': {
+      onRegisterHandle(e.row);
+      break;
+    }
+  }
+}
+// 刷新
+function onRefresh() {
+  gridApi.query();
+}
+
+// 登记
+function onRegisterHandle(row?: RegisterModel.Register) {
+  formModalApi.setData({ row: row ?? {}, mode: 'register' }).open();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <Grid>
+      <template #totalNum="{ row }">
+        {{ row.completeNum }}/{{ row.totalNum }}
+      </template>
+      <template #progressStatus="{ row }">
+        {{
+          row.progressStatus === 1
+            ? $t('register.register.status1')
+            : $t('register.register.status2')
+        }}
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 229 - 0
apps/health-remedy/src/views/register/register/modules/form.vue

@@ -0,0 +1,229 @@
+<script lang="ts" setup>
+import type { RegisterModel } from '#/api/method/register';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  getWriteOffListMethod,
+  writeOffItemMethod,
+} from '#/api/method/register';
+import { $t } from '#/locales';
+
+const emit = defineEmits<{
+  success: [];
+}>();
+
+// 模式:register | scan | verify
+const mode = ref<'register' | 'verify'>('register');
+
+// 模拟数据
+const data = ref<RegisterModel.Register[]>([]);
+
+const columns = [
+  {
+    title: $t('register.register.checked'),
+    width: 100,
+    type: 'checkbox' as const,
+    headerCheckbox: true,
+  },
+  {
+    field: 'itemName',
+    title: $t('register.register.projectName'),
+    minWidth: 150,
+  },
+  {
+    field: 'nextDate',
+    title: $t('register.register.nextTime'),
+    minWidth: 150,
+  },
+];
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions: {
+    columns,
+    data: data.value,
+    height: 300,
+    pagerConfig: {
+      enabled: false,
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+    checkboxConfig: {
+      checkField: 'selected',
+      labelField: 'label',
+      trigger: 'row',
+    },
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: false,
+    //   search: false,
+    //   zoom: false,
+    // },
+  },
+  gridEvents: {
+    checkboxChange({ records }: any) {
+      const idSet = new Set(records.map((r: any) => r.id));
+      data.value.forEach((row) => {
+        row.selected = idSet.has(row.id);
+      });
+    },
+    checkboxAll({ records }: any) {
+      const idSet = new Set(records.map((r: any) => r.id));
+      data.value.forEach((row) => {
+        row.selected = idSet.has(row.id);
+      });
+    },
+  },
+});
+const selectedItems = computed(() => {
+  return data.value.filter((item) => item.selected);
+});
+
+async function handleVerify() {
+  if (selectedItems.value.length === 0) {
+    message.error($t('register.register.verifyError'));
+    return;
+  }
+  if (selectedItems.value.length > 0) {
+    const ids = selectedItems.value.map((item) => item.id);
+    try {
+      await writeOffItemMethod(ids);
+      message.success($t('register.register.verifySuccess'));
+      // 触发刷新列表数据
+      emit('success');
+      // 直接切换到扫码视图,不关闭弹窗
+      mode.value = 'verify';
+    } catch (error: any) {
+      message.error(error.message || $t('register.register.verifyError'));
+    }
+  }
+}
+
+// function handleVerify() {
+//   if (selectedItems.value.length === 0) {
+//     message.error($t('register.register.verifyError'));
+//     return;
+//   }
+//   // 直接切换到扫码视图,不关闭弹窗
+//   mode.value = 'verify';
+// }
+
+const [Modal, modalApi] = useVbenModal({
+  showConfirmButton: false,
+  showCancelButton: false,
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      const payload = (modalApi.getData?.() as any) || {};
+      mode.value = payload.mode ?? 'register';
+      if (mode.value === 'register' && payload.row && payload.row?.id) {
+        const res = await getWriteOffListMethod(payload.row?.id);
+        if (res && res.length > 0) {
+          data.value = res as any;
+          data.value.forEach((item) => {
+            item.selected = false;
+            item.label = '选中';
+          });
+        }
+        gridApi.grid.reloadData(data.value);
+      }
+    }
+  },
+});
+function handleCancel() {
+  modalApi.close();
+}
+</script>
+
+<template>
+  <Modal
+    :title="
+      mode === 'register'
+        ? $t('register.title')
+        : mode === 'verify'
+          ? $t('register.register.verify')
+          : ''
+    "
+  >
+    <div class="p-4">
+      <!-- 登记内容 -->
+      <template v-if="mode === 'register'">
+        <div class="mb-4">
+          {{ $t('register.register.title1') }}"
+          {{ $t('register.register.status1') }}",
+          {{ $t('register.register.title2') }}
+        </div>
+
+        <Grid />
+
+        <div class="mt-4 flex justify-end gap-3">
+          <Button @click="handleCancel">
+            {{ $t('register.register.cancel') }}
+          </Button>
+          <Button type="primary" @click="handleVerify" v-if="data?.length > 0">
+            {{ $t('register.register.verify') }}
+          </Button>
+        </div>
+      </template>
+
+      <!-- 扫码内容 -->
+      <template v-else>
+        <div class="p-6 text-center">
+          <div class="mb-4 text-2xl font-bold">
+            <!-- {{
+              mode === 'scan'
+                ? $t('register.register.scan')
+                : $t('register.register.verifySuccess')
+            }} -->
+            {{ $t('register.register.verifySuccess') }}
+          </div>
+          <div class="mb-6 text-gray-600">
+            {{ $t('register.register.tips') }}
+          </div>
+
+          <div class="mb-4 text-left">
+            <div class="mb-3 font-medium">
+              {{ $t('register.register.verifyProject') }}:
+            </div>
+            <div class="overflow-hidden rounded-lg border">
+              <div class="border-b bg-gray-50 px-4 py-2">
+                <div class="grid grid-cols-2 gap-4 font-medium">
+                  <div>{{ $t('register.register.projectName') }}</div>
+                  <div>{{ $t('register.register.nextTime') }}</div>
+                </div>
+              </div>
+              <div
+                v-for="item in selectedItems"
+                :key="item.id"
+                class="border-b px-4 py-3 last:border-b-0"
+              >
+                <div class="grid grid-cols-2 gap-4">
+                  <div>{{ item.itemName }}</div>
+                  <div>{{ item.nextDate }}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="mt-2 flex justify-end">
+            <!-- <Button @click="handleCancel" v-if="mode === 'verify'">
+              {{ $t('register.register.cancel') }}
+            </Button> -->
+            <!-- <Button
+              @click="handleConfirm"
+              type="primary"
+              v-if="mode === 'scan'"
+            >
+              {{ $t('register.register.confirm') }}
+            </Button> -->
+          </div>
+        </div>
+      </template>
+    </div>
+  </Modal>
+</template>

+ 110 - 0
apps/health-remedy/src/views/system/organization/data.ts

@@ -0,0 +1,110 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api/method/system';
+
+import { listOrganizationsMethodAll } from '#/api/method/system';
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.organization.name'),
+    },
+  ];
+}
+
+export function useUserTableColumns<T = SystemModel.Organization>(
+  onActionClick?: OnActionClickFn<T>,
+): VxeTableGridOptions<T>['columns'] {
+  return [
+    { type: 'seq', title: $t('table.column.seq'), width: 50 },
+    {
+      field: 'name',
+      title: $t('system.organization.name'),
+      minWidth: 100,
+    },
+    {
+      field: 'code',
+      title: $t('system.organization.code'),
+      minWidth: 100,
+    },
+    {
+      field: 'parentinstitutionSelsourceName',
+      title: $t('system.organization.superior'),
+      minWidth: 100,
+    },
+    {
+      field: 'createTime',
+      title: $t('system.organization.createTime'),
+      minWidth: 100,
+    },
+    {
+      field: 'createUser',
+      title: $t('system.organization.createUser'),
+      minWidth: 100,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.user._'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 130,
+    },
+  ];
+}
+
+export function useUserFormSchema(
+  current?: Pick<SystemModel.Organization, 'id' | 'name'>,
+): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.organization.name'),
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: $t('system.organization.input'),
+      },
+      fieldName: 'code',
+      label: $t('system.organization.code'),
+      rules: 'required',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: listOrganizationsMethodAll,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'pid',
+        childrenField: 'children',
+        afterFetch: (res: SystemModel.Organization[]) => {
+          if (!current) return res;
+          return Array.isArray(res)
+            ? res.filter(
+                (item) => item.pid !== current.id && item.name !== current.name,
+              )
+            : res;
+        },
+      },
+      fieldName: 'parentInstitutionId',
+      label: $t('system.organization.superior'),
+      // rules: 'selectRequired',
+    },
+  ];
+}

+ 113 - 0
apps/health-remedy/src/views/system/organization/list.vue

@@ -0,0 +1,113 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteOrganizationMethod, listOrganizationsMethod } from '#/api';
+import { $t } from '#/locales';
+
+import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useUserSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useUserTableColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listOrganizationsMethod(
+            page.currentPage,
+            page.pageSize,
+            formValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
+  } as VxeTableGridOptions<SystemModel.User>,
+});
+// 表格的操作 删除和修改功能
+function onActionClick(e: OnActionClickParams<SystemModel.User>) {
+  switch (e.code) {
+    case 'delete': {
+      onDeleteHandle(e.row);
+      break;
+    }
+    case 'edit': {
+      onEditHandle(e.row);
+      break;
+    }
+  }
+}
+// 刷新
+function onRefresh() {
+  gridApi.query();
+}
+
+// 修改
+function onEditHandle(row?: SystemModel.User) {
+  formModalApi.setData(row ?? {}).open();
+}
+
+// 删除
+async function onDeleteHandle(row: SystemModel.Organization) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  try {
+    await deleteOrganizationMethod(row);
+    message.success({
+      content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+      key: 'action_process_msg',
+    });
+    // 删除之后重新刷新页面
+    onRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <Grid>
+      <template #toolbar-tools>
+        <Button type="primary" @click="onEditHandle()">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.organization._')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 86 - 0
apps/health-remedy/src/views/system/organization/modules/form.vue

@@ -0,0 +1,86 @@
+<script lang="ts" setup>
+import type { SystemModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { useRequest } from '@six/request';
+import { Button } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { editOrganizationMethod } from '#/api';
+import { $t } from '#/locales';
+
+import { useUserFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const edit = useRequest(editOrganizationMethod, { immediate: false }).onSuccess(
+  () => {
+    emit('success');
+  },
+);
+
+const formData = ref<SystemModel.Organization>();
+const getTitle = computed(() => {
+  return formData.value?.id
+    ? $t('ui.actionTitle.edit', [$t('system.organization._')])
+    : $t('ui.actionTitle.create', [$t('system.organization._')]);
+});
+
+const [Form, formApi] = useVbenForm({
+  // layout: 'vertical',
+  schema: useUserFormSchema(),
+  showDefaultActions: false,
+});
+
+function resetForm() {
+  formApi.resetForm();
+  formApi.setValues(formData.value || {});
+}
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (valid) {
+      modalApi.lock();
+      const data = await formApi.getValues();
+      try {
+        await edit.send({ ...formData.value, ...data });
+        await modalApi.close();
+      } finally {
+        modalApi.lock(false);
+      }
+    }
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<SystemModel.Organization>();
+      if (data) {
+        if (data.id) {
+          // 编辑态:重建 schema,传入当前机构信息(id 与 name)以便过滤自身
+          formApi.setState(() => ({
+            schema: useUserFormSchema({ id: data.id, name: data.name as any }),
+          }));
+        }
+        formData.value = data;
+        formApi.setValues(formData.value);
+      }
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <Button type="primary" danger @click="resetForm">
+          {{ $t('common.reset') }}
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>

+ 67 - 0
apps/health-remedy/src/views/system/project/data.ts

@@ -0,0 +1,67 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { SystemModel } from '#/api/method/system';
+
+import { listSourcePlatformMethod } from '#/api/method/system';
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'itemName',
+      label: $t('system.project.itemName'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'institutionName',
+      label: $t('system.project.institutionName'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'institutionCode',
+      label: $t('system.project.institutionCode'),
+    },
+    {
+      component: 'ApiSelect',
+      fieldName: 'sourceCode',
+      label: $t('system.project.sourcePlatform'),
+      componentProps: {
+        allowClear: true,
+        api: listSourcePlatformMethod,
+        class: 'w-full',
+        labelField: 'sourceName',
+        valueField: 'sourceCode',
+        childrenField: 'children',
+      },
+    },
+  ];
+}
+
+export function useUserTableColumns<
+  T = SystemModel.Project,
+>(): VxeTableGridOptions<T>['columns'] {
+  return [
+    {
+      field: 'itemName',
+      title: $t('system.project.itemName'),
+      minWidth: 100,
+    },
+    {
+      field: 'institutionName',
+      title: $t('system.project.institutionName'),
+      minWidth: 100,
+    },
+    {
+      field: 'institutionCode',
+      title: $t('system.project.institutionCode'),
+      minWidth: 100,
+    },
+    {
+      field: 'sourceName',
+      title: $t('system.project.sourcePlatform'),
+      minWidth: 100,
+    },
+  ];
+}

+ 61 - 0
apps/health-remedy/src/views/system/project/list.vue

@@ -0,0 +1,61 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { listProjectsMethod } from '#/api';
+
+import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useUserSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useUserTableColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listProjectsMethod(
+            page.currentPage,
+            page.pageSize,
+            formValues,
+          );
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
+  } as VxeTableGridOptions<SystemModel.User>,
+});
+
+function onRefresh() {
+  gridApi.query();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <Grid />
+  </Page>
+</template>

+ 5 - 0
apps/health-remedy/src/views/system/project/modules/form.vue

@@ -0,0 +1,5 @@
+<script lang="ts" setup></script>
+
+<template>
+  <div></div>
+</template>

+ 39 - 41
apps/health-remedy/src/views/system/role/data.ts

@@ -2,6 +2,7 @@ import type { VbenFormSchema } from '#/adapter/form';
 import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
 import type { SystemModel } from '#/api';
 
+import { z } from '#/adapter/form';
 import { $t } from '#/locales';
 
 export function useRoleSearchFormSchema(): VbenFormSchema[] {
@@ -27,31 +28,12 @@ export function useRoleTableColumns<T = SystemModel.Role>(
   onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
 ): VxeTableGridOptions['columns'] {
   return [
+    { type: 'seq', title: $t('table.column.seq'), width: 50 },
     {
       field: 'name',
       title: $t('system.role.name'),
       width: 200,
     },
-    {
-      field: 'code',
-      title: $t('system.role.code'),
-      width: 200,
-    },
-    {
-      cellRender: {
-        attrs: { beforeChange: onStatusChange },
-        // props: { accessRole: '超级管理员' },
-        name: onStatusChange ? 'CellSwitch' : 'CellTag',
-      },
-      field: 'status',
-      title: $t('system.role.status'),
-      width: 100,
-    },
-    {
-      field: 'remark',
-      minWidth: 100,
-      title: $t('system.role.remark'),
-    },
     {
       field: 'lastTime',
       title: $t('table.column.lastTime'),
@@ -60,13 +42,23 @@ export function useRoleTableColumns<T = SystemModel.Role>(
       field: 'lastUser',
       title: $t('table.column.lastUser'),
     },
+    {
+      field: 'createTime',
+      title: $t('table.column.createTime'),
+    },
     {
       field: 'createUser',
       title: $t('table.column.createUser'),
     },
     {
-      field: 'createTime',
-      title: $t('table.column.createTime'),
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        // props: { accessRole: '超级管理员' },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      title: $t('system.role.enabledStatus'),
+      width: 100,
     },
     {
       align: 'center',
@@ -94,31 +86,37 @@ export function useRoleFormSchema(): VbenFormSchema[] {
       label: $t('system.role.name'),
       rules: 'required',
     },
-    {
-      component: 'RadioGroup',
-      componentProps: {
-        buttonStyle: 'solid',
-        options: [
-          { label: $t('common.enabled'), value: 1 },
-          { label: $t('common.disabled'), value: 0 },
-        ],
-        optionType: 'button',
-      },
-      defaultValue: 1,
-      fieldName: 'status',
-      label: $t('system.role.status'),
-    },
-    {
-      component: 'Textarea',
-      fieldName: 'remark',
-      label: $t('system.role.remark'),
-    },
+    // {
+    //   component: 'RadioGroup',
+    //   componentProps: {
+    //     buttonStyle: 'solid',
+    //     // options: [
+    //     //   { label: $t('common.enabled'), value: 1 },
+    //     //   { label: $t('common.disabled'), value: 0 },
+    //     // ],
+    //     options: [
+    //       { label: $t('system.role.oneself'), value: 1 },
+    //       { label: $t('system.role.oneInstitution'), value: 0 },
+    //     ],
+    //     optionType: 'button',
+    //   },
+    //   defaultValue: 1,
+    //   fieldName: 'status',
+    //   label: $t('system.role.dataAuthority'),
+    //   rules: 'required',
+    // },
+    // {
+    //   component: 'Textarea',
+    //   fieldName: 'remark',
+    //   label: $t('system.role.remark'),
+    // },
     {
       component: 'Input',
       fieldName: 'permissions',
       formItemClass: 'items-start',
       label: $t('system.role.setPermissions'),
       modelPropName: 'modelValue',
+      rules: z.array(z.any()).min(1, { message: '请选择菜单权限' }),
     },
   ];
 }

+ 8 - 8
apps/health-remedy/src/views/system/role/list.vue

@@ -49,13 +49,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
       keyField: 'id',
     },
 
-    toolbarConfig: {
-      custom: true,
-      export: false,
-      refresh: true,
-      search: true,
-      zoom: true,
-    },
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
   } as VxeTableGridOptions<SystemModel.Role>,
 });
 
@@ -144,7 +144,7 @@ async function onDeleteHandle(row: SystemModel.Role) {
 <template>
   <Page auto-content-height>
     <FormDrawer @success="onRefresh" />
-    <Grid :table-title="$t('system.role.list')">
+    <Grid>
       <template #toolbar-tools>
         <Button type="primary" @click="onEditHandle()">
           <Plus class="size-5" />

+ 6 - 1
apps/health-remedy/src/views/system/role/modules/form.vue

@@ -62,11 +62,16 @@ const [Drawer, drawerApi] = useVbenDrawer({
       if (data) {
         formData.value = data;
         await formApi.setValues(formData.value);
+        // 清除验证状态,避免在数据设置后立即显示验证错误
+        await formApi.resetValidate();
       }
       if (menus.value.length === 0) {
         await loadMenus();
-        if (formData.value?.permissions?.length)
+        if (formData.value?.permissions?.length) {
           await formApi.setValues(formData.value);
+          // 再次清除验证状态
+          await formApi.resetValidate();
+        }
       }
     }
   },

+ 74 - 10
apps/health-remedy/src/views/system/user/data.ts

@@ -5,7 +5,10 @@ import type { OnActionClickFn } from '#/adapter/vxe-table';
 import type { SystemModel } from '#/api/method/system';
 
 import { z } from '#/adapter/form';
-import { optionsRoleMethod } from '#/api/method/system';
+import {
+  listUsersInstitutionMethodTree,
+  optionsRoleMethod,
+} from '#/api/method/system';
 import { $t } from '#/locales';
 
 export function useUserSearchFormSchema(): VbenFormSchema[] {
@@ -22,19 +25,43 @@ export function useUserSearchFormSchema(): VbenFormSchema[] {
     },
     {
       component: 'Input',
-      fieldName: 'worker',
-      label: $t('system.user.worker'),
+      fieldName: 'sititutionId',
+      label: $t('system.organization.name'),
     },
     {
-      component: 'Input',
-      fieldName: 'mobile',
-      label: $t('system.user.mobile'),
+      component: 'ApiSelect',
+      fieldName: 'roles',
+      label: $t('system.role._'),
+      componentProps: {
+        api: optionsRoleMethod,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        mode: 'multiple',
+        allowClear: true,
+        submitOnChange: true,
+      },
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: [
+          { label: $t('common.all'), value: null },
+          { label: $t('common.enabled'), value: 0 },
+          { label: $t('common.disabled'), value: 1 },
+        ],
+      },
+      fieldName: 'status',
+      label: $t('system.role.status'),
     },
   ];
 }
 
 export function useUserTableColumns<T = SystemModel.User>(
   onActionClick?: OnActionClickFn<T>,
+  onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
 ): VxeTableGridOptions<T>['columns'] {
   return [
     { type: 'seq', title: $t('table.column.seq'), width: 50 },
@@ -49,15 +76,36 @@ export function useUserTableColumns<T = SystemModel.User>(
       minWidth: 100,
     },
     {
-      field: 'worker',
-      title: $t('system.user.worker'),
+      field: 'hospitalName',
+      title: $t('system.organization._'),
+      minWidth: 100,
+    },
+    {
+      field: 'roleNames',
+      title: $t('system.role._'),
+      minWidth: 100,
+    },
+    {
+      field: 'createTime',
+      title: $t('table.column.createTime'),
       minWidth: 100,
     },
     {
-      field: 'mobile',
-      title: $t('system.user.mobile'),
+      field: 'createUser',
+      title: $t('system.organization.createUser'),
       minWidth: 100,
     },
+    {
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        // props: { accessRole: '超级管理员' },
+        props: { _props: { checkedValue: 0, unCheckedValue: 1 } },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      title: $t('system.role.enabledStatus'),
+      width: 100,
+    },
     {
       align: 'center',
       cellRender: {
@@ -123,10 +171,26 @@ export function useUserFormSchema(): VbenFormSchema[] {
       label: $t('system.user.name'),
       rules: 'required',
     },
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        class: 'w-full',
+        api: listUsersInstitutionMethodTree,
+        labelField: 'name',
+        valueField: 'pid',
+        childrenField: 'children',
+        treeDefaultExpandAll: true,
+        dropdownStyle: { maxHeight: 400, overflow: 'auto' },
+      },
+      fieldName: 'sititutionId',
+      label: $t('system.organization._'),
+      rules: 'required',
+    },
     {
       component: 'Input',
       fieldName: 'worker',
       label: $t('system.user.worker'),
+      rules: 'required',
     },
     {
       component: 'Input',

+ 71 - 11
apps/health-remedy/src/views/system/user/list.vue

@@ -1,4 +1,6 @@
 <script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
 import type {
   OnActionClickParams,
   VxeTableGridOptions,
@@ -8,10 +10,14 @@ import type { SystemModel } from '#/api';
 import { Page, useVbenModal } from '@vben/common-ui';
 import { Plus } from '@vben/icons';
 
-import { Button, message } from 'ant-design-vue';
+import { Button, message, Modal, notification } from 'ant-design-vue';
 
 import { useVbenVxeGrid } from '#/adapter/vxe-table';
-import { deleteUserMethod, listUsersMethod } from '#/api';
+import {
+  deleteUserMethod,
+  listUsersMethod,
+  updateUserStatusMethod,
+} from '#/api';
 import { $t } from '#/locales';
 
 import { useUserSearchFormSchema, useUserTableColumns } from './data';
@@ -28,7 +34,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
     submitOnChange: true,
   },
   gridOptions: {
-    columns: useUserTableColumns(onActionClick),
+    columns: useUserTableColumns(onActionClick, onStatusChange),
     height: 'auto',
     keepSource: true,
     proxyConfig: {
@@ -42,13 +48,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
       keyField: 'id',
     },
 
-    toolbarConfig: {
-      custom: true,
-      export: false,
-      refresh: true,
-      search: true,
-      zoom: true,
-    },
+    // toolbarConfig: {
+    //   custom: true,
+    //   export: false,
+    //   refresh: true,
+    //   search: true,
+    //   zoom: true,
+    // },
   } as VxeTableGridOptions<SystemModel.User>,
 });
 
@@ -65,6 +71,60 @@ function onActionClick(e: OnActionClickParams<SystemModel.User>) {
   }
 }
 
+/**
+ * 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
+ * @param content 提示内容
+ * @param title 提示标题
+ */
+function confirm(content: string, title: string) {
+  return new Promise((reslove, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        reslove(true);
+      },
+      title,
+    });
+  });
+}
+
+/**
+ * 状态开关即将改变
+ * @param newStatus 期望改变的状态值
+ * @param row 行数据
+ * @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
+ */
+async function onStatusChange(newStatus: 0 | 1, row: SystemModel.User) {
+  const status: Recordable<string> = {
+    0: '启用',
+    1: '禁用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      `切换状态`,
+    );
+
+    try {
+      await updateUserStatusMethod(row.pid!, { status: newStatus });
+      notification.success({
+        message: '切换状态成功',
+      });
+    } catch (error: any) {
+      notification.error({
+        message: error.message || '切换状态失败',
+      });
+    }
+
+    return true;
+  } catch {
+    return false;
+  }
+}
+
 function onRefresh() {
   gridApi.query();
 }
@@ -94,7 +154,7 @@ async function onDeleteHandle(row: SystemModel.User) {
 <template>
   <Page auto-content-height>
     <FormModal @success="onRefresh" />
-    <Grid :table-title="$t('system.user.list')">
+    <Grid>
       <template #toolbar-tools>
         <Button type="primary" @click="onEditHandle()">
           <Plus class="size-5" />

+ 380 - 0
apps/health-remedy/src/views/treatment/treatment/components/PatientList.vue

@@ -0,0 +1,380 @@
+<script lang="ts" setup>
+import type { TreatmentModel } from '#/api/method/treatment';
+
+import { computed, nextTick, onMounted, ref, watch } from 'vue';
+
+import { listPatientsMethod } from '#/api/method/treatment';
+import { $t } from '#/locales';
+
+import CollapseTransition from '../../../../../../../packages/@core/ui-kit/menu-ui/src/components/collapse-transition.vue';
+
+const props = defineProps<{
+  activeTab?: string;
+  patients?: TreatmentModel.Patient[];
+  selectedPatientId?: string;
+}>();
+
+const emit = defineEmits<{
+  patientSelect: [patient: TreatmentModel.Patient];
+  treatmentSelect: [
+    payload: {
+      index: number;
+      patient: TreatmentModel.Patient;
+      treatment: TreatmentModel.Patient['itemVOS'][0];
+    },
+  ];
+}>();
+
+// 搜索相关
+const searchKeyword = ref('');
+
+// 使用父组件传入的患者数据,如果没有则使用默认数据
+const defaultPatients = ref<TreatmentModel.Patient[]>([]);
+
+const patients = computed(() => props.patients || defaultPatients.value);
+
+const selectedPatientId = computed(() => props.selectedPatientId || '1');
+// 自动选择第一个患者的方法
+const autoSelectFirstPatient = () => {
+  // 使用nextTick确保computed属性已经更新
+  nextTick(() => {
+    const currentPatients = filteredPatients.value;
+    if (currentPatients.length > 0) {
+      // 优先选择有与标签名称相同未治疗项目的患者
+      let targetPatient = null;
+      let targetTreatment = null;
+
+      if (
+        props.activeTab &&
+        props.activeTab !== '' &&
+        props.activeTab !== '全部'
+      ) {
+        // 查找有该标签名称未治疗项目的患者
+        for (const patient of currentPatients) {
+          const matchingTreatment = patient.itemVOS.find(
+            (item) => item.itemName === props.activeTab && item.itemState === 0,
+          );
+          if (matchingTreatment) {
+            targetPatient = patient;
+            targetTreatment = matchingTreatment;
+            break;
+          }
+        }
+      }
+
+      // 如果没有找到匹配的,则选择第一个患者的第一个未治疗项目
+      if (!targetPatient) {
+        targetPatient = currentPatients[0];
+        if (targetPatient) {
+          // 全部/未设置:直接选择第一个项目(无论状态);
+          // 指定标签但未匹配:优先选择未开始的项目,否则第一个项目
+          targetTreatment =
+            !props.activeTab ||
+            props.activeTab === '' ||
+            props.activeTab === '全部'
+              ? (targetPatient.itemVOS[0] ?? null)
+              : (targetPatient.itemVOS.find((item) => item.itemState === 0) ??
+                targetPatient.itemVOS[0] ??
+                null);
+        }
+      }
+
+      if (targetPatient && targetTreatment) {
+        // 选中患者
+        emit('patientSelect', targetPatient);
+
+        // 先关闭所有已展开的患者,只展开目标患者
+        expandedIds.value.clear();
+        expandedIds.value.add(targetPatient.patientId);
+
+        // 选中对应的治疗项目
+        selectedTreatmentId.value = targetTreatment.id;
+        const treatmentIndex = targetPatient.itemVOS.findIndex(
+          (item) => item.id === targetTreatment.id,
+        );
+        emit('treatmentSelect', {
+          index: treatmentIndex,
+          patient: targetPatient,
+          treatment: targetTreatment,
+        });
+      }
+    }
+  });
+};
+
+onMounted(async () => {
+  const res = await listPatientsMethod();
+  defaultPatients.value = res?.items ?? ([] as TreatmentModel.Patient[]);
+  // 默认选中第一个患者并展开
+  autoSelectFirstPatient();
+});
+
+// 当切换标签(包括切到“全部”)时,自动选中第一个患者和其第一个未开始治疗项目
+watch(
+  () => props.activeTab,
+  () => {
+    autoSelectFirstPatient();
+  },
+);
+
+// 过滤后的患者列表
+const filteredPatients = computed(() => {
+  let result = patients.value;
+
+  // 按搜索关键词过滤
+  if (searchKeyword.value.trim()) {
+    const keyword = searchKeyword.value.toLowerCase();
+    result = result.filter(
+      (patient) =>
+        patient.name.toLowerCase().includes(keyword) ||
+        patient.phone.includes(keyword),
+    );
+  }
+
+  // 按标签过滤
+  if (props.activeTab && props.activeTab !== '' && props.activeTab !== '全部') {
+    // 指定标签:仅显示包含该项目且未开始治疗的患者
+    result = result.filter((patient) => {
+      return patient.itemVOS.some(
+        (treatment) =>
+          treatment.itemName === props.activeTab && treatment.itemState === 0,
+      );
+    });
+  }
+  // 如果 activeTab 为空字符串,则显示所有患者(不进行筛选)
+
+  return result;
+});
+
+// 搜索处理
+const handleSearch = () => {
+  // 搜索逻辑已在 computed 中处理
+};
+
+const getStatusColor = (status: string) => {
+  switch (status) {
+    case '0': {
+      // 未开始:蓝色
+      return 'text-blue-600';
+    }
+    case '1': {
+      // 治疗中:黄色
+      return 'text-yellow-600';
+    }
+    case '2': {
+      // 治疗完成:绿色
+      return 'text-green-600';
+    }
+    default: {
+      return 'text-gray-500';
+    }
+  }
+};
+
+const getStatusText = (status: string) => {
+  switch (status) {
+    case '0': {
+      return $t('treatment.patient.noStart');
+    }
+    case '1': {
+      return $t('treatment.patient.treatmenting');
+    }
+    case '2': {
+      return $t('treatment.patient.complete');
+    }
+    default: {
+      return $t('treatment.patient.unknown');
+    }
+  }
+};
+
+// 展开/收起:用 Set 存储展开的患者 id
+const expandedIds = ref<Set<string>>(new Set());
+const selectedTreatmentId = ref<null | string>(null);
+
+const isExpanded = (id: string) => expandedIds.value.has(id);
+
+const toggleExpand = (id: string) => {
+  const wasExpanded = expandedIds.value.has(id);
+  if (wasExpanded) {
+    expandedIds.value.delete(id);
+  } else {
+    expandedIds.value.add(id);
+    // 默认选中第一项治疗
+    const patient = patients.value.find((p) => p.patientId === id);
+    if (patient && patient.itemVOS.length > 0) {
+      const first: TreatmentModel.Patient['itemVOS'][0] = patient.itemVOS[0]!;
+      selectedTreatmentId.value = first.id;
+      emit('treatmentSelect', { index: 0, patient, treatment: first });
+    }
+  }
+  // 触发更新
+  expandedIds.value = new Set(expandedIds.value);
+};
+
+const handlePatientClick = (patient: TreatmentModel.Patient) => {
+  // 点击卡片:选中患者并切换展开
+  emit('patientSelect', patient);
+
+  // 如果患者已经展开,则折叠
+  if (isExpanded(patient.patientId)) {
+    toggleExpand(patient.patientId);
+  } else {
+    // 如果患者未展开,则展开并选择第一个治疗项目
+    expandedIds.value.add(patient.patientId);
+
+    // 选择第一个治疗项目
+    if (patient.itemVOS.length > 0) {
+      const firstTreatment = patient.itemVOS[0];
+      selectedTreatmentId.value = firstTreatment.id;
+      emit('treatmentSelect', {
+        index: 0,
+        patient,
+        treatment: firstTreatment,
+      });
+    }
+  }
+};
+
+const handleTreatmentClick = (
+  patient: TreatmentModel.Patient,
+  treatment: TreatmentModel.Patient['itemVOS'][0],
+  index: number,
+) => {
+  selectedTreatmentId.value = treatment.id;
+  emit('treatmentSelect', { index, patient, treatment });
+};
+
+// 暴露方法给父组件
+defineExpose({
+  autoSelectFirstPatient,
+});
+</script>
+
+<template>
+  <div class="h-full w-80 overflow-y-auto border-r bg-white">
+    <div class="p-4">
+      <!-- 搜索框 -->
+      <div class="mb-4">
+        <div class="relative">
+          <input
+            v-model="searchKeyword"
+            type="text"
+            :placeholder="$t('treatment.patient.searchPlaceholder')"
+            class="w-full rounded-lg border border-gray-300 px-4 py-2 pr-10 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
+            @keyup.enter="handleSearch"
+          />
+          <button
+            @click="handleSearch"
+            class="absolute right-2 top-1/2 -translate-y-1/2 transform text-blue-600 hover:text-blue-800"
+          >
+            <svg
+              class="h-4 w-4"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                stroke-width="2"
+                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+              />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      <div class="space-y-3">
+        <!-- 暂无数据 -->
+        <div
+          v-if="filteredPatients.length === 0"
+          class="flex flex-col items-center justify-center py-12 text-gray-500"
+        >
+          <svg
+            class="mb-4 h-12 w-12 text-gray-300"
+            fill="none"
+            stroke="currentColor"
+            viewBox="0 0 24 24"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="2"
+              d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
+            />
+          </svg>
+          <p class="text-lg font-medium">
+            {{ $t('treatment.patient.noPatientData') }}
+          </p>
+        </div>
+
+        <!-- 患者列表 -->
+        <div
+          v-for="patient in filteredPatients"
+          :key="patient.patientId"
+          @click="handlePatientClick(patient)"
+          class="cursor-pointer rounded-lg border p-4 transition-all duration-200"
+          :class="[
+            selectedPatientId === patient.patientId
+              ? 'border-blue-500 bg-blue-50 shadow-md'
+              : 'border-gray-200 hover:border-gray-300 hover:shadow-sm',
+          ]"
+        >
+          <!-- 患者基本信息 -->
+          <div class="mb-2 flex items-center space-x-3">
+            <!-- 患者信息 -->
+            <div class="flex-1">
+              <div class="flex items-center justify-between space-x-3">
+                <div class="text-lg font-semibold text-gray-800">
+                  {{ patient.name }}
+                </div>
+                <div class="flex text-sm text-gray-500">
+                  {{ patient.sex }} {{ patient.age }}
+                  {{ $t('treatment.patient.age') }}
+                </div>
+              </div>
+              <div class="text-sm text-gray-600">{{ patient.phone }}</div>
+            </div>
+          </div>
+          <!-- 治疗项目(折叠内容) -->
+          <CollapseTransition>
+            <div
+              v-show="
+                isExpanded(patient.patientId) && patient.itemVOS.length > 0
+              "
+              class="space-y-1"
+            >
+              <div
+                v-for="(treatment, index) in patient.itemVOS"
+                :key="treatment.id"
+                @click.stop="
+                  handleTreatmentClick(
+                    patient,
+                    treatment as unknown as TreatmentModel.Patient['itemVOS'][0],
+                    index,
+                  )
+                "
+                class="ml-4 flex cursor-pointer items-center justify-between rounded border px-2 py-1 text-sm transition-colors"
+                :class="[
+                  selectedTreatmentId === treatment.id &&
+                  selectedPatientId === patient.patientId
+                    ? 'border-blue-200 bg-blue-200 text-black'
+                    : 'border-transparent bg-white text-gray-700 hover:border-gray-200 hover:bg-gray-50',
+                ]"
+              >
+                <span>{{ index + 1 }}、{{ treatment.itemName }}</span>
+                <span
+                  class="font-semibold"
+                  :class="getStatusColor(treatment.itemState.toString())"
+                >
+                  {{ getStatusText(treatment.itemState.toString()) }}
+                </span>
+              </div>
+            </div>
+          </CollapseTransition>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 840 - 0
apps/health-remedy/src/views/treatment/treatment/components/TreatmentDetail.vue

@@ -0,0 +1,840 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeGridProps } from '#/adapter/vxe-table';
+import type { TreatmentModel } from '#/api/method/treatment';
+
+import {
+  computed,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Image, notification } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  endTreatmentMethod,
+  getAcupointDetailMethod,
+  getMeridianDetailMethod,
+  saveTreatmentMethod,
+} from '#/api/method/treatment';
+import { $t } from '#/locales';
+
+const props = defineProps<{
+  countdown?: number;
+  index?: number;
+  patient?: TreatmentModel.Patient;
+  treatment?: TreatmentModel.TreatmentDetail;
+}>();
+
+const emit = defineEmits<{
+  refreshData: [payload?: { minutes?: number; treatmentId?: string }];
+  startTreatment: [payload: { minutes: number; treatmentId: string }];
+}>();
+
+const treatmentDetail = computed<TreatmentModel.TreatmentDetail>(() => {
+  if (props.treatment) {
+    const result = props.treatment as unknown as TreatmentModel.TreatmentDetail;
+    return result;
+  }
+  return {} as TreatmentModel.TreatmentDetail;
+});
+
+let registerStatusWatchOnce = false;
+
+const isInProgress = ref(false);
+const isCompleted = computed(() => treatmentDetail.value?.itemState === 2);
+
+// 操作记录数据
+const operatesData = computed(() => {
+  if (!props.treatment) {
+    return [];
+  }
+
+  const data = props.treatment.operates || [];
+  return data;
+});
+
+// 穴位数据(包含说明行)
+const acupointData = computed(() => {
+  if (!props.treatment) {
+    return [];
+  }
+
+  const acupoints = props.treatment.acupoints || [];
+  const data = acupoints.map((acupoint, index) => ({
+    id: acupoint.id,
+    index: (index + 1) as any,
+    acupointName: acupoint.acupointName,
+    acuType: acupoint.acuType, // 添加 acuType 字段
+  }));
+
+  // 将穴位数据按4个一组重新组织,每行4个穴位
+  const groupedData = [];
+  for (let i = 0; i < data.length; i += 4) {
+    const rowData = data.slice(i, i + 4);
+    // 确保每行都有4个元素,不足的用空对象填充
+    while (rowData.length < 4) {
+      rowData.push({
+        id: `empty-${i + rowData.length}`,
+        index: '',
+        acupointName: '',
+        acuType: '',
+      });
+    }
+    groupedData.push({
+      id: `row-${Math.floor(i / 4)}`,
+      acupoints: rowData,
+    });
+  }
+
+  // 添加说明行(永远在最后)
+  if (props.treatment.describe) {
+    groupedData.push({
+      id: 'describe',
+      acupoints: [
+        {
+          id: 'describe',
+          index: '说明',
+          acupointName: props.treatment.describe,
+          acuType: '',
+        },
+        { id: 'empty-1', index: '', acupointName: '', acuType: '' },
+        { id: 'empty-2', index: '', acupointName: '', acuType: '' },
+        { id: 'empty-3', index: '', acupointName: '', acuType: '' },
+      ],
+    });
+  }
+
+  return groupedData;
+});
+
+// 操作记录表格配置
+const operateGridOptions = computed(
+  (): VxeGridProps<TreatmentModel.TreatmentDetail['operates'][0]> => ({
+    columns: [
+      {
+        field: 'operateDate',
+        title: $t('treatment.detail.operateDate'),
+        width: 160,
+        sortable: true,
+      },
+      {
+        field: 'operateUserName',
+        title: $t('treatment.detail.operateUser'),
+        width: 100,
+      },
+      {
+        field: 'remark',
+        title: $t('treatment.detail.treatmentRemark'),
+        minWidth: 200,
+        showOverflow: 'tooltip' as const,
+      },
+      {
+        field: 'treatmentImageUrl',
+        title: $t('treatment.detail.treatmentPhoto'),
+        width: 120,
+        slots: { default: 'treatmentImage' },
+      },
+    ],
+    data: operatesData.value,
+    keepSource: false,
+    pagerConfig: {
+      enabled: false,
+    },
+    height: 'auto',
+    maxHeight: 300,
+    border: true,
+    showOverflow: 'tooltip' as const,
+    scrollY: { enabled: true },
+  }),
+);
+
+const [OperateGrid, operateGridApi] = useVbenVxeGrid({
+  gridOptions: operateGridOptions as any,
+});
+
+// 监听 props.treatment 变化,强制刷新表格
+watch(
+  () => props.treatment?.id,
+  (newId, oldId) => {
+    if (newId && newId !== oldId) {
+      nextTick(() => {
+        operateGridApi?.grid?.reloadData?.(operatesData.value);
+      });
+    }
+  },
+  { immediate: false },
+);
+
+const formatCountdown = (sec: number) => {
+  const m = Math.floor(sec / 60)
+    .toString()
+    .padStart(2, '0');
+  const s = Math.floor(sec % 60)
+    .toString()
+    .padStart(2, '0');
+  return `${m}:${s}`;
+};
+
+function toTsWithOffset(input?: Date | number | string) {
+  if (!input) return 0;
+  if (typeof input === 'number') return input;
+  if (input instanceof Date) return input.getTime();
+  // 兼容 2025-10-13T15:09:26.000+0800
+  const normalized = String(input).replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
+  const t = new Date(normalized).getTime();
+  return Number.isFinite(t) ? t : 0;
+}
+
+function diffSeconds(
+  from?: Date | number | string,
+  to: Date | number | string = Date.now(),
+) {
+  const ms = toTsWithOffset(to) - toTsWithOffset(from);
+  return Math.max(0, Math.floor(ms / 1000));
+}
+
+const nowTick = ref(Date.now());
+let tickTimer: null | number = null;
+
+onMounted(() => {
+  tickTimer = window.setInterval(() => {
+    nowTick.value = Date.now();
+  }, 1000);
+});
+
+onBeforeUnmount(() => {
+  if (tickTimer) {
+    clearInterval(tickTimer);
+    tickTimer = null;
+  }
+});
+
+const remainSeconds = computed(() => {
+  console.warn('props.countdown:', props.countdown);
+  if (props.countdown !== undefined) {
+    return Math.max(0, props.countdown);
+  }
+
+  // 如果没有父组件倒计时,则使用本地计算
+  const d: any = treatmentDetail.value || {};
+  const cur: any = d.currentOperate || {};
+  console.warn('cur:', cur);
+  console.warn('d:', d);
+  const totalMin = cur.treatmentTime ?? d.treatmentTime;
+  const upd = cur.updateTime ?? d.updateTime ?? cur.operateDate;
+  const total = Math.max(0, Math.floor(Number(totalMin || 0) * 60));
+  const passed = diffSeconds(upd, nowTick.value);
+  return Math.max(0, total - passed);
+});
+
+const treatmentStartTime = ref<null | number>(null);
+const isEndingTreatment = ref(false);
+
+// 计算是否倒计时结束
+const isTimerFinished = computed(() => {
+  console.warn('isInProgress.value:', isInProgress.value);
+  console.warn('remainSeconds.value:', remainSeconds.value);
+  return isInProgress.value && remainSeconds.value <= 0;
+});
+
+// vben 弹窗 + 表单:还原“开始治疗”弹窗
+const startSchemas: VbenFormSchema[] = [
+  {
+    fieldName: 'remark',
+    label: $t('treatment.detail.treatmentRemark'),
+    component: 'Textarea',
+    componentProps: { rows: 4, placeholder: $t('treatment.detail.input') },
+  },
+  {
+    fieldName: 'treatmentImageUrl',
+    label: $t('treatment.detail.treatmentPhoto'),
+    component: 'Avatar',
+    componentProps: {
+      accept: 'image/*',
+    },
+  },
+  {
+    fieldName: 'treatmentTime',
+    label: $t('treatment.detail.treatmentTime'),
+    component: 'InputNumber',
+    componentProps: { min: 1, placeholder: $t('treatment.detail.input') },
+    suffix: $t('treatment.detail.minutes'),
+    rules: 'required',
+  },
+];
+
+const [Form, formApi] = useVbenForm({
+  schema: startSchemas,
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  title: $t('treatment.detail.startTreatment'),
+  closeOnClickModal: false,
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    const values = await formApi.getValues();
+    const minutes = Number(values.treatmentTime);
+
+    try {
+      if (treatmentDetail.value.itemState === 1) {
+        values.id = treatmentDetail.value.currentOperate.id;
+      }
+
+      values.recordId = treatmentDetail.value.id;
+      await saveTreatmentMethod(values);
+      notification.success({ message: $t('treatment.detail.saveSuccess') });
+
+      // 只有在保存成功后才设置治疗进行中状态
+      // 这样可以避免在数据未更新时立即触发倒计时结束
+      isInProgress.value = true;
+      treatmentStartTime.value = Date.now();
+
+      // 触发本地计时
+      emit('startTreatment', {
+        treatmentId: treatmentDetail.value.id,
+        minutes: Math.max(1, minutes),
+      });
+
+      // 触发刷新数据事件,更新标签页计数
+      emit('refreshData', { treatmentId: treatmentDetail.value.id });
+
+      await modalApi.close();
+    } catch (error) {
+      // 如果保存失败,确保不设置治疗进行中状态
+      isInProgress.value = false;
+      notification.error({
+        message:
+          error instanceof Error
+            ? error.message
+            : $t('treatment.detail.saveFailure'),
+      });
+    }
+  },
+});
+
+// 穴位详情弹窗
+const selectedAcupoint = ref<any | null>(null);
+const [AcupointModal, acupointModalApi] = useVbenModal({
+  title: $t('treatment.detail.detail'),
+  footer: false, // 不显示确认/取消
+  closable: true,
+});
+
+const handleStartTreatment = (treatment: TreatmentModel.TreatmentDetail) => {
+  // 基础默认值
+  const baseValues: Record<string, any> = {
+    recordId: treatment.id,
+    treatmentTime: '',
+    remark: '',
+    treatmentImageUrl: '',
+  };
+
+  // 若处于治疗中,则回填当前操作项数据,供用户修改
+  const maybeCurrent = (treatmentDetail.value as any)?.currentOperate;
+  if (treatmentDetail.value?.itemState === 1 && maybeCurrent) {
+    formApi.setValues({
+      ...baseValues,
+      id: maybeCurrent.id,
+      remark: maybeCurrent.remark ?? '',
+      treatmentImageUrl: maybeCurrent.treatmentImageUrl ?? '',
+      treatmentTime: Number(
+        maybeCurrent.treatmentTime ??
+          maybeCurrent.minutes ??
+          baseValues.treatmentTime,
+      ),
+    });
+  } else {
+    formApi.setValues(baseValues);
+  }
+
+  modalApi.open();
+};
+
+if (!registerStatusWatchOnce) {
+  registerStatusWatchOnce = true;
+  watch(
+    () => props.treatment?.itemState,
+    (status) => {
+      console.warn(
+        '治疗状态变化:',
+        status,
+        '当前isInProgress:',
+        isInProgress.value,
+      );
+      if (status === 1) {
+        isInProgress.value = true;
+        treatmentStartTime.value = Date.now();
+      } else if (status === 2) {
+        isInProgress.value = false;
+        treatmentStartTime.value = null;
+        console.warn('治疗已完成,设置isInProgress为false');
+      } else {
+        isInProgress.value = false;
+        treatmentStartTime.value = null;
+      }
+    },
+    { immediate: true },
+  );
+}
+
+// 监听计时结束,调用治疗结束接口
+watch(
+  () => isTimerFinished.value,
+  async (finished) => {
+    if (!finished) return;
+
+    // 防止重复调用
+    if (isEndingTreatment.value) {
+      console.warn('治疗结束接口正在调用中,跳过重复调用');
+      return;
+    }
+
+    isEndingTreatment.value = true;
+    console.warn('计时结束,开始调用治疗结束接口:', treatmentDetail.value.id);
+    try {
+      await endTreatmentMethod([treatmentDetail.value.id]);
+      notification.success({ message: $t('treatment.detail.endSuccess') });
+      // 刷新页面数据
+      emit('refreshData', { treatmentId: treatmentDetail.value.id });
+      console.warn('治疗结束接口调用成功');
+    } catch (error) {
+      console.error('治疗结束失败:', error);
+    } finally {
+      isEndingTreatment.value = false;
+    }
+  },
+  { immediate: false },
+);
+// 点击穴位:弹出详情弹窗
+const handleAcupointClick = async (
+  acupoint: TreatmentModel.TreatmentDetail['acupoints'][0],
+) => {
+  switch (Number(acupoint.acuType)) {
+    case 1: {
+      try {
+        // 获取穴位
+        const detail = await getAcupointDetailMethod(acupoint.id);
+        selectedAcupoint.value = { ...acupoint, ...detail } as any;
+      } catch {
+        notification.error({
+          message: $t('treatment.detail.getAcupointDetailFailure'),
+        });
+      }
+      break;
+    }
+    case 2: {
+      // 获取经络
+      try {
+        const detail = await getMeridianDetailMethod(acupoint.id);
+        selectedAcupoint.value = { ...acupoint, ...detail } as any;
+      } catch {
+        notification.error({
+          message: $t('treatment.detail.getMeridianDetailFailure'),
+        });
+      }
+      break;
+    }
+  }
+
+  acupointModalApi.open();
+};
+</script>
+
+<template>
+  <div class="h-full flex-1 overflow-y-auto bg-white">
+    <div class="p-3">
+      <!-- 患者信息头部 -->
+      <div v-if="patient" class="mb-6 flex items-center justify-between">
+        <!-- 用户信息 -->
+        <div class="ml-4 flex items-center space-x-2">
+          <div
+            class="flex h-8 w-8 items-center justify-center rounded-full bg-gray-300"
+            v-if="patient.sex || patient.age || patient.phone"
+          >
+            <svg
+              class="h-5 w-5 text-gray-600"
+              fill="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
+              />
+            </svg>
+          </div>
+          <div class="flex text-sm">
+            <div class="font-medium">{{ patient?.name }}</div>
+            <div class="ml-4 mr-2 text-gray-500">
+              {{ patient?.sex }}
+            </div>
+            <div class="text-gray-500">{{ patient?.phone }}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 治疗详情 -->
+      <div v-if="treatmentDetail" class="space-y-6"></div>
+      <!-- 治疗项目信息 -->
+      <div class="rounded-lg bg-gray-50 p-3">
+        <div class="flex items-center justify-between">
+          <h3
+            class="text-lg font-semibold text-gray-800"
+            v-if="treatmentDetail?.itemName"
+          >
+            {{ (props?.index ?? 0) + 1 }}、{{ treatmentDetail?.itemName }}
+          </h3>
+          <div class="flex items-center space-x-3">
+            <div
+              v-if="isInProgress && !isTimerFinished"
+              class="text-sm text-yellow-600"
+            >
+              {{ $t('treatment.detail.remainingTime') }}:{{
+                formatCountdown(remainSeconds)
+              }}
+            </div>
+            <button
+              @click="handleStartTreatment(treatmentDetail)"
+              class="rounded-lg px-6 py-2 text-white transition-colors"
+              v-if="treatmentDetail?.itemName"
+              :class="[
+                isCompleted
+                  ? 'cursor-not-allowed bg-green-600'
+                  : isInProgress
+                    ? 'bg-yellow-500 hover:bg-yellow-600'
+                    : 'bg-blue-600 hover:bg-blue-700',
+              ]"
+              :disabled="isCompleted"
+            >
+              {{
+                isCompleted
+                  ? $t('treatment.patient.complete')
+                  : isInProgress
+                    ? $t('treatment.patient.treatmenting')
+                    : $t('treatment.detail.start')
+              }}
+            </button>
+          </div>
+        </div>
+
+        <div class="mb-2 grid grid-cols-3">
+          <div v-if="treatmentDetail?.planType">
+            <span class="text-sm text-gray-600">
+              {{ $t('treatment.detail.schemeType') }}:
+            </span>
+            <span class="ml-2 font-medium">{{ treatmentDetail.planType }}</span>
+          </div>
+          <div v-if="treatmentDetail?.frequency">
+            <span class="text-sm text-gray-600">
+              {{ $t('treatment.detail.frequency') }}:
+            </span>
+            <span class="ml-2 font-medium">
+              {{ treatmentDetail.frequency }}
+            </span>
+          </div>
+          <div v-if="treatmentDetail.totalNum">
+            <span class="text-sm text-gray-600">
+              {{ $t('treatment.detail.totalNum') }}:
+            </span>
+            <span class="ml-2 font-medium">
+              {{ treatmentDetail.completeNum }} /
+              {{ treatmentDetail.totalNum }}
+            </span>
+          </div>
+        </div>
+
+        <!-- 穴位表格 -->
+        <div>
+          <div class="overflow-hidden rounded-sm border border-gray-300">
+            <!-- 表头 -->
+            <div
+              class="border-b border-gray-300"
+              style="background-color: #f5f4f5"
+            >
+              <div
+                class="grid"
+                style="
+                  grid-template-columns: 80px 1fr 80px 1fr 80px 1fr 80px 1fr;
+                "
+              >
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.sequence') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.acupointOrMeridian') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.sequence') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.acupointOrMeridian') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.sequence') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.acupointOrMeridian') }}
+                </div>
+                <div
+                  class="border-r border-gray-300 px-4 py-3 text-center font-medium text-gray-700"
+                >
+                  {{ $t('treatment.detail.sequence') }}
+                </div>
+                <div class="px-4 py-3 text-center font-medium text-gray-700">
+                  {{ $t('treatment.detail.acupointOrMeridian') }}
+                </div>
+              </div>
+            </div>
+
+            <!-- 表格内容 -->
+            <div
+              v-for="row in acupointData"
+              :key="row.id"
+              class="border-b border-gray-300 last:border-b-0"
+            >
+              <div
+                v-if="row.id === 'describe'"
+                class="grid"
+                style="grid-template-columns: 80px 1fr"
+              >
+                <!-- 说明标题列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div class="text-sm font-medium text-gray-600">说明</div>
+                </div>
+                <!-- 说明内容列 -->
+                <div class="px-4 py-3">
+                  <div
+                    class="whitespace-pre-line text-sm leading-relaxed text-gray-700"
+                  >
+                    {{ row.acupoints[0]?.acupointName }}
+                  </div>
+                </div>
+              </div>
+              <div
+                v-else
+                class="grid"
+                style="
+                  grid-template-columns: 80px 1fr 80px 1fr 80px 1fr 80px 1fr;
+                "
+              >
+                <!-- 第一个穴位:序号列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[0]?.acupointName"
+                    class="text-sm font-medium text-gray-600"
+                  >
+                    {{ row.acupoints[0].index }}
+                  </div>
+                </div>
+                <!-- 第一个穴位:穴位列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[0]?.acupointName"
+                    class="text-black-600 hover:text-black-800 cursor-pointer hover:underline"
+                    @click.stop="handleAcupointClick(row.acupoints[0] as any)"
+                  >
+                    {{ row.acupoints[0].acupointName }}
+                  </div>
+                </div>
+                <!-- 第二个穴位:序号列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[1]?.acupointName"
+                    class="text-sm font-medium text-gray-600"
+                  >
+                    {{ row.acupoints[1].index }}
+                  </div>
+                </div>
+                <!-- 第二个穴位:穴位列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[1]?.acupointName"
+                    class="text-black-600 hover:text-black-800 cursor-pointer hover:underline"
+                    @click.stop="handleAcupointClick(row.acupoints[1] as any)"
+                  >
+                    {{ row.acupoints[1].acupointName }}
+                  </div>
+                </div>
+                <!-- 第三个穴位:序号列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[2]?.acupointName"
+                    class="text-sm font-medium text-gray-600"
+                  >
+                    {{ row.acupoints[2].index }}
+                  </div>
+                </div>
+                <!-- 第三个穴位:穴位列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[2]?.acupointName"
+                    class="text-black-600 hover:text-black-800 cursor-pointer hover:underline"
+                    @click.stop="handleAcupointClick(row.acupoints[2] as any)"
+                  >
+                    {{ row.acupoints[2].acupointName }}
+                  </div>
+                </div>
+                <!-- 第四个穴位:序号列 -->
+                <div class="border-r border-gray-300 px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[3]?.acupointName"
+                    class="text-sm font-medium text-gray-600"
+                  >
+                    {{ row.acupoints[3].index }}
+                  </div>
+                </div>
+                <!-- 第四个穴位:穴位列 -->
+                <div class="px-4 py-3 text-center">
+                  <div
+                    v-if="row.acupoints[3]?.acupointName"
+                    class="text-black-600 hover:text-black-800 cursor-pointer hover:underline"
+                    @click.stop="handleAcupointClick(row.acupoints[3] as any)"
+                  >
+                    {{ row.acupoints[3].acupointName }}
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      <!-- end 穴位表格 -->
+
+      <!-- 开具信息 -->
+      <div class="flex flex-col items-end justify-between pt-3">
+        <!-- 诊断信息 -->
+        <div class="align-end flex flex-col items-end rounded-lg pt-5">
+          <div class="flex space-x-6 text-sm">
+            <div v-if="treatmentDetail.diagnosis">
+              <span class="text-gray-600"
+                >{{ $t('treatment.detail.diagnosis') }}:</span
+              >
+              <span class="ml-2">{{ treatmentDetail.diagnosis }}</span>
+            </div>
+            <div v-if="treatmentDetail.behavior">
+              <span class="text-gray-600"
+                >{{ $t('treatment.detail.appearance') }}:</span
+              >
+              <span class="ml-2">{{ treatmentDetail.behavior }}</span>
+            </div>
+            <div v-if="treatmentDetail.constitution">
+              <span class="text-gray-600"
+                >{{ $t('treatment.detail.constitution') }}:</span
+              >
+              <span class="ml-2">{{ treatmentDetail.constitution }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="mb-2 mt-3 flex space-x-4 text-sm">
+          <div v-if="treatmentDetail.issueInstitutionName">
+            <span class="text-gray-600"
+              >{{ $t('treatment.detail.openOrganization') }}:</span
+            >
+            <span class="ml-2">{{ treatmentDetail.issueInstitutionName }}</span>
+          </div>
+          <div v-if="treatmentDetail.issueDoctorName">
+            <span class="text-gray-600"
+              >{{ $t('treatment.detail.openDoctor') }}:</span
+            >
+            <span class="ml-2">{{ treatmentDetail.issueDoctorName }}</span>
+          </div>
+          <div v-if="treatmentDetail.issueDate">
+            <span class="text-gray-600"
+              >{{ $t('treatment.detail.openTime') }}:</span
+            >
+            <span class="ml-2">{{ treatmentDetail.issueDate }}</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 操作记录 -->
+      <div class="rounded-lg" v-if="operatesData.length > 0">
+        <OperateGrid>
+          <template #treatmentImage="{ row }">
+            <Image
+              :src="row.treatmentImageUrl"
+              height="30"
+              width="30"
+              v-if="row.treatmentImageUrl"
+            />
+            <span v-else class="text-gray-400">-</span>
+          </template>
+        </OperateGrid>
+      </div>
+      <!-- end 操作记录 -->
+    </div>
+  </div>
+
+  <!-- vben 弹窗:开始治疗 -->
+  <Modal>
+    <div class="px-4 pb-2">
+      <Form />
+    </div>
+  </Modal>
+
+  <!-- vben 弹窗:穴位详情 -->
+  <AcupointModal>
+    <div class="p-0">
+      <div class="p-2">
+        <div class="gap-6">
+          <div class="w-full space-y-3">
+            <div
+              class="flex rounded-xl pl-6"
+              v-if="selectedAcupoint?.acupointName"
+            >
+              <div class="mb-2 text-sm text-gray-600">
+                {{ $t('treatment.detail.acupointName') }}:
+                {{ selectedAcupoint?.acupointName }}
+              </div>
+            </div>
+
+            <div
+              class="flex rounded-xl pl-6"
+              v-for="item in selectedAcupoint?.attributes"
+              :key="item.title"
+              v-show="selectedAcupoint?.attributes.length > 0"
+            >
+              <div class="mb-2 text-sm text-gray-600">
+                {{ item.title }}: {{ item.content }}
+              </div>
+            </div>
+            <div class="flex rounded-xl pl-6" v-if="selectedAcupoint?.photo">
+              <div class="mb-2 text-sm text-gray-600">
+                {{ $t('treatment.detail.photo') }}:
+              </div>
+              <div class="relative overflow-hidden">
+                <Image
+                  :src="selectedAcupoint?.photo"
+                  v-show="selectedAcupoint?.photo"
+                  class="object-contain"
+                  alt=""
+                  style="width: 100px; height: 100px"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </AcupointModal>
+</template>

+ 307 - 0
apps/health-remedy/src/views/treatment/treatment/components/TreatmentTabs.vue

@@ -0,0 +1,307 @@
+<script lang="ts" setup>
+import type { TreatmentModel } from '#/api/method/treatment';
+
+import {
+  computed,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from 'vue';
+
+import { notification } from 'ant-design-vue';
+
+import { listUntreatedProjectsMethod } from '#/api/method/treatment';
+import { $t } from '#/locales';
+
+const props = defineProps<{
+  activeTab?: string;
+  patients?: Array<{
+    age: number;
+    itemVOS: Array<{
+      id: string;
+      itemCode: string;
+      itemName: string;
+      itemState: number;
+    }>;
+    name: string;
+    patientId: string;
+    phone: string;
+    sex: string;
+  }>;
+}>();
+
+const emit = defineEmits<{
+  autoSelectPatient: [];
+  tabChange: [value: string];
+  'update:activeTab': [value: string];
+}>();
+
+const titleTabs = ref<TreatmentModel.TreatmentTab[]>(
+  [] as TreatmentModel.TreatmentTab[],
+);
+
+// 计算所有治疗项目的总数
+const calculateTotalCount = () => {
+  if (!props.patients || props.patients.length === 0) {
+    return 0;
+  }
+
+  return props.patients.reduce((total, patient) => {
+    return total + (patient.itemVOS ? patient.itemVOS.length : 0);
+  }, 0);
+};
+
+// 刷新标签页数据的方法
+const refreshTabs = async () => {
+  try {
+    // 获取未治疗的项目
+    const res = await listUntreatedProjectsMethod();
+    let untreatedTabs: TreatmentModel.TreatmentTab[] = [];
+    if (Array.isArray(res)) {
+      untreatedTabs = res;
+    } else if (res) {
+      untreatedTabs = [res];
+    }
+
+    // 始终显示"全部"标签
+    titleTabs.value = [
+      {
+        itemName: '全部',
+        patientNum: calculateTotalCount(), // 使用计算函数
+      },
+      ...(untreatedTabs || []),
+    ];
+  } catch (error: any) {
+    notification.error({
+      message:
+        error.message || $t('treatment.tabs.getUntreatedProjectsFailure'),
+    });
+  }
+};
+
+// 暴露方法给父组件
+defineExpose({
+  refreshTabs,
+});
+
+watch(
+  () => props.patients,
+  () => {
+    if (titleTabs.value.length > 0) {
+      titleTabs.value[0]!.patientNum = calculateTotalCount();
+    }
+  },
+  { deep: true },
+);
+const activeTab = computed({
+  get: () => props.activeTab || '',
+  set: (value) => {
+    emit('update:activeTab', value);
+    emit('tabChange', value);
+  },
+});
+
+const handleTabClick = async (tab: TreatmentModel.TreatmentTab) => {
+  const newTabValue = activeTab.value === tab.itemName ? '' : tab.itemName;
+  activeTab.value = newTabValue;
+
+  await nextTick();
+  emit('autoSelectPatient');
+};
+
+const containerRef = ref<HTMLDivElement | null>(null);
+
+let wheelHandler: ((e: WheelEvent) => void) | null = null;
+let mouseDownHandler: ((e: MouseEvent) => void) | null = null;
+let mouseMoveHandler: ((e: MouseEvent) => void) | null = null;
+let mouseUpHandler: ((e: MouseEvent) => void) | null = null;
+
+const isDragging = ref(false);
+let dragStartX = 0;
+let dragStartScrollLeft = 0;
+
+onMounted(async () => {
+  const el = containerRef.value;
+  if (!el) return;
+
+  wheelHandler = (e: WheelEvent) => {
+    // 仅当存在横向可滚动内容时处理
+    if (!el) return;
+    const hasHorizontalOverflow = el.scrollWidth > el.clientWidth;
+    if (!hasHorizontalOverflow) return;
+
+    // 当用户上下滚时,改为横向滚动
+    if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
+      e.preventDefault();
+      el.scrollLeft += e.deltaY;
+    }
+  };
+
+  el.addEventListener('wheel', wheelHandler, { passive: false });
+
+  mouseDownHandler = (e: MouseEvent) => {
+    isDragging.value = true;
+    dragStartX = e.clientX;
+    dragStartScrollLeft = el.scrollLeft;
+    el.classList.add('dragging');
+  };
+
+  mouseMoveHandler = (e: MouseEvent) => {
+    if (!isDragging.value) return;
+    const delta = e.clientX - dragStartX;
+    el.scrollLeft = dragStartScrollLeft - delta;
+  };
+
+  mouseUpHandler = () => {
+    isDragging.value = false;
+    el.classList.remove('dragging');
+  };
+
+  el.addEventListener('mousedown', mouseDownHandler);
+  window.addEventListener('mousemove', mouseMoveHandler);
+  window.addEventListener('mouseup', mouseUpHandler);
+  // 初始化标签页数据
+  await refreshTabs();
+  // 默认选中"全部"标签
+  activeTab.value = '全部';
+});
+
+onBeforeUnmount(() => {
+  const el = containerRef.value;
+  if (el && wheelHandler) {
+    el.removeEventListener('wheel', wheelHandler as EventListener);
+  }
+  if (el && mouseDownHandler) {
+    el.removeEventListener('mousedown', mouseDownHandler as EventListener);
+  }
+  if (mouseMoveHandler) {
+    window.removeEventListener('mousemove', mouseMoveHandler as EventListener);
+  }
+  if (mouseUpHandler) {
+    window.removeEventListener('mouseup', mouseUpHandler as EventListener);
+  }
+  wheelHandler = null;
+  mouseDownHandler = null;
+  mouseMoveHandler = null;
+  mouseUpHandler = null;
+});
+</script>
+
+<template>
+  <div class="w-full border-b bg-white px-6 py-1">
+    <!-- 标签页容器 - 支持水平滚动 -->
+    <div class="tabs-container" ref="containerRef">
+      <div class="tabs-wrapper">
+        <button
+          v-for="tab in titleTabs"
+          :key="tab.itemName"
+          @click="handleTabClick(tab)"
+          class="tab-button"
+          :class="
+            activeTab === tab.itemName
+              ? 'tab-button-active'
+              : 'tab-button-inactive'
+          "
+        >
+          {{ tab.itemName }}
+          <span
+            v-if="tab.patientNum !== undefined"
+            class="tab-count"
+            :class="
+              activeTab === tab.itemName
+                ? 'tab-count-active'
+                : 'tab-count-inactive'
+            "
+          >
+            {{ tab.patientNum }}
+          </span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* 标签页容器 - 支持水平滚动 */
+.tabs-container {
+  width: 100%;
+  overflow: auto hidden;
+  scroll-behavior: smooth;
+
+  /* 隐藏滚动条 */
+  -ms-overflow-style: none; /* IE and Edge */
+  scrollbar-width: none; /* Firefox */
+}
+
+.tabs-container::-webkit-scrollbar {
+  display: none; /* Chrome, Safari and Opera */
+}
+
+/* 标签页包装器 */
+.tabs-wrapper {
+  display: flex;
+  gap: 4px;
+  align-items: center;
+  min-width: max-content;
+  padding-bottom: 4px;
+}
+
+/* 标签按钮基础样式 */
+.tab-button {
+  position: relative;
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  padding: 8px 16px;
+  font-size: 14px;
+  font-weight: 500;
+  white-space: nowrap;
+  cursor: pointer;
+  background: none;
+  border: none;
+
+  /* 固定边框高度,避免抖动 */
+  border-bottom: 2px solid transparent;
+  transition: all 0.2s ease;
+}
+
+/* 激活状态的标签按钮 */
+.tab-button-active {
+  color: #2563eb;
+  border-bottom-color: #2563eb;
+}
+
+.tab-button-active .tab-count {
+  color: #2563eb;
+}
+
+/* 非激活状态的标签按钮 */
+.tab-button-inactive {
+  color: #2563eb;
+}
+
+.tab-button-inactive:hover {
+  color: #1d4ed8;
+}
+
+/* 标签计数样式 */
+.tab-count {
+  padding: 2px 6px;
+  margin-left: 4px;
+  font-size: 12px;
+  border-radius: 9999px;
+}
+
+.tab-count-active {
+  color: #2563eb;
+  background-color: #dbeafe;
+  border: 1px solid #2563eb;
+}
+
+.tab-count-inactive {
+  color: #2563eb;
+  background-color: #f3f4f6;
+}
+</style>

+ 115 - 0
apps/health-remedy/src/views/treatment/treatment/data.ts

@@ -0,0 +1,115 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { OperateModel } from '#/api/method/operate';
+
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'itemName',
+      label: $t('operate.record.name'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'operateUserName',
+      label: $t('operate.record.operationUser'),
+    },
+    {
+      component: 'RangePicker',
+      fieldName: 'operateTimeRange',
+      label: $t('operate.record.operationTime'),
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        style: { width: '100%' },
+        placeholder: [
+          $t('operate.record.startTime'),
+          $t('operate.record.endTime'),
+        ],
+        onChange: (_dates: any, _dateStrings: string[]) => {},
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'startTime',
+      label: '',
+      componentProps: {
+        style: { display: 'none' },
+      },
+    },
+    {
+      component: 'Input',
+      fieldName: 'endTime',
+      label: '',
+      componentProps: {
+        style: { display: 'none' },
+      },
+    },
+  ];
+}
+
+export function useUserTableColumns<T = OperateModel.Record>(
+  onActionClick?: OnActionClickFn<T>,
+): VxeTableGridOptions<T>['columns'] {
+  return [
+    {
+      field: 'itemName',
+      title: $t('operate.record.name'),
+      minWidth: 100,
+    },
+    {
+      field: 'operateDate',
+      title: $t('operate.record.operationTime'),
+      minWidth: 100,
+    },
+    {
+      field: 'operateUserName',
+      title: $t('operate.record.operationUser'),
+      minWidth: 100,
+    },
+    {
+      field: 'remark',
+      title: $t('operate.record.operationRemark'),
+      minWidth: 100,
+    },
+    {
+      field: 'name',
+      title: $t('operate.record.patientName'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueInstitutionName',
+      title: $t('operate.record.openInstitution'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueDoctorName',
+      title: $t('operate.record.openDoctor'),
+      minWidth: 100,
+    },
+    {
+      field: 'issueDate',
+      title: $t('operate.record.openTime'),
+      minWidth: 100,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        name: 'CellOperation',
+        options: [{ code: 'view', text: '查看' }],
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('operate.record._'),
+          onClick: onActionClick,
+        },
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 100,
+    },
+  ];
+}

+ 342 - 0
apps/health-remedy/src/views/treatment/treatment/list.vue

@@ -0,0 +1,342 @@
+<script lang="ts" setup>
+import type { TreatmentModel } from '#/api/method/treatment';
+
+import { onActivated, onBeforeUnmount, onMounted, ref } from 'vue';
+
+import {
+  getTreatmentDetailMethod,
+  listPatientsMethod,
+} from '#/api/method/treatment';
+
+import PatientList from './components/PatientList.vue';
+import TreatmentDetail from './components/TreatmentDetail.vue';
+import TreatmentTabs from './components/TreatmentTabs.vue';
+// 当前选中的标签页(空字符串表示不筛选)
+const activeTab = ref('');
+
+// 访问 PatientList
+const patientListRef = ref<InstanceType<typeof PatientList> | null>(null);
+
+const treatmentTabsRef = ref<InstanceType<typeof TreatmentTabs> | null>(null);
+
+// 患者数据(与 PatientList 保持一致)
+const patients = ref<TreatmentModel.Patient[]>([]);
+
+// 当前选中的患者
+const selectedPatient = ref<TreatmentModel.Patient>({
+  patientId: '',
+  name: '',
+  sex: '',
+  age: 0,
+  phone: '',
+  itemVOS: [
+    {
+      id: '',
+      itemCode: '',
+      itemName: '',
+      itemState: 0,
+    },
+  ],
+});
+const selectedTreatment = ref<any | null>(null);
+const selectedTreatmentDetail = ref<any | null>(null);
+const selectedTreatmentIndex = ref<number>(0);
+
+// 每个治疗项的倒计时管理
+const treatmentCountdowns = ref<Record<string, number>>({});
+const countdownTimers = ref<Record<string, number>>({});
+// 移除endingTreatments,现在由子组件处理治疗结束逻辑
+// 刷新中的节流/去重标记
+const isRefreshing = ref(false);
+
+onMounted(async () => {
+  const res = await listPatientsMethod();
+  patients.value = res?.items ?? ([] as TreatmentModel.Patient[]);
+  selectedPatient.value = patients.value[0]!;
+  if (
+    selectedPatient.value.itemVOS &&
+    selectedPatient.value.itemVOS.length > 0
+  ) {
+    selectedTreatmentIndex.value = 0; // 默认选择第一个治疗项目
+    await buildTreatmentDetailFromBasic(selectedPatient.value.itemVOS[0]);
+  }
+});
+
+// 从其他菜单切回本页时触发刷新
+onActivated(async () => {
+  // 刷新患者数据和标签页数据
+  await refreshData({ treatmentId: selectedTreatment.value?.id });
+});
+
+// 组件卸载时清理所有定时器
+onBeforeUnmount(() => {
+  Object.values(countdownTimers.value).forEach((timerId) => {
+    if (timerId) {
+      clearInterval(timerId);
+    }
+  });
+  countdownTimers.value = {};
+  treatmentCountdowns.value = {};
+});
+
+const buildTreatmentDetailFromBasic = async (basic: any) => {
+  const treatmentDetail = await getTreatmentDetailMethod(basic.id);
+  if (treatmentDetail && Object.keys(treatmentDetail).length > 0) {
+    selectedTreatmentDetail.value = treatmentDetail;
+    selectedTreatment.value = treatmentDetail;
+  }
+  return {
+    ...treatmentDetail,
+  };
+};
+
+// 处理标签页切换
+const handleTabChange = (tab: string) => {
+  activeTab.value = tab;
+};
+
+const handleAutoSelectPatient = () => {
+  patientListRef.value?.autoSelectFirstPatient();
+};
+
+const handleFilterAndSelect = async (payload: {
+  treatmentName: string;
+  treatmentType: string;
+}) => {
+  const { treatmentName } = payload;
+
+  const filteredPatients = patients.value.filter((patient) => {
+    return patient.itemVOS.some(
+      (item) => item.itemName.includes(treatmentName) && item.itemState === 0, // 0表示未开始
+    );
+  });
+
+  if (filteredPatients.length > 0) {
+    // 选择第一个患者
+    const firstPatient = filteredPatients[0];
+    if (firstPatient) {
+      selectedPatient.value = firstPatient;
+
+      // 找到该患者的第一个未开始治疗项目
+      const untreatedItem = firstPatient.itemVOS.find(
+        (item) => item.itemName.includes(treatmentName) && item.itemState === 0,
+      );
+
+      if (untreatedItem) {
+        // 找到该治疗项目在患者治疗列表中的索引
+        const treatmentIndex = firstPatient.itemVOS.findIndex(
+          (item) => item.id === untreatedItem.id,
+        );
+        // 构建治疗详情并选中
+        const treatmentDetail =
+          await buildTreatmentDetailFromBasic(untreatedItem);
+        selectedTreatment.value = treatmentDetail;
+        selectedTreatmentDetail.value = treatmentDetail;
+        selectedTreatmentIndex.value = treatmentIndex;
+      }
+    }
+  }
+};
+
+// 处理患者选择
+const handlePatientSelect = async (patient: any) => {
+  selectedPatient.value = patient;
+
+  // 如果患者有治疗项目,自动选择第一个治疗项目
+  if (patient.itemVOS && patient.itemVOS.length > 0) {
+    const firstTreatment = patient.itemVOS[0];
+    selectedTreatment.value = firstTreatment;
+    selectedTreatmentIndex.value = 0;
+    selectedTreatmentDetail.value =
+      await buildTreatmentDetailFromBasic(firstTreatment);
+  }
+};
+
+const handleTreatmentSelect = async (payload: {
+  index: number;
+  patient: any;
+  treatment: any;
+}) => {
+  selectedPatient.value = payload.patient;
+  selectedTreatment.value = payload.treatment;
+  selectedTreatmentIndex.value = payload.index;
+  selectedTreatmentDetail.value = await buildTreatmentDetailFromBasic(
+    payload.treatment,
+  );
+};
+
+// 刷新数据(保持当前选择的治疗项,不再总是回到第一个)
+const refreshData = async (payload?: {
+  minutes?: number;
+  treatmentId?: string;
+}) => {
+  if (isRefreshing.value) {
+    return;
+  }
+  isRefreshing.value = true;
+  try {
+    // 重新获取患者列表
+    const res = await listPatientsMethod();
+    patients.value = res?.items ?? ([] as TreatmentModel.Patient[]);
+
+    // 根据当前选中患者的ID找到更新后的患者数据
+    const currentPatientId = selectedPatient.value.patientId;
+    const updatedPatient = patients.value.find(
+      (p) => p.patientId === currentPatientId,
+    );
+
+    if (updatedPatient) {
+      selectedPatient.value = updatedPatient;
+      const targetTreatmentId =
+        payload?.treatmentId ||
+        selectedTreatment.value?.id ||
+        updatedPatient.itemVOS?.[0]?.id;
+      if (targetTreatmentId) {
+        const treatmentIndex = updatedPatient.itemVOS.findIndex(
+          (item) => item.id === targetTreatmentId,
+        );
+        if (treatmentIndex !== -1) {
+          selectedTreatmentIndex.value = treatmentIndex;
+        }
+        const updatedDetail = await buildTreatmentDetailFromBasic({
+          id: targetTreatmentId,
+        });
+        console.warn('刷新治疗详情后状态:', updatedDetail?.itemState);
+        console.warn(
+          'selectedTreatmentDetail状态:',
+          selectedTreatmentDetail.value?.itemState,
+        );
+        console.warn(
+          'selectedTreatment状态:',
+          selectedTreatment.value?.itemState,
+        );
+      }
+    }
+
+    // 若保存时传入了分钟,则继续驱动倒计时
+    if (payload?.treatmentId && payload?.minutes) {
+      handleStartTreatment({
+        treatmentId: payload.treatmentId,
+        minutes: payload.minutes,
+      });
+    }
+
+    // 刷新标签页数据,因为治疗状态改变会影响未治疗项目的计数
+    await treatmentTabsRef.value?.refreshTabs();
+  } finally {
+    isRefreshing.value = false;
+  }
+};
+
+// 处理开始治疗
+const handleStartTreatment = async (payload: {
+  minutes: number;
+  treatmentId: string;
+}) => {
+  console.warn('handleStartTreatment被调用:', payload);
+  const { treatmentId, minutes } = payload;
+
+  // 停止该治疗项之前的倒计时
+  if (countdownTimers.value[treatmentId]) {
+    clearInterval(countdownTimers.value[treatmentId]);
+  }
+
+  // 设置新的倒计时
+  const seconds = minutes * 60;
+  console.warn('seconds:', seconds);
+  console.warn('treatmentCountdowns:', treatmentCountdowns.value);
+  treatmentCountdowns.value[treatmentId] = seconds;
+  console.warn(`开始治疗: ${treatmentId}, 设置倒计时: ${seconds}秒`);
+
+  // 启动倒计时 - 现在由子组件处理计时结束逻辑
+  countdownTimers.value[treatmentId] = window.setInterval(() => {
+    const raw = treatmentCountdowns.value[treatmentId];
+    const currentCount = Number.isFinite(Number(raw)) ? Number(raw) : 0;
+
+    if (currentCount > 0) {
+      treatmentCountdowns.value[treatmentId] = currentCount - 1;
+      console.warn(`倒计时进行中: ${treatmentId} 剩余 ${currentCount - 1} 秒`);
+    } else {
+      // 倒计时结束,清理定时器
+      if (countdownTimers.value[treatmentId]) {
+        clearInterval(countdownTimers.value[treatmentId]);
+        delete countdownTimers.value[treatmentId];
+        delete treatmentCountdowns.value[treatmentId];
+      }
+      console.warn(`倒计时结束: ${treatmentId}`);
+    }
+  }, 1000);
+
+  // 更新左侧状态为治疗中
+  await updateTreatmentStatus(treatmentId, 'in-progress');
+};
+
+// 更新治疗项状态(需要与 PatientList 组件通信)
+const updateTreatmentStatus = async (
+  treatmentId: string,
+  status: 'completed' | 'in-progress' | 'not-started',
+) => {
+  try {
+    const res = await listPatientsMethod();
+    patients.value = res?.items ?? ([] as TreatmentModel.Patient[]);
+
+    // 更新当前选中的患者数据
+    const currentPatientId = selectedPatient.value.patientId;
+    const updatedPatient = patients.value.find(
+      (p) => p.patientId === currentPatientId,
+    );
+    if (updatedPatient) {
+      selectedPatient.value = updatedPatient;
+    }
+  } catch (error) {
+    console.warn('更新患者列表失败:', error);
+  }
+
+  // 更新当前选中的治疗项状态
+  if (selectedTreatment.value?.id === treatmentId) {
+    selectedTreatment.value.status = status;
+    selectedTreatmentDetail.value = buildTreatmentDetailFromBasic(
+      selectedTreatment.value,
+    );
+  }
+};
+</script>
+
+<template>
+  <div class="flex h-screen flex-col">
+    <!-- 顶部导航栏 -->
+    <div class="flex-shrink-0">
+      <TreatmentTabs
+        ref="treatmentTabsRef"
+        v-model:active-tab="activeTab"
+        :patients="patients"
+        @auto-select-patient="handleAutoSelectPatient"
+        @tab-change="handleTabChange"
+        @filter-and-select="handleFilterAndSelect"
+      />
+    </div>
+
+    <!-- 主要内容区域 -->
+    <div class="flex flex-1 overflow-hidden">
+      <!-- 左侧患者列表 -->
+      <PatientList
+        ref="patientListRef"
+        :selected-patient-id="selectedPatient.patientId"
+        :active-tab="activeTab"
+        :patients="patients"
+        @patient-select="handlePatientSelect"
+        @treatment-select="handleTreatmentSelect"
+      />
+
+      <!-- 右侧治疗详情 -->
+      <TreatmentDetail
+        :patient="selectedPatient"
+        :treatment="selectedTreatmentDetail"
+        :index="selectedTreatmentIndex"
+        :countdown="treatmentCountdowns[selectedTreatmentDetail?.id || '']"
+        @start-treatment="handleStartTreatment"
+        @refresh-data="refreshData"
+      />
+    </div>
+  </div>
+</template>

+ 92 - 0
apps/health-remedy/src/views/treatment/treatment/modules/form.vue

@@ -0,0 +1,92 @@
+<script lang="ts" setup>
+import type { OperateModel } from '#/api/method/operate';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Divider } from 'ant-design-vue';
+
+import { getRecordDetailMethod } from '#/api/method/operate';
+import { $t } from '#/locales';
+
+const detail = ref<OperateModel.Record>({} as OperateModel.Record);
+
+const title = computed(() => $t('operate.record.title'));
+
+const [Modal, modalApi] = useVbenModal({
+  showConfirmButton: false,
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<OperateModel.Record>();
+      if (data && data.id) {
+        const res = await getRecordDetailMethod(data.id);
+        detail.value = res || ({} as OperateModel.Record);
+      }
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="title">
+    <div class="select-text p-4">
+      <div class="space-y-4 rounded-b">
+        <div class="font-bold" v-if="detail.itemName">
+          {{ $t('operate.record.name') }}:
+          {{ detail.itemName || '-' }}
+        </div>
+        <div class="font-bold" v-if="detail.operateDate">
+          {{ $t('operate.record.operationTime') }}:
+          {{ detail.operateDate || '-' }}
+        </div>
+        <div class="flex">
+          <div v-if="detail.operateUserName">
+            {{ $t('operate.record.operationUser') }}:
+            {{ detail.operateUserName || '-' }}
+          </div>
+          <div v-if="detail.treatmentTime">
+            {{ $t('operate.record.treatmentTime') }}:
+            {{ detail.treatmentTime || '-' }}{{ $t('operate.record.minutes') }}
+          </div>
+        </div>
+        <div v-if="detail.remark">
+          {{ $t('operate.record.treatmentDescription') }}:
+          {{ detail.remark || '-' }}
+        </div>
+        <div class="flex items-start space-x-2" v-if="detail.treatmentImageUrl">
+          <span class="leading-8">
+            {{ $t('operate.record.treatmentPhoto') }}:
+          </span>
+          <img
+            v-if="detail.treatmentImageUrl"
+            :src="detail.treatmentImageUrl"
+            alt=""
+            class="h-36 w-56 border object-cover"
+          />
+          <span v-else class="leading-8">-</span>
+        </div>
+        <Divider />
+        <div v-if="detail.name">
+          {{ $t('operate.record.patientName') }}:
+          {{ detail.name || '-' }}
+        </div>
+        <div v-if="detail.issueDoctorName">
+          {{ $t('operate.record.openDoctor') }}:
+          {{ detail.issueDoctorName || '-' }}
+        </div>
+        <div v-if="detail.issueDate">
+          {{ $t('operate.record.openTime') }}:{{ detail.issueDate || '-' }}
+        </div>
+        <div v-if="detail.acupointName">
+          {{ $t('operate.record.acupoint') }}:
+          {{ detail.acupointName?.join(',') || '-' }}
+        </div>
+        <div v-if="detail.describe">
+          {{ $t('operate.record.description') }}:
+          {{ detail.describe || '-' }}
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>

+ 2 - 2
apps/health-remedy/vite.config.mts

@@ -6,7 +6,7 @@ export default defineConfig(async () => {
     vite: {
       server: {
         proxy: {
-          '/dz': {
+          '/wf': {
             changeOrigin: true,
             target: `https://wx.hzliuzhi.com:4433`,
             ws: true,
@@ -15,7 +15,7 @@ export default defineConfig(async () => {
             changeOrigin: true,
             rewrite: (path) => path.replace(/^\/api/, ''),
             // mock代理目标地址
-            target: 'http://localhost:5320/api',
+            target: 'http://192.168.1.16:8039',
             ws: true,
           },
         },

+ 10 - 1
packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue

@@ -64,6 +64,8 @@ interface Props {
   trigger?: 'both' | 'click' | 'hover';
   /** hover触发时,延迟响应的时间 */
   hoverDelay?: number;
+  /** 是否在头像右侧展示名称(仅头部触发器处) */
+  showTextInHeader?: boolean;
 }
 
 defineOptions({
@@ -80,6 +82,7 @@ const props = withDefaults(defineProps<Props>(), {
   text: '',
   trigger: 'click',
   hoverDelay: 500,
+  showTextInHeader: true,
 });
 
 const emit = defineEmits<{ logout: [] }>();
@@ -191,8 +194,14 @@ if (enableShortcutKey.value) {
   <DropdownMenu v-model:open="openPopover">
     <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
       <div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5">
-        <div class="hover:text-accent-foreground flex-center">
+        <div class="hover:text-accent-foreground flex items-center">
           <VbenAvatar :alt="text" :src="avatar" class="size-8" dot />
+          <span
+            v-if="props.showTextInHeader && text"
+            class="ml-2 hidden max-w-[10rem] truncate md:inline"
+          >
+            {{ text }}
+          </span>
         </div>
       </div>
     </DropdownMenuTrigger>

+ 1 - 0
packages/locales/src/langs/zh-CN/common.json

@@ -14,6 +14,7 @@
   "search": "搜索",
   "enabled": "已启用",
   "disabled": "已禁用",
+  "all": "全部",
   "edit": "修改",
   "delete": "删除",
   "create": "新增",