From 3bc22d02e611728b966760fb2d293b974b2c5f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Sat, 1 Aug 2026 08:04:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=AF=8A=E6=89=80=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=8C=E6=B0=94=E6=B3=A1=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=8D=AF=E5=93=81=E7=9A=84=E6=97=B6=E5=80=99=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=89=93=E5=BC=80=E5=BC=80=E6=96=B9=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E4=B8=94=E5=88=87=E6=8D=A2tab=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/Code-Standards.mdc | 79 ----------------- apps/web-antd/src/api/core/auth.ts | 4 + apps/web-antd/src/api/request.ts | 19 ++++- .../components/store-card/StoreCardModal.vue | 36 ++++++++ .../chat/components/PrescriptionModal.vue | 28 +----- .../views/business/store/settings/index.vue | 5 +- .../components/PrescriptionDetail.vue | 16 +++- .../views/doctor/doctor-reception/index.vue | 28 +----- .../admin/_shared/platform-admin-role.ts | 10 +++ .../components/AddDoctorFromStoreModal.vue | 85 +++++++++++++++++++ .../web-antd/src/views/system/store/index.vue | 39 ++++++++- .../src/views/system/system-config/index.vue | 34 +++++++- 12 files changed, 241 insertions(+), 142 deletions(-) create mode 100644 apps/web-antd/src/views/system/store/components/AddDoctorFromStoreModal.vue diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index ce8a1e98..396f5527 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -152,82 +152,3 @@ alwaysApply: true - 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"` - 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量 -# 专项模块规范 - -## OA 通知模块前端规则 - -### 组件与封装复用 - -- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单 - -### 平台动态化(禁止写死) - -- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组 -- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染 -- 平台编码统一为字符串 snake_case(如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐 - -### 系统配置 tab 交互 - -- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关 -- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致) -- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口(config_key = `oa_notify_enabled`),**禁止新建专用接口** - -### 表单字段约定 - -- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件) -- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示 -- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组) - -### 消息类型全量支持(数据库驱动 schema) - -- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取, - 按 `platform_code` 过滤当前平台支持的所有 `message_type` -- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`(JSON 字符串)动态渲染, - 禁止前端写死 schema(数据库可启停类型,前端硬编码会不同步) -- payload 字段渲染规则(与后端 `payload_schema` 约定): - - `Textarea`:多行文本 - - `Input` / `InputNumber`:单行文本/数字 - - `RadioGroup` / `Select`:选项组 - - `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型 - - `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`) -- 提交结构:`message_config: { platform_code: { message_type, payload } }` - -### image-gallery-picker 文件类型过滤 - -- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop -- 取值对应 `xk_file_type.value`:1=图片、2=视频、3=音频、6=文件 等 -- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容) -- 传数组时只展示匹配类型 tab,且自动锁定到第一个匹配类型 -- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型 - -### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`) - -- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求,**必须复用此通用组件**,禁止重复造轮子 -- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部 -- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`) -- 双向绑定走 `v-model`(`modelValue` + `update:modelValue`),禁止内部直接修改 props -- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装) - -### 测试发送(全类型循环) - -- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种) -- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条) -- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`) -- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息 -- 测试发送支持两套入口(共用 `testSendOaRobot` API): - - **表单弹窗内测试**(modal.vue):用于「保存前验证密钥」,直接传 webhook_url/secret(不带 id) - - **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥 -- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选 -- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效) - -### 群聊绑定(按平台过滤的 N:N 多选) - -- 「OA群聊管理」是独立菜单(pid=232 OA 通知父级下,菜单 id=233,路由 `/system/oa-chat`) -- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue` -- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`: - - 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象) - - 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选) - - 平台切换通过 `componentProps.onChange` 监听(vben form 字段值变化无现成 watch,必须通过 schema 注入 onChange 回调) -- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显 -- 提交时把 `chat_ids` 数组合并到 payload(后端 `OaRobotController::notRequest` 已包含该字段) -- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组 diff --git a/apps/web-antd/src/api/core/auth.ts b/apps/web-antd/src/api/core/auth.ts index 845a7352..9fb17234 100644 --- a/apps/web-antd/src/api/core/auth.ts +++ b/apps/web-antd/src/api/core/auth.ts @@ -54,12 +54,15 @@ export namespace AuthApi { * 登录 */ export async function loginApi(data: AuthApi.LoginParams) { + // PC 管理端固定 web,供单机登录按端区分会话 + const clientType = 'web'; if (data.account) { return requestClient.post('login', { account: data.account, password: data.password, code: data.code, admin_id: data.admin_id, + client_type: clientType, }); } @@ -67,6 +70,7 @@ export async function loginApi(data: AuthApi.LoginParams) { login_method: data.login_method || 'phone', password: data.password, code: data.code, + client_type: clientType, }; if (data.login_method === 'phone') { diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 1e1071bb..1a0defc5 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -12,7 +12,7 @@ import { } from '@vben/request'; import { useAccessStore } from '@vben/stores'; -import { message } from 'ant-design-vue'; +import { message, Modal } from 'ant-design-vue'; // eslint-disable-next-line n/no-extraneous-import import { Base64 } from 'js-base64'; @@ -37,6 +37,8 @@ function createRequestClient(baseURL: string) { /** * 重新认证逻辑 */ + /** 防重复:并发 401 只弹一次重新登录提示 */ + let authModalShowing = false; async function doReAuthenticate() { const accessStore = useAccessStore(); const authStore = useAuthStore(); @@ -47,6 +49,21 @@ function createRequestClient(baseURL: string) { ) { accessStore.setLoginExpired(true); } else { + // 被踢/登录失效时用模态框提示,确认后再退出 + if (!authModalShowing) { + authModalShowing = true; + await new Promise((resolve) => { + Modal.warning({ + title: '登录失效', + content: '账号需重新登录', + okText: '重新登录', + centered: true, + onOk: () => resolve(), + onCancel: () => resolve(), + }); + }); + authModalShowing = false; + } await authStore.logout(true); } } diff --git a/apps/web-antd/src/components/store-card/StoreCardModal.vue b/apps/web-antd/src/components/store-card/StoreCardModal.vue index 14d2327f..36c834bc 100644 --- a/apps/web-antd/src/components/store-card/StoreCardModal.vue +++ b/apps/web-antd/src/components/store-card/StoreCardModal.vue @@ -8,6 +8,7 @@ import type { Dayjs } from 'dayjs'; import { computed, ref } from 'vue'; import { useVbenModal } from '@vben/common-ui'; +import { useUserStore } from '@vben/stores'; import dayjs from 'dayjs'; import { @@ -35,9 +36,15 @@ import { } from '#/views/system/store/api'; import { getSalespersonList } from '#/views/system/store/api/salesperson'; import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; +import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue'; +import { canAddStoreDoctor } from '#/views/system/admin/_shared/platform-admin-role'; const RangePicker = DatePicker.RangePicker; +const userStore = useUserStore(); +// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员 +const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo)); + const storeId = ref(0); const loading = ref(false); const activeTab = ref('basic'); @@ -121,6 +128,25 @@ const [DoctorModal, DoctorModalApi] = useVbenModal({ connectedComponent: DoctorCardModal, }); +/** 添加医生弹窗:诊所详情医生团队 Tab 内入口 */ +const [AddDoctorModal, AddDoctorModalApi] = useVbenModal({ + connectedComponent: AddDoctorFromStoreModal, +}); + +/** + * 打开「添加医生」弹窗:默认绑定当前门店,提交成功后刷新医生团队列表 + */ +const openAddDoctorModal = () => { + AddDoctorModalApi.setData({ + storeId: storeId.value, + storeName: (store.value as any)?.name || '', + onSuccess: () => { + loadCard(); + }, + }); + AddDoctorModalApi.open(); +}; + function searchTimeParam(): [string, string] { return [ searchTime.value[0].format('YYYY-MM-DD 00:00:00'), @@ -256,6 +282,7 @@ function clinicTypeText(v: number) {