Forráskód Böngészése

chore(@six/wisdom-legacy): 工作室管理

cc12458 1 hete
szülő
commit
95788b19b0
28 módosított fájl, 1918 hozzáadás és 0 törlés
  1. 161 0
      apps/wisdom-legacy/openApi/business/addDepartment.openapi.json
  2. 131 0
      apps/wisdom-legacy/openApi/business/addMember.openapi.json
  3. 13 0
      apps/wisdom-legacy/src/api/account/account.api.ts
  4. 15 0
      apps/wisdom-legacy/src/api/account/account.schema.ts
  5. 3 0
      apps/wisdom-legacy/src/api/workroom/index.ts
  6. 47 0
      apps/wisdom-legacy/src/api/workroom/news.api.ts
  7. 83 0
      apps/wisdom-legacy/src/api/workroom/news.schema.ts
  8. 77 0
      apps/wisdom-legacy/src/api/workroom/team.api.ts
  9. 139 0
      apps/wisdom-legacy/src/api/workroom/team.schema.ts
  10. 83 0
      apps/wisdom-legacy/src/api/workroom/workroom.api.ts
  11. 221 0
      apps/wisdom-legacy/src/api/workroom/workroom.schema.ts
  12. 40 0
      apps/wisdom-legacy/src/components/workroom/Selector.vue
  13. 4 0
      apps/wisdom-legacy/src/layouts/BasicLayout.vue
  14. 12 0
      apps/wisdom-legacy/src/locales/langs/zh-CN/workroom.json
  15. 7 0
      apps/wisdom-legacy/src/router/guard/access.guard.ts
  16. 10 0
      apps/wisdom-legacy/src/stores/auth.store.ts
  17. 1 0
      apps/wisdom-legacy/src/stores/index.ts
  18. 55 0
      apps/wisdom-legacy/src/stores/workroom.store.ts
  19. 29 0
      apps/wisdom-legacy/src/views/workroom/Management.vue
  20. 42 0
      apps/wisdom-legacy/src/views/workroom/TeamMemberList.vue
  21. 78 0
      apps/wisdom-legacy/src/views/workroom/Workspace.vue
  22. 75 0
      apps/wisdom-legacy/src/views/workroom/components/WorkroomInfoCard.vue
  23. 123 0
      apps/wisdom-legacy/src/views/workroom/components/WorkroomNewsCard.vue
  24. 22 0
      apps/wisdom-legacy/src/views/workroom/modules/DynamicNewsEdit.vue
  25. 22 0
      apps/wisdom-legacy/src/views/workroom/modules/TeamMemberEdit.vue
  26. 26 0
      apps/wisdom-legacy/src/views/workroom/modules/WorkroomEdit.vue
  27. 126 0
      apps/wisdom-legacy/src/views/workroom/team.data.ts
  28. 273 0
      apps/wisdom-legacy/src/views/workroom/workroom.data.ts

+ 161 - 0
apps/wisdom-legacy/openApi/business/addDepartment.openapi.json

@@ -0,0 +1,161 @@
+{
+  "openapi": "3.1.0",
+  "info": {
+    "title": "智慧传承系统pc端",
+    "description": "智慧传承系统pc端—相关接口文档",
+    "version": "1.0-SNAPSHOT"
+  },
+  "paths": {
+    "/basis/department/add": {
+      "post": {
+        "tags": ["工作室管理API"],
+        "summary": "新增工作室",
+        "operationId": "addPs",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PersonalStudioDetail"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "*/*": {
+                "schema": {
+                  "$ref": "#/components/schemas/AjaxResult"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "PersonalStudioDetail": {
+        "type": "object",
+        "properties": {
+          "pid": {
+            "type": "integer",
+            "format": "int64",
+            "description": "主键ID"
+          },
+          "deptCode": {
+            "type": "string",
+            "description": "工作室编码",
+            "minLength": 1
+          },
+          "deptName": {
+            "type": "string",
+            "description": "工作室名称",
+            "minLength": 1
+          },
+          "profile": {
+            "type": "string",
+            "description": "工作室简介"
+          },
+          "hospitalId": {
+            "type": "integer",
+            "format": "int64",
+            "description": "机构id"
+          },
+          "hospitalName": {
+            "type": "string",
+            "description": "机构名称",
+            "minLength": 1
+          },
+          "doctorName": {
+            "type": "string",
+            "description": "医生姓名",
+            "minLength": 1
+          },
+          "doctorProfile": {
+            "type": "string",
+            "description": "医生简介"
+          },
+          "curatorName": {
+            "type": "string",
+            "description": "负责人姓名",
+            "minLength": 1
+          },
+          "phoneNumber": {
+            "type": "string",
+            "description": "联系电话",
+            "minLength": 1
+          },
+          "specificDirection": {
+            "type": "string",
+            "description": "专科方向"
+          },
+          "sectName": {
+            "type": "string",
+            "description": "流派名称"
+          },
+          "sectImpart": {
+            "type": "string",
+            "description": "流派传承"
+          },
+          "impartTeam": {
+            "type": "string",
+            "description": "传承团队"
+          },
+          "createBy": {
+            "type": "string",
+            "description": "创建者"
+          },
+          "createTime": {
+            "type": "string",
+            "format": "date-time",
+            "description": "创建时间"
+          },
+          "updateBy": {
+            "type": "string",
+            "description": "更新者"
+          },
+          "updateTime": {
+            "type": "string",
+            "format": "date-time",
+            "description": "更新时间"
+          },
+          "remark": {
+            "type": "string",
+            "description": "备注"
+          }
+        },
+        "required": [
+          "curatorName",
+          "deptCode",
+          "deptName",
+          "doctorName",
+          "hospitalId",
+          "hospitalName",
+          "phoneNumber"
+        ]
+      },
+      "AjaxResult": {
+        "type": "object",
+        "description": "接口返回对象",
+        "properties": {
+          "code": {
+            "type": "integer",
+            "format": "int32",
+            "description": "状态码"
+          },
+          "msg": {
+            "type": "string",
+            "description": "提示语"
+          },
+          "data": {
+            "description": "数据对象"
+          }
+        }
+      }
+    }
+  }
+}

+ 131 - 0
apps/wisdom-legacy/openApi/business/addMember.openapi.json

@@ -0,0 +1,131 @@
+{
+  "openapi": "3.1.0",
+  "info": {
+    "title": "智慧传承系统pc端",
+    "description": "智慧传承系统pc端—相关接口文档",
+    "version": "1.0-SNAPSHOT"
+  },
+  "paths": {
+    "/basis/member/add": {
+      "post": {
+        "tags": ["工作室管理API"],
+        "summary": "新增工作室成员",
+        "operationId": "addPsMember",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PersonalStudioMemberDetail"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "*/*": {
+                "schema": {
+                  "$ref": "#/components/schemas/AjaxResult"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "PersonalStudioMemberDetail": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "integer",
+            "format": "int64",
+            "description": "主键ID"
+          },
+          "personalStudioId": {
+            "type": "integer",
+            "format": "int64",
+            "description": "工作室ID"
+          },
+          "name": {
+            "type": "string",
+            "description": "姓名",
+            "minLength": 1
+          },
+          "phone": {
+            "type": "string",
+            "description": "电话",
+            "minLength": 1
+          },
+          "jobTitle": {
+            "type": "string",
+            "description": "职称(详见字典:智慧传承系统-职称)",
+            "minLength": 1,
+            "pattern": "^[01234]$"
+          },
+          "expertTitle": {
+            "type": "string",
+            "description": "专家头衔(详见字典:智慧传承系统-专家头衔)",
+            "pattern": "^[0123]$"
+          },
+          "imageUrl": {
+            "type": "string",
+            "description": "头像"
+          },
+          "jobProfile": {
+            "type": "string",
+            "description": "职责简介",
+            "minLength": 1
+          },
+          "createBy": {
+            "type": "string",
+            "description": "创建者"
+          },
+          "createTime": {
+            "type": "string",
+            "format": "date-time",
+            "description": "创建时间"
+          },
+          "updateBy": {
+            "type": "string",
+            "description": "更新者"
+          },
+          "updateTime": {
+            "type": "string",
+            "format": "date-time",
+            "description": "更新时间"
+          }
+        },
+        "required": [
+          "jobProfile",
+          "jobTitle",
+          "name",
+          "personalStudioId",
+          "phone"
+        ]
+      },
+      "AjaxResult": {
+        "type": "object",
+        "description": "接口返回对象",
+        "properties": {
+          "code": {
+            "type": "integer",
+            "format": "int32",
+            "description": "状态码"
+          },
+          "msg": {
+            "type": "string",
+            "description": "提示语"
+          },
+          "data": {
+            "description": "数据对象"
+          }
+        }
+      }
+    }
+  }
+}

+ 13 - 0
apps/wisdom-legacy/src/api/account/account.api.ts

@@ -7,6 +7,7 @@ import {
   decodeAccessMenu,
   decodeAccessToken,
   decodeAccessUser,
+  decodeAccessWorkroom,
 } from './account.schema';
 
 export type { AccessTokenVO, AccessUserVO } from './account.schema';
@@ -51,3 +52,15 @@ export function accessMenuMethod() {
     transform: arrayTransform(decodeAccessMenu),
   });
 }
+
+/** 当前登录用户所在工作室列表 */
+export function accessWorkroomMethod() {
+  return httpClient.Post(
+    `/wis-pc/basis/department/listByUser`,
+    {},
+    {
+      hitSource: 'login',
+      transform: arrayTransform(decodeAccessWorkroom),
+    },
+  );
+}

+ 15 - 0
apps/wisdom-legacy/src/api/account/account.schema.ts

@@ -2,6 +2,7 @@ import type { BasicUserInfo } from '@vben/types';
 
 import type { MenuVO } from '#/api/system/menu.schema';
 import type { UserDTO } from '#/api/system/user.schema';
+import type { WorkroomDTO, WorkroomVO } from '#/api/workroom/workroom.schema';
 import type { AuthTokenPayload } from '#/request';
 
 import { HOME_PATH } from '@vben/constants';
@@ -89,6 +90,13 @@ export interface AccessMenuDTO {
   children: AccessMenuDTO[];
 }
 
+/**
+ * 当前用户 工作室
+ */
+export interface AccessWorkroomVo extends Partial<WorkroomVO> {
+  id: string;
+}
+
 // ---------------------------------------------------------------------------
 // 编解码
 // ---------------------------------------------------------------------------
@@ -137,3 +145,10 @@ export const decodeAccessMenu = (dto: AccessMenuDTO): MenuVO => {
     children: decodeList(dto.children, decodeAccessMenu),
   };
 };
+
+export const decodeAccessWorkroom = (dto: WorkroomDTO): AccessWorkroomVo => {
+  return {
+    id: dto.pid?.toString() ?? '',
+    name: dto.deptName,
+  };
+};

+ 3 - 0
apps/wisdom-legacy/src/api/workroom/index.ts

@@ -0,0 +1,3 @@
+export * from './news.api';
+export * from './team.api';
+export * from './workroom.api';

+ 47 - 0
apps/wisdom-legacy/src/api/workroom/news.api.ts

@@ -0,0 +1,47 @@
+import type { DynamicNewsVO } from './news.schema';
+import type { WorkroomVO } from './workroom.schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import { arrayTransform } from '#/request/schema';
+
+import { decodeDynamicNews, encodeDynamicNews } from './news.schema';
+
+export type { DynamicNewsVO } from './news.schema';
+export { DynamicNewsVoSchema } from './news.schema';
+
+/** 工作室动态列表 */
+export function listDynamicNewsMethod(
+  workroom: Pick<WorkroomVO, 'id'> | string,
+) {
+  const workroomId = typeof workroom === 'string' ? workroom : workroom.id;
+  return httpClient.Post(
+    `/wis-pc/basis/trend/list/${workroomId}`,
+    {},
+    {
+      hitSource: /^workroom-news:(edit|delete)/,
+      transform: arrayTransform(decodeDynamicNews),
+    },
+  );
+}
+
+/** 新增 / 修改工作室动态 */
+export function editDynamicNewsMethod(vo: DynamicNewsVO) {
+  const method = vo.id ? `update` : 'add';
+  return httpClient.Post(
+    `/wis-pc/basis/trend/${method}`,
+    encodeDynamicNews(vo),
+    {
+      name: 'workroom-news:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 删除工作室动态 */
+export function deleteDynamicNewsMethod(vo: Pick<DynamicNewsVO, 'id'>) {
+  return httpClient.Post(
+    `/wis-pc/basis/trend/delete/${vo.id}`,
+    {},
+    { name: 'workroom-news:delete' },
+  );
+}

+ 83 - 0
apps/wisdom-legacy/src/api/workroom/news.schema.ts

@@ -0,0 +1,83 @@
+import type { Dayjs } from 'dayjs';
+
+import { isDayjs } from 'dayjs';
+
+import { z } from '#/adapter/form';
+
+// ---------------------------------------------------------------------------
+// 类型 — DTO / VO
+// ---------------------------------------------------------------------------
+
+/** 工作室动态 DTO,对应 OpenAPI `PersonalStudioTrendDetail` */
+export interface DynamicNewsDTO {
+  /** 主键 ID */
+  id?: number | string;
+  /** 工作室 ID */
+  personalStudioId?: number | string;
+  /** 动态新闻标题 */
+  title: string;
+  /** 动态新闻内容 */
+  content: string;
+  /** 动态新闻时间 */
+  trendTime?: string;
+}
+/** 工作室 VO */
+export interface DynamicNewsVO {
+  /** 主键 ID,-> `id` */
+  id?: string;
+  /** 工作室 ID,-> `personalStudioId` */
+  workroomId: string /** 动态新闻标题 */;
+  /** 标题,-> `title` */
+  title: string;
+  /** 内容,-> `content` */
+  content: string;
+  /** 时间,-> `trendTime`(表单 DatePicker 可能为 dayjs 对象) */
+  date?: Dayjs | string;
+  /** 时间,-> `trendTime` */
+  datetime?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const DynamicNewsVoSchema = z.object({
+  workroomId: z.string().min(1, '工作室不能为空'),
+  id: z.string().optional(),
+  title: z.string().min(1, `标题不能为空`).max(50, `标题不能超过50个字符`),
+  content: z
+    .string()
+    .min(1, `内容不能为空`)
+    .max(1024, `内容不能超过1024个字符`),
+  date: z.union([
+    z.string().min(1, '日期不能为空'),
+    z.custom<Dayjs>((val) => isDayjs(val), '日期不能为空'),
+  ]),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export const decodeDynamicNews = (dto: DynamicNewsDTO): DynamicNewsVO => {
+  return {
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    title: dto.title,
+    content: dto.content,
+    date: dto.trendTime,
+  };
+};
+
+export const encodeDynamicNews = (vo: DynamicNewsVO): DynamicNewsDTO => {
+  const date = isDayjs(vo.date)
+    ? vo.date.format('YYYY-MM-DD HH:mm:ss')
+    : vo.date;
+  return {
+    personalStudioId: vo.workroomId,
+    id: vo.id,
+    title: vo.title,
+    content: vo.content,
+    trendTime: date,
+  };
+};

+ 77 - 0
apps/wisdom-legacy/src/api/workroom/team.api.ts

@@ -0,0 +1,77 @@
+import type { TeamMemberVO } from './team.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  listPageTransform,
+  pageQueryArgsTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  decodeTeamMember,
+  encodeTeamMember,
+  encodeTeamMemberQuery,
+} from './team.schema';
+
+export type { TeamMemberVO } from './team.schema';
+export {
+  expertTitleOptions,
+  getExpertTitleLabel,
+  getJobTitleLabel,
+  jobTitleOptions,
+  TeamMemberVOSchema,
+} from './team.schema';
+
+/** 团队成员分页列表 */
+export function listTeamMemberMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(args, encodeTeamMemberQuery);
+  const workroomId = data.personalStudioId;
+  return httpClient.Post(
+    `/wis-pc/basis/member/list/${workroomId}`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^workroom-team:(edit|delete)/,
+      transform: listPageTransform(decodeTeamMember),
+    },
+  );
+}
+
+/** 根据成员 ID 获取详情 */
+export function getTeamMemberMethod(vo: Pick<TeamMemberVO, 'id'>) {
+  return httpClient.Post(
+    `/wis-pc/basis/member/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^workroom-team:(edit|delete)/,
+      transform: transform(decodeTeamMember),
+    },
+  );
+}
+
+/** 新增 / 修改团队成员 */
+export function editTeamMemberMethod(vo: TeamMemberVO) {
+  const method = vo.id ? 'update' : 'add';
+  return httpClient.Post(
+    `/wis-pc/basis/member/${method}`,
+    encodeTeamMember(vo),
+    {
+      name: 'workroom-team:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 删除团队成员 */
+export function deleteTeamMemberMethod(vo: Pick<TeamMemberVO, 'id'>) {
+  return httpClient.Post(
+    `/wis-pc/basis/member/delete/${vo.id}`,
+    {},
+    {
+      name: 'workroom-team:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 139 - 0
apps/wisdom-legacy/src/api/workroom/team.schema.ts

@@ -0,0 +1,139 @@
+import type {
+  AuditRecordDTO,
+  AuditRecordVO,
+} from '#/request/schema/audit-record';
+
+import { phone, z } from '#/adapter/form';
+import { decodeAuditRecord } from '#/request/schema/audit-record';
+
+// ---------------------------------------------------------------------------
+// 字典 — 职称 / 专家头衔
+// ---------------------------------------------------------------------------
+
+/** 职称选项,对应字典:智慧传承系统-职称 */
+export const jobTitleOptions = [
+  { label: '主任医师', value: '0' },
+  { label: '副主任医师', value: '1' },
+  { label: '主治医师', value: '2' },
+  { label: '住院医师', value: '3' },
+  { label: '其他', value: '4' },
+] as const;
+
+/** 专家头衔选项,对应字典:智慧传承系统-专家头衔 */
+export const expertTitleOptions = [
+  { label: '国医大师', value: '0' },
+  { label: '全国名中医', value: '1' },
+  { label: '省级名中医', value: '2' },
+  { label: '其他', value: '3' },
+] as const;
+
+// ---------------------------------------------------------------------------
+// 类型 — DTO / VO
+// ---------------------------------------------------------------------------
+
+/** 团队成员 DTO,对应 OpenAPI `PersonalStudioMemberDetail` */
+export interface TeamMemberDTO extends AuditRecordDTO {
+  /** 主键 ID */
+  id?: number | string;
+  /** 工作室 ID */
+  personalStudioId: number | string;
+  /** 姓名 */
+  name: string;
+  /** 电话 */
+  phone: string;
+  /** 职称 */
+  jobTitle: string;
+  /** 专家头衔 */
+  expertTitle?: string;
+  /** 头像 */
+  imageUrl?: string;
+  /** 职责简介 */
+  jobProfile: string;
+}
+
+/** 团队成员 VO */
+export interface TeamMemberVO extends AuditRecordVO {
+  /** 主键 ID,-> `id` */
+  id?: string;
+  /** 工作室 ID,-> `personalStudioId` */
+  workroomId: string;
+  /** 姓名,-> `name` */
+  name: string;
+  /** 电话,-> `phone` */
+  phone: string;
+  /** 职称,-> `jobTitle` */
+  rank: string;
+  /** 专家头衔,-> `expertTitle` */
+  title?: string;
+  /** 头像,-> `imageUrl` */
+  avatar?: string;
+  /** 职责简介,-> `jobProfile` */
+  description: string;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const TeamMemberVOSchema = z.object({
+  workroomId: z.string().min(1, '工作室不能为空'),
+  id: z.string().optional(),
+  name: z.string().min(1, '姓名不能为空').max(50, '姓名不能超过50个字符'),
+  phone: phone(),
+  rank: z.string({ required_error: '职称不能为空' }).min(1, '职称不能为空'),
+  title: z
+    .string({ required_error: '专家头衔不能为空' })
+    .min(1, '专家头衔不能为空'),
+  avatar: z.string().max(512, '头像地址不能超过512个字符').optional(),
+  description: z
+    .string()
+    .min(1, '职责简介不能为空')
+    .max(512, '职责简介不能超过512个字符'),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export const decodeTeamMember = (dto: TeamMemberDTO): TeamMemberVO => {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    name: dto.name,
+    phone: dto.phone,
+    rank: dto.jobTitle?.toString(),
+    title: dto.expertTitle?.toString(),
+    avatar: dto.imageUrl,
+    description: dto.jobProfile,
+  };
+};
+
+export const encodeTeamMember = (vo: TeamMemberVO): TeamMemberDTO => {
+  return {
+    personalStudioId: vo.workroomId,
+    id: vo.id,
+    name: vo.name,
+    phone: vo.phone,
+    jobTitle: vo.rank,
+    expertTitle: vo.title,
+    imageUrl: vo.avatar,
+    jobProfile: vo.description,
+  };
+};
+
+export const encodeTeamMemberQuery = (
+  vo: Partial<TeamMemberVO>,
+): Partial<TeamMemberDTO> => {
+  return encodeTeamMember(vo as TeamMemberVO);
+};
+
+export function getJobTitleLabel(value?: string) {
+  return jobTitleOptions.find((item) => item.value === value)?.label ?? value;
+}
+
+export function getExpertTitleLabel(value?: string) {
+  return (
+    expertTitleOptions.find((item) => item.value === value)?.label ?? value
+  );
+}

+ 83 - 0
apps/wisdom-legacy/src/api/workroom/workroom.api.ts

@@ -0,0 +1,83 @@
+import type { WorkroomVO } from './workroom.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  arrayTransform,
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  decodeWorkroom,
+  encodeWorkroom,
+  encodeWorkroomQuery,
+} from './workroom.schema';
+
+export type { WorkroomVO } from './workroom.schema';
+export { WorkroomVOSchema } from './workroom.schema';
+
+/** 工作室分页列表 */
+export function listWorkroomMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(args, encodeWorkroomQuery);
+  return httpClient.Post(
+    '/wis-pc/basis/department/listPage',
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^workroom:(edit|delete)/,
+      transform: paginateTransform(decodeWorkroom),
+    },
+  );
+}
+
+/** 根据工作室 ID 获取详情 */
+export function getWorkroomMethod(vo: Pick<WorkroomVO, 'id'>) {
+  return httpClient.Post(
+    `/wis-pc/basis/department/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^workroom:(edit|delete)/,
+      transform: transform(decodeWorkroom),
+    },
+  );
+}
+
+/** 新增 / 修改工作室 */
+export function editWorkroomMethod(vo: WorkroomVO) {
+  const method = vo.id ? `update` : 'add';
+  return httpClient.Post(
+    `/wis-pc/basis/department/${method}`,
+    encodeWorkroom(vo),
+    {
+      name: 'workroom:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 删除工作室 */
+export function deleteWorkroomMethod(vo: Pick<WorkroomVO, 'id'>) {
+  return httpClient.Post(
+    '/wis-pc/basis/department/batchDelete',
+    {},
+    {
+      name: 'workroom:delete',
+      params: { ids: vo.id },
+      meta: { ignoreError: true },
+    },
+  );
+}
+
+/** 工作室下拉选项 */
+export function optionsWorkroomMethod() {
+  return httpClient.Post(
+    '/wis-pc/basis/department/list',
+    {},
+    {
+      transform: arrayTransform(decodeWorkroom),
+    },
+  );
+}

+ 221 - 0
apps/wisdom-legacy/src/api/workroom/workroom.schema.ts

@@ -0,0 +1,221 @@
+import type { Dayjs } from 'dayjs';
+
+import type {
+  AuditRecordDTO,
+  AuditRecordVO,
+} from '#/request/schema/audit-record';
+
+import { $t } from '@vben/locales';
+
+import { isDayjs } from 'dayjs';
+
+import { phone, z } from '#/adapter/form';
+import { decodeAuditRecord } from '#/request/schema/audit-record';
+
+// ---------------------------------------------------------------------------
+// 类型 — DTO / VO
+// ---------------------------------------------------------------------------
+
+/** 工作室 DTO,对应 OpenAPI `PersonalStudioDetail` */
+export interface WorkroomDTO extends AuditRecordDTO {
+  /** 主键 ID */
+  pid?: number | string;
+  /** 工作室编码 */
+  deptCode: string;
+  /** 工作室名称 */
+  deptName: string;
+  /** 工作室简介 */
+  profile?: string;
+  /** 机构 ID */
+  hospitalId?: number | string;
+  /** 机构名称 */
+  hospitalName?: string;
+  /** 医生姓名 */
+  doctorName?: string;
+  /** 医生简介 */
+  doctorProfile?: string;
+  /** 负责人姓名 */
+  curatorName: string;
+  /** 联系电话 */
+  phoneNumber: string;
+  /** 专科方向 */
+  specificDirection?: string;
+  /** 流派名称 */
+  sectName?: string;
+  /** 流派传承 */
+  sectImpart?: string;
+  /** 传承团队 */
+  impartTeam?: string;
+  /** 备注 */
+  remark?: string;
+}
+/** 工作室 VO */
+export interface WorkroomVO extends AuditRecordVO {
+  /** 主键 ID,-> `pid` */
+  id?: string;
+  /** 工作室编码,-> `deptCode` */
+  code: string;
+  /** 工作室名称,-> `deptName` */
+  name: string;
+  /** 工作室简介,-> `profile` */
+  description?: string;
+  /**
+   * 机构,
+   * id -> `hospitalId`
+   * name -> `hospitalName` */
+  department?: { id: string; name: string };
+  /**
+   * 医生,
+   * name -> `doctorName`
+   * description -> `doctorProfile`
+   */
+  doctor: { description?: string; id?: string; name?: string };
+  /**
+   * 流派,
+   * 流派名称 name -> `sectName`
+   * 流派传承 description -> `sectImpart`
+   */
+  sect: { description?: string; id?: string; name?: string };
+  /**
+   * 传承,
+   * 专科方向 name -> `specificDirection`
+   * 传承团队 description -> `impartTeam`
+   */
+  smriti: { description?: string; id?: string; name?: string };
+  /** 负责人姓名,-> `curatorName` */
+  principal: string;
+  /** 联系电话,-> `phoneNumber` */
+  phone: string;
+  /** 备注,-> `remark` */
+  remark?: string;
+
+  avatar?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const WorkroomVOSchema = z.object({
+  id: z.string().optional(),
+  code: z
+    .string()
+    .min(1, `${$t('workroom.name', ['编码'])}不能为空`)
+    .max(50, `${$t('workroom.name', ['编码'])}不能超过50个字符`),
+  name: z
+    .string()
+    .min(1, `${$t('workroom.name', ['名称'])}不能为空`)
+    .max(50, `${$t('workroom.name', ['名称'])}不能超过50个字符`),
+  description: z.string().max(512, '工作室简介不能超过512个字符'),
+  department: z
+    .object({
+      id: z.string().optional(),
+      name: z
+        .string()
+        .max(50, `${$t('workroom.department')}不能超过50个字符`)
+        .optional(),
+    })
+    .optional(),
+  doctor: z.object({
+    id: z.string().optional(),
+    name: z
+      .string()
+      .min(1, '名老中医不能为空')
+      .max(50, '名老中医不能超过50个字符'),
+    description: z.string().max(512, '名老中医简介不能超过512个字符'),
+  }),
+  sect: z.object({
+    id: z.string().optional(),
+    name: z.string().max(50, '流派名称不能超过50个字符'),
+    description: z.string().max(512, '流派传承不能超过512个字符'),
+  }),
+  smriti: z.object({
+    id: z.string().optional(),
+    name: z.string().max(50, '专科方向不能超过50个字符'),
+    description: z.string().max(512, '传承团队不能超过512个字符'),
+  }),
+  principal: z
+    .string()
+    .min(1, '负责人不能为空')
+    .max(50, '负责人不能超过50个字符'),
+  phone: phone(),
+  remark: z.string().max(512, '备注不能超过512个字符').optional(),
+});
+
+export const WorkroomDynamicNewsVoSchema = z.object({
+  workroomId: z.string().min(1, '工作室不能为空'),
+  id: z.string().optional(),
+  title: z.string().min(1, `标题不能为空`).max(50, `标题不能超过50个字符`),
+  content: z
+    .string()
+    .min(1, `内容不能为空`)
+    .max(1024, `内容不能超过1024个字符`),
+  date: z.union([
+    z.string().min(1, '日期不能为空'),
+    z.custom<Dayjs>((val) => isDayjs(val), '日期不能为空'),
+  ]),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export const decodeWorkroom = (dto: WorkroomDTO): WorkroomVO => {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.pid?.toString(),
+    code: dto.deptCode,
+    name: dto.deptName,
+    description: dto.profile,
+    department: {
+      id: dto.hospitalId?.toString() ?? '',
+      name: dto.hospitalName ?? '',
+    },
+    doctor: {
+      name: dto.doctorName,
+      description: dto.doctorProfile,
+    },
+    sect: {
+      name: dto.sectName,
+      description: dto.sectImpart,
+    },
+    smriti: {
+      name: dto.specificDirection,
+      description: dto.impartTeam,
+    },
+    principal: dto.curatorName,
+    phone: dto.phoneNumber,
+    remark: dto.remark,
+  };
+};
+
+export const encodeWorkroom = (vo: WorkroomVO): WorkroomDTO => {
+  return {
+    pid: vo.id,
+    deptCode: vo.code,
+    deptName: vo.name,
+    profile: vo.description,
+    hospitalId: vo.department?.id,
+    hospitalName: vo.department?.name,
+    doctorName: vo.doctor.name,
+    doctorProfile: vo.doctor.description,
+    curatorName: vo.principal,
+    phoneNumber: vo.phone,
+    specificDirection: vo.smriti.name,
+    sectName: vo.sect.name,
+    sectImpart: vo.sect.description,
+    impartTeam: vo.smriti.description,
+    remark: vo.remark,
+  };
+};
+
+export const encodeWorkroomQuery = (
+  vo: Partial<WorkroomVO>,
+): Partial<WorkroomDTO> => {
+  return {
+    hospitalId: vo.department?.id,
+    hospitalName: vo.department?.name,
+    deptName: vo.name,
+    doctorName: vo.doctor?.name,
+  };
+};

+ 40 - 0
apps/wisdom-legacy/src/components/workroom/Selector.vue

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+import { useRouter } from 'vue-router';
+
+import { $t } from '@vben/locales';
+
+import { tryOnMounted } from '@vueuse/core';
+import { Button, Empty, Select } from 'ant-design-vue';
+import { storeToRefs } from 'pinia';
+
+import { useWorkroomStore } from '#/stores';
+
+const workroomStore = useWorkroomStore();
+const { workrooms, userWorkroom } = storeToRefs(workroomStore);
+tryOnMounted(() => {});
+
+const router = useRouter();
+const onCreateHandle = () => {
+  router.push({ path: '/business/dept' });
+};
+</script>
+
+<template>
+  <Select
+    class="min-w-32"
+    :placeholder="$t('business.department._')"
+    :dropdown-match-select-width="false"
+    :options="workrooms ?? []"
+    :value="userWorkroom?.id"
+    :field-names="{ label: 'name', value: 'id' }"
+    @update:value="workroomStore.setUserWorkroom($event as string)"
+  >
+    <template #notFoundContent>
+      <Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="">
+        <Button type="primary" size="small" @click="onCreateHandle">
+          去创建
+        </Button>
+      </Empty>
+    </template>
+  </Select>
+</template>

+ 4 - 0
apps/wisdom-legacy/src/layouts/BasicLayout.vue

@@ -16,6 +16,7 @@ import { preferences, usePreferences } from '@vben/preferences';
 import { useAccessStore, useUserStore } from '@vben/stores';
 
 import LoginForm from '#/components/auth/AccountLogin.vue';
+import WorkroomSelector from '#/components/workroom/Selector.vue';
 import { useAuthStore } from '#/stores';
 
 const notifications = ref<NotificationItem[]>([]);
@@ -126,6 +127,9 @@ watch(
 
 <template>
   <BasicLayout @clear-preferences-and-logout="handleLogout">
+    <template #header-right-0>
+      <WorkroomSelector />
+    </template>
     <template #user-dropdown>
       <UserDropdown
         :avatar

+ 12 - 0
apps/wisdom-legacy/src/locales/langs/zh-CN/workroom.json

@@ -0,0 +1,12 @@
+{
+  "name": "{1}工作室{0}",
+  "department": "所属@:{'system.department._'}",
+  "news": {
+    "name": "{1}工作室动态{0}"
+  },
+  "team": {
+    "member": {
+      "name": "{1}团队成员{0}"
+    }
+  }
+}

+ 7 - 0
apps/wisdom-legacy/src/router/guard/access.guard.ts

@@ -9,6 +9,7 @@ import __layouts_map from '#/layouts';
 import dynamicRoutes from '#/router/routes/modules';
 import staticRoutes from '#/router/routes/static';
 import { useAuthStore } from '#/stores';
+import { useWorkroomStore } from '#/stores/workroom.store';
 
 /**
  * 权限访问守卫配置
@@ -19,6 +20,7 @@ export default function setupAccessGuard(router: Router) {
     const accessStore = useAccessStore();
     const userStore = useUserStore();
     const authStore = useAuthStore();
+    const workroomStore = useWorkroomStore();
 
     if (to.path === LOGIN_PATH && accessStore.accessToken)
       return decodeURIComponent(
@@ -60,6 +62,11 @@ export default function setupAccessGuard(router: Router) {
     const userRoles = userInfo.roles ?? [];
     const userPermissions = userInfo.permissions ?? [];
 
+    // 当前登录用户拥有的工作室
+    const userWorkroom =
+      workroomStore.userWorkroom || (await authStore.getUserWorkroom());
+    void userWorkroom;
+
     // 生成菜单和路由
     const { accessibleMenus, accessibleRoutes } = await generateAccessible(
       preferences.app.accessMode,

+ 10 - 0
apps/wisdom-legacy/src/stores/auth.store.ts

@@ -15,16 +15,20 @@ import { defineStore } from 'pinia';
 import {
   accessMenuMethod,
   accessUserMethod,
+  accessWorkroomMethod,
   loginMethod,
   logoutMethod,
   refreshMethod,
 } from '#/api/account';
 
+import { useWorkroomStore } from './workroom.store';
+
 const options = { immediate: false };
 
 export const useAuthStore = defineStore('auth', () => {
   const accessStore = useAccessStore();
   const userStore = useUserStore();
+  const workroomStore = useWorkroomStore();
   const router = useRouter();
 
   const login = useSerialRequest(
@@ -63,11 +67,16 @@ export const useAuthStore = defineStore('auth', () => {
     ({ data }) => userStore.setUserMenus((data ?? []) as any[]),
   );
 
+  const accessWorkroom = useRequest(accessWorkroomMethod, options).onSuccess(
+    ({ data }) => workroomStore.setWorkrooms(data),
+  );
+
   function $reset() {
     login.abort()?.then();
     logout.abort()?.then();
     accessUser.abort()?.then();
     accessMenu.abort()?.then();
+    accessWorkroom.abort()?.then();
   }
 
   function updateAuthenticateHandler(payload?: AccessTokenVO) {
@@ -113,6 +122,7 @@ export const useAuthStore = defineStore('auth', () => {
     logout: logout.send,
     getUserInfo: accessUser.send,
     getUserMenus: accessMenu.send,
+    getUserWorkroom: accessWorkroom.send,
     isAuthenticateExpired() {
       return accessStore.loginExpired;
     },

+ 1 - 0
apps/wisdom-legacy/src/stores/index.ts

@@ -14,4 +14,5 @@ export default async function init(app: App, options: InitStoreOptions) {
 
 export { useAuthStore } from './auth.store';
 export { getSafePinia } from './pinia-latch';
+export { useWorkroomStore } from './workroom.store';
 export { useAccessStore } from '@vben/stores';

+ 55 - 0
apps/wisdom-legacy/src/stores/workroom.store.ts

@@ -0,0 +1,55 @@
+import type { Ref } from 'vue';
+
+import type { AccessWorkroomVo } from '#/api/account/account.schema';
+
+import { ref } from 'vue';
+
+import { useRequest } from 'alova/client';
+import { defineStore } from 'pinia';
+
+import { getWorkroomMethod } from '#/api/workroom';
+
+export const useWorkroomStore = defineStore(
+  'workroom',
+  () => {
+    const workrooms = ref<AccessWorkroomVo[] | null>();
+
+    const { data, loading, send, abort } = useRequest(getWorkroomMethod, {
+      immediate: false,
+    });
+
+    function setWorkrooms(values: AccessWorkroomVo[]) {
+      workrooms.value = values;
+      void setUserWorkroom(workrooms.value[0]?.id);
+    }
+
+    async function setUserWorkroom(value?: AccessWorkroomVo | string) {
+      if (typeof value === 'string') value = { id: value };
+      if (value) {
+        await abort();
+        await send(value);
+      } else {
+        data.value = null;
+      }
+    }
+
+    function $reset() {
+      data.value = null;
+      workrooms.value = null;
+    }
+
+    return {
+      $reset,
+      setWorkrooms,
+      setUserWorkroom,
+      userWorkroom: data as Ref<AccessWorkroomVo>,
+      workrooms,
+      loading,
+    };
+  },
+  {
+    persist: {
+      pick: ['workrooms', 'userWorkroom'],
+    },
+  },
+);

+ 29 - 0
apps/wisdom-legacy/src/views/workroom/Management.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { editDrawer, useGridPage } from '#/adapter/vxe-table';
+import { deleteWorkroomMethod } from '#/api/workroom';
+
+import workroomEdit from './modules/WorkroomEdit.vue';
+import { workroomGrid } from './workroom.data';
+
+const { Grid, Edit, scope, actions } = useGridPage(workroomGrid, {
+  edit: editDrawer(workroomEdit),
+  delete: deleteWorkroomMethod,
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <Edit />
+    <Grid>
+      <template #toolbar-tools>
+        <a-button type="primary" @click="actions.create()">
+          <Plus class="size-5" />
+          {{ scope.createText }}
+        </a-button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 42 - 0
apps/wisdom-legacy/src/views/workroom/TeamMemberList.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+import { watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { storeToRefs } from 'pinia';
+
+import { editModal, useGridPage } from '#/adapter/vxe-table';
+import { deleteTeamMemberMethod } from '#/api/workroom/team.api';
+import { useWorkroomStore } from '#/stores';
+
+import TeamMemberEdit from './modules/TeamMemberEdit.vue';
+import { teamMemberGrid } from './team.data';
+
+const workroomStore = useWorkroomStore();
+const { userWorkroom } = storeToRefs(workroomStore);
+
+const workroom = () => userWorkroom.value?.id;
+
+const { Grid, Edit, scope, actions, grid } = useGridPage(teamMemberGrid, {
+  params: { workroomId: workroom },
+  edit: editModal(TeamMemberEdit),
+  delete: deleteTeamMemberMethod,
+});
+
+watch(workroom, () => grid.reload());
+</script>
+
+<template>
+  <Page auto-content-height>
+    <Edit />
+    <Grid>
+      <template #toolbar-tools>
+        <a-button type="primary" @click="actions.create()">
+          <Plus class="size-5" />
+          {{ scope.createText }}
+        </a-button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 78 - 0
apps/wisdom-legacy/src/views/workroom/Workspace.vue

@@ -0,0 +1,78 @@
+<script setup lang="ts">
+import { computed, unref } from 'vue';
+
+import { Avatar } from 'ant-design-vue';
+import { storeToRefs } from 'pinia';
+
+import { useWorkroomStore } from '#/stores';
+
+import WorkroomInfoCard from './components/WorkroomInfoCard.vue';
+import WorkroomNewsCard from './components/WorkroomNewsCard.vue';
+
+const workroomStore = useWorkroomStore();
+const { userWorkroom, loading } = storeToRefs(workroomStore);
+const title = computed(() => unref(userWorkroom)?.name?.slice(0, 3) ?? '');
+
+const load = () => {
+  workroomStore.setUserWorkroom(userWorkroom.value);
+};
+</script>
+
+<template>
+  <div class="p-5">
+    <div class="card-box p-4 py-6 lg:flex">
+      <Avatar :size="64" :src="userWorkroom?.avatar">
+        {{ title }}
+      </Avatar>
+      <div class="flex flex-col justify-center md:mt-0 md:ml-6">
+        <h1 class="text-md font-semibold md:text-xl">
+          {{ userWorkroom?.name }}
+        </h1>
+        <span class="mt-1 text-foreground/80">
+          负责人:
+          <template v-if="userWorkroom?.principal">
+            {{ userWorkroom.principal }}
+          </template>
+          <template v-if="userWorkroom?.phone">
+            <a class="tel" :href="`tel:${userWorkroom.phone}`">{{
+              userWorkroom.phone
+            }}</a>
+          </template>
+        </span>
+      </div>
+      <div v-show="false" class="mt-4 flex flex-1 justify-end md:mt-0">
+        <div class="flex flex-col justify-center text-right">
+          <span class="text-foreground/80"> 活动 </span>
+          <span class="text-2xl">2/10</span>
+        </div>
+
+        <div class="mx-12 flex flex-col justify-center text-right md:mx-16">
+          <span class="text-foreground/80"> 学员 </span>
+          <span class="text-2xl">8</span>
+        </div>
+        <div class="mr-4 flex flex-col justify-center text-right md:mr-10">
+          <span class="text-foreground/80"> 团队 </span>
+          <span class="text-2xl">300</span>
+        </div>
+      </div>
+    </div>
+    <div class="mt-5 w-full">
+      <WorkroomInfoCard :data="userWorkroom" :loading @load="load()" />
+    </div>
+    <div class="mt-5">
+      <WorkroomNewsCard :id="userWorkroom?.id" />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tel {
+  &::before {
+    content: '(';
+  }
+
+  &::after {
+    content: ')';
+  }
+}
+</style>

+ 75 - 0
apps/wisdom-legacy/src/views/workroom/components/WorkroomInfoCard.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import type { DynamicNewsVO, WorkroomVO } from '#/api/workroom';
+
+import { Card, Descriptions, DescriptionsItem, Spin } from 'ant-design-vue';
+
+import { useShell } from '#/adapter/shell';
+
+import WorkroomEdit from '../modules/WorkroomEdit.vue';
+
+const { data, loading = false } = defineProps<{
+  data: Partial<WorkroomVO> | undefined;
+  loading?: boolean;
+}>();
+const emits = defineEmits<{ load: [] }>();
+
+const [Edit, editApi] = useShell('modal', {
+  connectedComponent: WorkroomEdit,
+});
+
+const actions = {
+  async edit() {
+    const result = await editApi
+      .setData(data)
+      .open<DynamicNewsVO>(Promise.withResolvers());
+    if (result?.id) emits('load');
+  },
+};
+</script>
+
+<template>
+  <Card title="基本信息">
+    <Edit mode="info" />
+    <template #extra>
+      <a-button
+        type="link"
+        size="small"
+        :disabled="loading"
+        @click="actions.edit()"
+      >
+        修改
+      </a-button>
+    </template>
+    <Spin :spinning="loading">
+      <Descriptions bordered :column="3">
+        <DescriptionsItem label="名老中医">
+          {{ data?.doctor?.name }}
+        </DescriptionsItem>
+        <DescriptionsItem label="流派名称">
+          {{ data?.sect?.name }}
+        </DescriptionsItem>
+        <DescriptionsItem label="专科方向">
+          {{ data?.smriti?.name }}
+        </DescriptionsItem>
+        <DescriptionsItem label="工作室简介" :span="3">
+          {{ data?.description }}
+        </DescriptionsItem>
+        <DescriptionsItem label="名老中医简介" :span="3">
+          {{ data?.doctor?.description }}
+        </DescriptionsItem>
+        <DescriptionsItem label="流派传承" :span="3">
+          {{ data?.sect?.description }}
+        </DescriptionsItem>
+        <DescriptionsItem label="传承团队" :span="3">
+          {{ data?.smriti?.description }}
+        </DescriptionsItem>
+      </Descriptions>
+    </Spin>
+  </Card>
+</template>
+
+<style scoped>
+:deep(.ant-descriptions-item-label) {
+  width: 140px;
+}
+</style>

+ 123 - 0
apps/wisdom-legacy/src/views/workroom/components/WorkroomNewsCard.vue

@@ -0,0 +1,123 @@
+<script setup lang="ts">
+import type { DynamicNewsVO, WorkroomVO } from '#/api/workroom';
+
+import { h, shallowRef, triggerRef } from 'vue';
+
+import { DeleteOutlined } from '@ant-design/icons-vue';
+import { useRequest, useWatcher } from 'alova/client';
+import {
+  Card,
+  Empty,
+  message,
+  Popconfirm,
+  Spin,
+  Timeline,
+  TimelineItem,
+} from 'ant-design-vue';
+
+import { useShell } from '#/adapter/shell';
+import { deleteDynamicNewsMethod, listDynamicNewsMethod } from '#/api/workroom';
+
+import DynamicNewsEdit from '../modules/DynamicNewsEdit.vue';
+
+const { id } = defineProps<Partial<WorkroomVO>>();
+const {
+  data: news,
+  loading,
+  send: load,
+} = useWatcher(() => listDynamicNewsMethod(id ?? ''), [() => id], {
+  immediate: true,
+  initialData: [] as DynamicNewsVO[],
+  middleware(_, next) {
+    if (id) next();
+  },
+});
+const { send: deleteHandle } = useRequest(deleteDynamicNewsMethod, {
+  immediate: false,
+}).onSuccess(() => {
+  message.success(`操作成功`);
+  load();
+});
+
+const [Edit, editApi] = useShell('modal', {
+  connectedComponent: DynamicNewsEdit,
+});
+
+const actions = {
+  async edit() {
+    const result = await editApi
+      .setData({ workroomId: id } as DynamicNewsVO)
+      .open<DynamicNewsVO>(Promise.withResolvers());
+    if (result?.id) await load();
+  },
+  delete(row: DynamicNewsVO) {
+    if (!row.id || status.value.get(row.id)) return;
+    const re = update(row.id);
+    deleteHandle(row).finally(re);
+  },
+};
+
+const status = shallowRef(new Map<string, boolean>());
+const update = (id: string) => {
+  status.value.set(id, true);
+  triggerRef(status);
+  return () => {
+    status.value.delete(id);
+    triggerRef(status);
+  };
+};
+</script>
+
+<template>
+  <Card title="动态">
+    <Edit />
+    <template #extra>
+      <a-button type="link" size="small" @click="actions.edit()">
+        添加动态
+      </a-button>
+    </template>
+    <Spin :spinning="loading">
+      <div :class="{ 'min-h-24': loading }">
+        <Empty
+          v-if="!loading && news.length === 0"
+          :image="Empty.PRESENTED_IMAGE_SIMPLE"
+        >
+          <a-button type="primary" @click="actions.edit()">添加动态</a-button>
+        </Empty>
+        <Timeline>
+          <TimelineItem v-for="item in news" :key="item.id">
+            <p class="text-foreground/80">{{ item.date }}</p>
+            <div class="flex items-center mb-1">
+              <p class="text-md font-semibold md:text-xl">{{ item.title }}</p>
+              <Popconfirm
+                title="确定删除动态吗?"
+                :cancel-button-props="{
+                  disabled: status.get(item.id) ?? false,
+                }"
+                :ok-button-props="{ loading: status.get(item.id) ?? false }"
+                @confirm="actions.delete(item)"
+              >
+                <a-button
+                  class="ml-2"
+                  type="dashed"
+                  danger
+                  shape="circle"
+                  size="small"
+                  :disabled="status.get(item.id) ?? false"
+                  :icon="h(DeleteOutlined)"
+                />
+
+                <template #description>
+                  <div class="w-[10em] truncate">{{ item.title }}</div>
+                </template>
+              </Popconfirm>
+            </div>
+            <p>{{ item.content }}</p>
+          </TimelineItem>
+        </Timeline>
+      </div>
+    </Spin>
+  </Card>
+</template>
+
+<style scoped></style>

+ 22 - 0
apps/wisdom-legacy/src/views/workroom/modules/DynamicNewsEdit.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+import { $t } from '@vben/locales';
+
+import { useEditShell } from '#/adapter/shell';
+
+import { dynamicNewsForm } from '../workroom.data';
+
+const { Form, Shell, api } = useEditShell(dynamicNewsForm);
+</script>
+
+<template>
+  <Shell>
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <a-button type="primary" danger @click="api.reset()">
+          {{ $t('common.reset') }}
+        </a-button>
+      </div>
+    </template>
+  </Shell>
+</template>

+ 22 - 0
apps/wisdom-legacy/src/views/workroom/modules/TeamMemberEdit.vue

@@ -0,0 +1,22 @@
+<script setup lang="ts">
+import { $t } from '@vben/locales';
+
+import { useEditShell } from '#/adapter/shell';
+
+import { teamMemberForm } from '../team.data';
+
+const { Form, Shell, api } = useEditShell(teamMemberForm);
+</script>
+
+<template>
+  <Shell>
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <a-button type="primary" danger @click="api.reset()">
+          {{ $t('common.reset') }}
+        </a-button>
+      </div>
+    </template>
+  </Shell>
+</template>

+ 26 - 0
apps/wisdom-legacy/src/views/workroom/modules/WorkroomEdit.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import { $t } from '@vben/locales';
+
+import { useEditShell } from '#/adapter/shell';
+
+import { workroomForm, workroomInfoForm } from '../workroom.data';
+
+const { mode = 'system' } = defineProps<{ mode?: 'info' | 'system' }>();
+
+const { Form, Shell, api } = useEditShell(
+  mode === 'system' ? workroomForm : workroomInfoForm,
+);
+</script>
+
+<template>
+  <Shell>
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <a-button type="primary" danger @click="api.reset()">
+          {{ $t('common.reset') }}
+        </a-button>
+      </div>
+    </template>
+  </Shell>
+</template>

+ 126 - 0
apps/wisdom-legacy/src/views/workroom/team.data.ts

@@ -0,0 +1,126 @@
+import type { TeamMemberVO } from '#/api/workroom/team.schema';
+
+import { $t } from '@vben/locales';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import { defineGrid } from '#/adapter/vxe-table';
+import {
+  editTeamMemberMethod,
+  expertTitleOptions,
+  getExpertTitleLabel,
+  getJobTitleLabel,
+  getTeamMemberMethod,
+  jobTitleOptions,
+  listTeamMemberMethod,
+  TeamMemberVOSchema,
+} from '#/api/workroom/team.api';
+
+export const teamMemberGrid = defineGrid<TeamMemberVO>({
+  scope: 'workroom.team.member',
+  query: listTeamMemberMethod,
+  pager: false,
+  fields: [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: '姓名',
+    },
+    {
+      component: 'Input',
+      fieldName: 'phone',
+      label: '电话',
+    },
+  ],
+  columns: (col) => [
+    col.seq(),
+    { field: 'name', title: '姓名', minWidth: 120 },
+    { field: 'phone', title: '电话', width: 140 },
+    {
+      field: 'rank',
+      title: '职称',
+      width: 120,
+      formatter: ({ cellValue }) => getJobTitleLabel(cellValue) ?? '',
+    },
+    {
+      field: 'title',
+      title: '专家头衔',
+      width: 120,
+      formatter: ({ cellValue }) => getExpertTitleLabel(cellValue) ?? '',
+    },
+    { field: 'description', title: '职责简介', minWidth: 160 },
+    ...col.audit(),
+    col.actions(['edit', 'delete'], 160),
+  ],
+});
+
+export const teamMemberForm = defineEditShell<TeamMemberVO>({
+  scope: 'workroom.team.member',
+  submit: editTeamMemberMethod,
+  load: getTeamMemberMethod,
+  shell: 'modal',
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-2',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: '姓名',
+      rules: TeamMemberVOSchema.shape.name,
+    },
+    {
+      component: 'Input',
+      componentProps: { maxlength: 20 },
+      fieldName: 'phone',
+      label: '电话',
+      rules: TeamMemberVOSchema.shape.phone,
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        options: jobTitleOptions,
+        allowClear: true,
+      },
+      fieldName: 'rank',
+      label: '职称',
+      rules: 'selectRequired',
+      controlClass: 'w-full',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        options: expertTitleOptions,
+        allowClear: true,
+      },
+      fieldName: 'title',
+      label: '专家头衔',
+      // rules: 'selectRequired',
+      controlClass: 'w-full',
+    },
+    {
+      component: 'Input',
+      fieldName: 'avatar',
+      label: '头像',
+      rules: TeamMemberVOSchema.shape.avatar,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'description',
+      label: '职责简介',
+      rules: TeamMemberVOSchema.shape.description,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      label: $t('workroom.name', ['']),
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: TeamMemberVOSchema.shape.workroomId,
+    },
+  ],
+});

+ 273 - 0
apps/wisdom-legacy/src/views/workroom/workroom.data.ts

@@ -0,0 +1,273 @@
+import type { DynamicNewsVO, WorkroomVO } from '#/api/workroom';
+
+import { $t } from '@vben/locales';
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import { defineGrid } from '#/adapter/vxe-table';
+import { optionsDepartmentMethod } from '#/api/system';
+import {
+  DynamicNewsVoSchema,
+  editDynamicNewsMethod,
+  editWorkroomMethod,
+  getWorkroomMethod,
+  listWorkroomMethod,
+  WorkroomVOSchema,
+} from '#/api/workroom';
+
+export const workroomGrid = defineGrid<WorkroomVO>({
+  scope: 'workroom',
+  query: listWorkroomMethod,
+  fields: [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('workroom.name', ['名称']),
+    },
+    {
+      component: 'Input',
+      fieldName: 'doctor.name',
+      label: '名老中医',
+    },
+  ],
+  columns: (col) => [
+    col.seq(),
+    { field: 'name', title: $t('system.post.field.name'), minWidth: 140 },
+    { field: 'code', title: $t('system.post.field.code'), width: 140 },
+    {
+      field: 'department.name',
+      title: $t('workroom.department'),
+      minWidth: 140,
+    },
+    { field: 'principal', title: '负责人', width: 120 },
+    { field: 'phone', title: '联系电话', width: 130 },
+    { field: 'doctor.name', title: '名老中医', width: 120 },
+    {
+      field: 'doctor.description',
+      title: '名老中医简介',
+      minWidth: 120,
+      visible: false,
+    },
+    { field: 'sect.name', title: '流派名称', minWidth: 120, visible: false },
+    {
+      field: 'sect.description',
+      title: '流派传承',
+      minWidth: 120,
+      visible: false,
+    },
+    { field: 'smriti.name', title: '专科方向', minWidth: 120, visible: false },
+    {
+      field: 'smriti.description',
+      title: '传承团队',
+      minWidth: 120,
+      visible: false,
+    },
+    { field: 'remark', title: '备注', minWidth: 120, visible: false },
+    ...col.audit(),
+    col.actions(['edit', 'delete'], 160),
+  ],
+});
+
+export const workroomForm = defineEditShell<WorkroomVO>({
+  scope: 'workroom',
+  submit: editWorkroomMethod,
+  load: getWorkroomMethod,
+  shell: 'drawer',
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-2',
+  },
+  schema: [
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        api: optionsDepartmentMethod,
+        filterTreeNode(input: string, node: Recordable<any>) {
+          if (!input || input.length === 0) return true;
+          const title: string = node.label.toString() ?? '';
+          if (!title) return false;
+          return title.includes(input) || $t(title).includes(input);
+        },
+        getPopupContainer,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+        showSearch: true,
+        allowClear: true,
+        popupClassName: 'ant-select-tree-node-w-full',
+      },
+      fieldName: 'department.id',
+      label: $t('workroom.department'),
+      rules: 'selectRequired',
+      formItemClass: 'col-span-2 md:col-span-2 xxx',
+      controlClass: 'c--x',
+    },
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('workroom.name', ['名称']),
+      rules: WorkroomVOSchema.shape.name,
+    },
+    {
+      component: 'Input',
+      fieldName: 'code',
+      label: $t('workroom.name', ['编码']),
+      rules: WorkroomVOSchema.shape.code,
+    },
+    {
+      component: 'Input',
+      fieldName: 'principal',
+      label: '负责人',
+      rules: WorkroomVOSchema.shape.principal,
+    },
+    {
+      component: 'Input',
+      componentProps: { maxlength: 11 },
+      fieldName: 'phone',
+      label: '联系电话',
+      rules: WorkroomVOSchema.shape.phone,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'description',
+      label: '工作室简介',
+      rules: WorkroomVOSchema.shape.description.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'doctor.name',
+      label: '名老中医',
+      rules: WorkroomVOSchema.shape.doctor.shape.name,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'doctor.description',
+      label: '名老中医简介',
+      rules: WorkroomVOSchema.shape.doctor.shape.description.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'sect.name',
+      label: '流派名称',
+      rules: WorkroomVOSchema.shape.sect.shape.name.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'sect.description',
+      label: '流派传承',
+      rules: WorkroomVOSchema.shape.sect.shape.description.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'smriti.name',
+      label: '专科方向',
+      rules: WorkroomVOSchema.shape.smriti.shape.name.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'smriti.description',
+      label: '传承团队',
+      rules: WorkroomVOSchema.shape.smriti.shape.description.optional(),
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'remark',
+      label: $t('ui.field.remark'),
+      rules: WorkroomVOSchema.shape.remark,
+      formItemClass: 'col-span-2',
+    },
+  ],
+});
+
+export const workroomInfoForm = defineEditShell<WorkroomVO>({
+  submit: editWorkroomMethod,
+  load: getWorkroomMethod,
+  shell: 'modal',
+  schema: [
+    {
+      component: 'Textarea',
+      fieldName: 'description',
+      label: '工作室简介',
+      rules: WorkroomVOSchema.shape.description,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'doctor.name',
+      label: '名老中医',
+      rules: WorkroomVOSchema.shape.doctor.shape.name,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'doctor.description',
+      label: '名老中医简介',
+      rules: WorkroomVOSchema.shape.doctor.shape.description,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'sect.name',
+      label: '流派名称',
+      rules: WorkroomVOSchema.shape.sect.shape.name,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'sect.description',
+      label: '流派传承',
+      rules: WorkroomVOSchema.shape.sect.shape.description,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Input',
+      fieldName: 'smriti.name',
+      label: '专科方向',
+      rules: WorkroomVOSchema.shape.smriti.shape.name,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'smriti.description',
+      label: '传承团队',
+      rules: WorkroomVOSchema.shape.smriti.shape.description,
+      formItemClass: 'col-span-2 md:col-span-2',
+    },
+  ],
+});
+
+export const dynamicNewsForm = defineEditShell<DynamicNewsVO>({
+  scope: 'workroom.news',
+  submit: editDynamicNewsMethod,
+  shell: 'modal',
+  form: { layout: 'vertical' },
+  schema: [
+    {
+      component: 'DatePicker',
+      fieldName: 'date',
+      label: '日期',
+      rules: DynamicNewsVoSchema.shape.date,
+      controlClass: 'w-full',
+    },
+    {
+      component: 'Input',
+      fieldName: 'title',
+      label: '标题',
+      rules: DynamicNewsVoSchema.shape.title,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'content',
+      label: '内容',
+      rules: DynamicNewsVoSchema.shape.content,
+    },
+  ],
+});