9 Commits

Author SHA1 Message Date
李琦
52a1cb1ce5 feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-28 16:53:31 +08:00
李琦
5506c8917f feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题 2026-07-28 14:23:50 +08:00
李琦
05885ed405 Merge remote-tracking branch 'origin/dev/message-oa' into feature/invoice
# Conflicts:
#	apps/web-antd/src/views/system/system-config/index.vue
2026-07-28 08:25:45 +08:00
李琦
c8045e1da8 feat:优化了模态框的大小
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-28 07:59:10 +08:00
李琦
14414bf9b0 feat:优化了门店部门财务功能,新增开票功能 2026-07-24 09:54:44 +08:00
lq
777737d063 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-23 14:15:17 +08:00
lq
2b13a19731 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:41 +08:00
lq
4224130ad4 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:21 +08:00
lq
2e7bc80a11 feat: 钉钉、企业微信webbook 2026-07-20 15:05:34 +08:00
124 changed files with 17406 additions and 108 deletions

View File

@@ -1,10 +0,0 @@
---
description:
alwaysApply: true
---
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面记得使用v-if而不是v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化

View File

@@ -1,10 +1,233 @@
--- ---
description: description: 萧康云医管理后台Vben Admin v5 + Vue 3 + TS + Ant Design Vue代码规范
alwaysApply: true alwaysApply: true
--- ---
# 基础规范(所有项目通用)
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则 1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行 2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
3. 有封装好的方法、组件需要复用,不要重复造轮子 3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show 4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化 5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
# 全局架构规范
## 目录结构规范
- 业务页面统一放在 `apps/web-antd/src/views/<端>/<模块>/`system / business / doctor 等)
- 标准 CRUD 模块目录结构:
```
views/<端>/<模块>/
├── index.vue # 列表页Page + Grid + Modal
├── api/index.ts # 本模块 APICRUD
├── config/
│ ├── table.ts # vxe-grid 列定义 + proxyConfig
│ ├── search.ts # 顶部搜索表单 schema
│ └── form.ts # 新增/编辑弹窗 form schema
├── components/
│ └── modal.vue # 新增/编辑弹窗
└── utils/ # 模块工具(可选)
```
- 全局复用组件放 `apps/web-antd/src/components/`
- 表单内可用组件放 `apps/web-antd/src/components/form/components/`,文件名 kebab-case自动注册为 schema 的 `component` 名PascalCase 引用)
## 强制复用封装(禁止重复造轮子)
- 表格统一用 `useVbenVxeGrid`(来自 `#/adapter/vxe-table`**不要直接 new VxeGrid**
- 表单统一用 `useVbenForm`(来自 `#/adapter/form`**不要直接写 `<Form>` + 手动校验**
- 弹窗统一用 `useVbenModal`(来自 `@vben/common-ui`**不要用原生 `<Modal>`**
- 页面外壳统一用 `<Page>` 组件(来自 `@vben/common-ui`
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`**禁止直接 axios / fetch**
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
## API 层规范
- API 文件统一放在 `<模块>/api/index.ts`
- 函数命名约定:`get{Entity}List` / `get{Entity}Option` / `get{Entity}Info` / `create{Entity}` / `update{Entity}` / `delete{Entity}`
- URL 使用 kebab-case统一前缀变量 `const prefix = 'xxx/'`**URL 末尾不补 `/`**
- GET 用 `params`POST 用 `data`,例如:
```ts
import { requestClient } from '#/api/request';
const prefix = 'oa-robot/';
export async function getOaRobotList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
```
## 字段命名对齐后端
- 数据库字段在前端 schema 的 `field` / `fieldName` 中保持 snake_case如 `created_at`、`store_id`、`platform_code`**不要转换为 camelCase**
- 时间戳字段(`created_at` 等)后端查询器已格式化为字符串,前端**无需再次格式化**
- TS 变量用 camelCase常量用 UPPER_SNAKE_CASE类型用 PascalCase
## 表单校验
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
## 字典/枚举管理
- 模块内建 `config/constants.ts`,导出 `{ label, value }[]` 数组,禁止在 schema 中散落字面量
- 例如:
```ts
export const OA_MESSAGE_TYPE_OPTIONS = [
{ label: '文本', value: 'text' },
{ label: 'Markdown', value: 'markdown' },
];
```
## 路由规范
- 业务路由通过后端动态菜单驱动(`xk_menu` 表),**不要在前端 `router/routes/modules/` 静态注册业务路由**
- `defineOptions({ name: 'PascalCaseName' })` 必须与 `xk_menu.name` 字段一致
## Tab 用法
- 统一用 antd 原生 `Tabs` + `Tabs.TabPane`**不要找 Vben 自封装的 Tabs**
- Tab 选中状态持久化走 `localStorage`(参考 `system-config/index.vue` 的 `TAB_STORAGE_KEY` 模式)
## 抽屉用法小程序端admin 不涉及)
- 小程序端的抽屉用 `page-container` + `v-if`(不是 `v-show`)防止用户意外退出页面
## 中文注释
- 每个 `<script setup>` 顶部说明模块用途
- 每个主要函数(特别是有业务逻辑的)必须有中文注释
- 复杂的 schema 字段配置要注释意图
## 空行控制
- 上一部分代码和下一部分代码中间空行不超过 2 行
## Vben 组件使用规范
### VbenModal来自 @vben/common-ui
- 创建弹窗必须用 `useVbenModal`,禁止直接用 antd `Modal` 或原生 `<dialog>`
- 抽离弹窗内容到子组件时,外层用 `connectedComponent` 参数连接,内外组件共享 `modalApi`
- 底部按钮扩展 slot 优先级:`prepend-footer`(取消按钮左侧)> `center-footer`(取消/确定之间)> `append-footer`(确定右侧)
- **严禁覆盖 `#footer` slot**,会丢失默认的取消/确定按钮和 loading 行为
- 表单提交 loading 走 `modalApi.setState({ confirmLoading: true })`,禁止自己写 button loading
- 提交防抖/防重复用 `modalApi.lock()` / `unlock()`>5.5.3),禁止手动 disabled
- 拖拽开 `draggable: true`,需要拖出可视区时同时开 `overflow: true`
- 数据回填走 `modalApi.setData()` + `onOpenChange(isOpen) { const data = modalApi.getData() }`
### VbenDrawer来自 @vben/common-ui
- 创建抽屉必须用 `useVbenDrawer`,禁止直接用 antd `Drawer`
- 底部按钮扩展 slot 与 Modal 一致:`prepend-footer` / `center-footer` / `append-footer`
- **同样严禁覆盖 `#footer` slot**
- 关闭前校验用 `onBeforeClose`>5.5.2 支持 Promise禁止自己拦截
- 提交防抖/防重复用 `drawerApi.lock()` / `unlock()`>5.5.3
- 小程序端的「抽屉」概念与此无关,小程序端按现有规则用 `page-container`
### VbenVxeTable来自 #/adapter/vxe-table
- 表格必须用 `useVbenVxeGrid`,禁止直接 `new VxeGrid` 或 antd `Table`
- 远程数据加载走 `gridOptions.proxyConfig.ajax.query`**禁止自己 fetch 后 `data = []` 赋值**
- query 接口返回结构必须匹配适配器配置:`{ items: [], total: number }`(在 `apps/web-antd/src/adapter/vxe-table.ts` 的 `response` 字段统一配置)
- 分页参数从 `{ page }` 解构取:`page.currentPage` / `page.pageSize`,禁止自己读 URL
- 刷新表格用 `gridApi.query()`(保留当前页)或 `gridApi.reload()`(回到第一页),禁止整页 `window.location.reload()`
- 工具栏按钮插 slot`toolbar-actions`(标题左侧)、`toolbar-tools`(工具按钮左侧),禁止改 vxe-grid 内部模板
- 表格 loading 用 `gridApi.setLoading(bool)`,禁止自己遮罩
- 搜索表单由 `formOptions` 配置(底层是 Vben Form开关走 `gridOptions.toolbarConfig.search = true`
- 自定义列渲染走 `slots: { default: 'action' }` + Grid 子组件内 `<template #action="{ row }">`,禁止 column 渲染函数里写 JSX
### VbenForm来自 #/adapter/form
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
- 动态修改 schema 必须用 `formApi.updateSchema([...])`**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
- 密码/敏感字段用 `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` 数组

View File

@@ -61,6 +61,7 @@ export type ComponentType =
| 'DatePicker' | 'DatePicker'
| 'DefaultButton' | 'DefaultButton'
| 'Divider' | 'Divider'
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
| 'IconPicker' | 'IconPicker'
| 'Input' | 'Input'
| 'InputNumber' | 'InputNumber'
@@ -81,6 +82,7 @@ export type ComponentType =
| 'UploadImage' | 'UploadImage'
| 'UploadImageSortable' | 'UploadImageSortable'
| 'UploadOssFile' | 'UploadOssFile'
| 'UploadDraggerPaste'
| BaseFormComponentType; | BaseFormComponentType;
async function initComponentAdapter() { async function initComponentAdapter() {

View File

@@ -8,11 +8,17 @@ const props = withDefaults(
multiple?: boolean; multiple?: boolean;
maxCount?: number; maxCount?: number;
disabled?: boolean; disabled?: boolean;
/**
* 限制可选择的文件类型(透传给 image-gallery-picker按 xk_file_type.value 过滤)
* 例如图片:[1];文件:[6];语音:[3]
*/
acceptTypes?: number[];
}>(), }>(),
{ {
multiple: false, multiple: false,
maxCount: 1, maxCount: 1,
disabled: false, disabled: false,
acceptTypes: () => [] as number[],
}, },
); );
@@ -41,6 +47,7 @@ function onSelect(urls: string[]) {
v-model:open="galleryOpen" v-model:open="galleryOpen"
:multiple="multiple" :multiple="multiple"
:max-count="maxCount" :max-count="maxCount"
:accept-types="acceptTypes"
@select="onSelect" @select="onSelect"
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue'; import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
@@ -14,10 +14,18 @@ const props = withDefaults(
open: boolean; open: boolean;
multiple?: boolean; multiple?: boolean;
maxCount?: number; maxCount?: number;
/**
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容)
* - 传数组:只展示指定类型 tab且自动锁定到第一个匹配的类型
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
*/
acceptTypes?: number[];
}>(), }>(),
{ {
multiple: false, multiple: false,
maxCount: 1, maxCount: 1,
acceptTypes: () => [] as number[],
}, },
); );
@@ -34,6 +42,14 @@ const pageSize = ref(24);
const pageSizeOptions = ['12', '24', '48']; const pageSizeOptions = ['12', '24', '48'];
const selected = ref<string[]>([]); const selected = ref<string[]>([]);
/** 按 acceptTypes 隔离 localStorage避免图片/视频选择器互相污染 */
const storageKey = computed(
() =>
`file-picker-type-${
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
}`,
);
const { const {
activeType, activeType,
keyword, keyword,
@@ -42,14 +58,55 @@ const {
buildListParams, buildListParams,
fileTypes, fileTypes,
typesLoading, typesLoading,
} = useFileGalleryFilter('file-picker-active-type'); } = useFileGalleryFilter(() => storageKey.value);
let searchTimer: ReturnType<typeof setTimeout> | null = null; /**
* 实际渲染的 tabs按 acceptTypes 过滤)
*/
const visibleTabs = computed(() => {
if (!props.acceptTypes || props.acceptTypes.length === 0) {
return tabs.value;
}
return tabs.value.filter(
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
);
});
/**
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
*/
function lockAcceptType() {
const arr = props.acceptTypes;
if (!arr || arr.length === 0) {
return;
}
if (activeType.value !== arr[0]) {
setActiveType(arr[0]!);
}
}
/**
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
*/
function canLoadList() {
if (typesLoading.value) {
return false;
}
if (props.acceptTypes && props.acceptTypes.length > 0) {
return visibleTabs.value.length > 0;
}
return true;
}
async function load() { async function load() {
if (!canLoadList()) {
return;
}
loading.value = true; loading.value = true;
try { try {
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value)); const res = await getFileGalleryList(
buildListParams(page.value, pageSize.value),
);
const data = (res as any)?.data ?? res; const data = (res as any)?.data ?? res;
items.value = data?.items ?? []; items.value = data?.items ?? [];
total.value = data?.total ?? 0; total.value = data?.total ?? 0;
@@ -58,21 +115,37 @@ async function load() {
} }
} }
watch(
() => props.acceptTypes,
(arr) => {
if (!arr || arr.length === 0) {
return;
}
lockAcceptType();
page.value = 1;
void load();
},
{ immediate: true },
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch( watch(
() => props.open, () => props.open,
(val) => { (val) => {
if (val) { if (val) {
selected.value = []; selected.value = [];
page.value = 1; page.value = 1;
if (!typesLoading.value) { // 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
lockAcceptType();
void load(); void load();
} }
}
}, },
); );
watch(typesLoading, (val) => { watch(typesLoading, (val) => {
if (!val && props.open) { if (!val && props.open) {
lockAcceptType();
void load(); void load();
} }
}); });
@@ -143,6 +216,11 @@ function onSearch(value: string) {
function itemIcon(item: FileGalleryItem) { function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value); return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
} }
/** 图片类型展示缩略图xk_file_type1=图片) */
function isImageItem(item: FileGalleryItem) {
return Number(item.type) === 1 || Number(item.type) === 0;
}
</script> </script>
<template> <template>
@@ -155,7 +233,7 @@ function itemIcon(item: FileGalleryItem) {
@cancel="handleCancel" @cancel="handleCancel"
> >
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange"> <Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)"> <Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
<template #tab> <template #tab>
<span class="tab-label"> <span class="tab-label">
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" /> <MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
@@ -176,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
/> />
</div> </div>
<Spin :spinning="loading"> <Spin :spinning="loading || typesLoading">
<div v-if="items.length" class="gallery-grid"> <div v-if="items.length" class="gallery-grid">
<div <div
v-for="item in items" v-for="item in items"
@@ -185,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
:class="{ active: isSelected(item.url) }" :class="{ active: isSelected(item.url) }"
@click="toggleSelect(item.url)" @click="toggleSelect(item.url)"
> >
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" /> <img
v-if="isImageItem(item)"
:src="item.url"
alt=""
class="gallery-thumb"
/>
<div v-else class="gallery-file-icon"> <div v-else class="gallery-file-icon">
<MIcon :icon="itemIcon(item)" size="28" /> <MIcon :icon="itemIcon(item)" size="28" />
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span> <span class="file-name">{{
item.file_name || item.original_name || '未命名'
}}</span>
</div> </div>
<div class="gallery-meta"> <div class="gallery-meta">
<div class="file-title"> <div class="file-title">

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
/**
* 通用拖拽 + 粘贴上传Dragger 点击/拖拽,下方外侧「开始监听粘贴」
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览blob避免直接下载
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
matchAccept,
usePasteUploadListen,
} from '#/utils/use-paste-upload-listen';
defineOptions({ name: 'UploadDraggerPaste' });
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
url: string;
}
interface Props {
modelValue?: string[];
accept?: string;
multiple?: boolean;
maxCount?: number;
/** 单文件最大 MB */
maxSizeMb?: number;
tip?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
multiple: true,
maxCount: 9,
maxSizeMb: 20,
tip: '点击或拖拽文件到此处上传',
disabled: false,
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
const UploadDragger = Upload.Dragger;
const fileList = ref<FileItem[]>([]);
const uploading = ref(false);
/** 文件预览弹窗状态 */
const previewVisible = ref(false);
const previewTitle = ref('');
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
function syncFromValue(urls: string[]) {
fileList.value = (urls || []).map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
syncFromValue(props.modelValue);
watch(
() => props.modelValue,
(val) => {
const current = fileList.value
.filter((f) => f.status === 'done')
.map((f) => f.url);
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
syncFromValue(val || []);
}
},
{ deep: true },
);
function emitUrls() {
const urls = fileList.value
.filter((f) => f.status === 'done' && f.url)
.map((f) => f.url);
emit('update:modelValue', urls);
}
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 预览已上传文件:图片用 ImagePDF fetch 成 blob 后 iframe避免 OSS 直接下载
*/
async function previewItem(item: FileItem) {
if (!item.url) return;
revokePdfBlob();
previewTitle.value = item.name || '预览';
if (isImageUrl(item.url)) {
previewIsImage.value = true;
previewImageUrl.value = item.url;
previewVisible.value = true;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
previewVisible.value = true;
try {
const resp = await fetch(item.url);
if (!resp.ok) throw new Error('加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch {
message.error('预览失败');
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
}
function onPreviewClose() {
previewVisible.value = false;
revokePdfBlob();
previewImageUrl.value = '';
}
/**
* 上传单个文件并写入列表
*/
async function uploadOneFile(file: File) {
if (props.disabled) return;
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
return;
}
if (!matchAccept(file, props.accept)) {
message.warning('文件类型不符合要求');
return;
}
if (file.size > maxBytes.value) {
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
return;
}
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
fileList.value = [
...fileList.value,
{ uid, name: file.name, status: 'uploading', url: '' },
];
uploading.value = true;
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({ file })
: await uploadFile({ file });
const url = res?.url || '';
if (!url) throw new Error('上传失败');
fileList.value = fileList.value.map((item) =>
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
);
emitUrls();
} catch (e: any) {
fileList.value = fileList.value.filter((item) => item.uid !== uid);
message.error(e?.message || '上传失败');
} finally {
uploading.value = false;
}
}
/** Ant Design Upload customRequest */
const customRequest = async (options: any) => {
const { file, onSuccess, onError } = options;
try {
await uploadOneFile(file as File);
onSuccess?.({});
} catch (e) {
onError?.(e);
}
};
function beforeUpload(file: File) {
if (props.disabled) return Upload.LIST_IGNORE;
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
return Upload.LIST_IGNORE;
}
if (!matchAccept(file, props.accept)) {
message.warning('文件类型不符合要求');
return Upload.LIST_IGNORE;
}
if (file.size > maxBytes.value) {
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
}
function handleRemove(file: UploadFile) {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
emitUrls();
return true;
}
const {
listening: pasteListening,
toggle: togglePaste,
} = usePasteUploadListen({
get accept() {
return props.accept;
},
get maxSize() {
return maxBytes.value;
},
onFiles: async (files) => {
for (const file of files) {
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
break;
}
await uploadOneFile(file);
}
},
});
function onTogglePaste() {
if (props.disabled) return;
togglePaste();
if (pasteListening.value) {
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
} else {
message.info('已停止粘贴监听');
}
}
const showDragger = computed(() => {
if (!props.multiple) {
return fileList.value.filter((f) => f.status === 'done').length === 0;
}
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<div class="upload-dragger-paste">
<UploadDragger
v-if="showDragger"
:file-list="[]"
:accept="accept"
:multiple="multiple"
:disabled="disabled || uploading"
:before-upload="beforeUpload"
:custom-request="customRequest"
:show-upload-list="false"
:max-count="maxCount"
>
<p class="ant-upload-drag-icon">
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
</p>
<p class="ant-upload-text">{{ tip }}</p>
<p class="ant-upload-hint text-gray-400">
支持拖拽或点击选择单文件不超过 {{ maxSizeMb }}MB
</p>
</UploadDragger>
<div v-if="fileList.length" class="mt-3 space-y-2">
<div
v-for="item in fileList"
:key="item.uid"
class="flex items-center gap-2 rounded border border-gray-100 px-3 py-2 text-sm"
>
<Icon
:icon="
item.status === 'uploading'
? 'ant-design:loading-outlined'
: item.status === 'error'
? 'ant-design:close-circle-outlined'
: 'ant-design:paper-clip-outlined'
"
class="shrink-0"
/>
<button
v-if="item.url"
type="button"
class="min-w-0 flex-1 truncate text-left text-blue-600"
@click="previewItem(item)"
>
{{ item.name }}
</button>
<span v-else class="min-w-0 flex-1 truncate text-gray-500">{{ item.name }}</span>
<Button
type="link"
size="small"
danger
:disabled="disabled"
@click="handleRemove(item as any)"
>
删除
</Button>
</div>
</div>
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
<div class="mt-3">
<Button
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
:disabled="disabled"
@click="onTogglePaste"
>
<Icon
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
class="mr-1"
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
监听中请将文件或截图 Ctrl+V 粘贴
</span>
</div>
<Modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
width="80%"
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="文件预览"
/>
</Modal>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 多图上传picture-card+ 下方「开始监听粘贴」支持截图/图片粘贴
*/
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue'; import { Button, Modal, Upload, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue'; import type { UploadFile } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue'; import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
@@ -10,9 +13,12 @@ import {
beforeImageUpload, beforeImageUpload,
resolveUploadFile, resolveUploadFile,
} from '#/utils/use-image-upload-pending'; } from '#/utils/use-image-upload-pending';
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon'; import { Icon } from '#/components/icon';
defineOptions({ name: 'UploadImage' });
interface FileItem { interface FileItem {
uid: string; uid: string;
name: string; name: string;
@@ -24,18 +30,25 @@ interface Props {
modelValue: string[]; modelValue: string[];
multiple?: boolean; multiple?: boolean;
maxCount?: number; maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink默认仅图片
*/
acceptTypes?: number[];
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: () => [], modelValue: () => [],
multiple: true, multiple: true,
maxCount: 9, maxCount: 9,
acceptTypes: () => [1],
}); });
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string[]]; 'update:modelValue': [value: string[]];
}>(); }>();
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
const fileList = ref<FileItem[]>([]); const fileList = ref<FileItem[]>([]);
const initFileList = () => { const initFileList = () => {
@@ -74,6 +87,41 @@ const updateModelValue = () => {
emit('update:modelValue', urls); emit('update:modelValue', urls);
}; };
/**
* 上传单个图片文件(点击上传与粘贴共用)
*/
async function uploadOneImage(file: File) {
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
if (remain <= 0) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
return;
}
const actualFile = resolveUploadFile(file);
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const uploadingFile: FileItem = {
uid,
name: actualFile.name || file.name || 'paste.png',
status: 'uploading',
url: '',
};
fileList.value = [...fileList.value, uploadingFile];
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({ file: actualFile })
: await uploadFile({ file: actualFile });
fileList.value = fileList.value.map((item) =>
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
);
updateModelValue();
} catch (error) {
console.error('上传失败', error);
fileList.value = fileList.value.filter((item) => item.uid !== uid);
message.error('上传失败');
}
}
const customRequest = async (options: any) => { const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options; const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File); const actualFile = resolveUploadFile(file as File);
@@ -155,6 +203,30 @@ const showUploadButton = computed(() =>
); );
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0)); const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
accept: IMAGE_ACCEPT,
maxSize: 20 * 1024 * 1024,
onFiles: async (files) => {
for (const file of files) {
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
break;
}
await uploadOneImage(file);
}
},
});
function onTogglePaste() {
togglePaste();
if (pasteListening.value) {
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
} else {
message.info('已停止粘贴监听');
}
}
</script> </script>
<template> <template>
@@ -184,9 +256,29 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
v-if="showUploadButton" v-if="showUploadButton"
:multiple="multiple" :multiple="multiple"
:max-count="galleryRemainCount" :max-count="galleryRemainCount"
:accept-types="acceptTypes"
@select="onGallerySelect" @select="onGallerySelect"
/> />
<!-- 粘贴监听放在上传区下方外侧 -->
<div class="mt-2">
<Button
size="small"
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
@click="onTogglePaste"
>
<Icon
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
class="mr-1"
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
监听中Ctrl+V 粘贴图片
</span>
</div>
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%"> <Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
<img alt="预览图片" style="width: 100%" :src="previewImage" /> <img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal> </Modal>

View File

@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* OSS 文件上传(单文件 URL 字符串)
* 可选从素材库选择acceptTypes 过滤,如文件=6、语音=3
*/
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { Button, Upload } from 'ant-design-vue'; import { Button, Upload } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { Icon } from '#/components/icon'; import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload'; import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload'; import { uploadToOss } from '#/utils/oss-upload';
@@ -17,11 +23,17 @@ interface FileItem {
interface Props { interface Props {
modelValue?: string; modelValue?: string;
maxCount?: number; maxCount?: number;
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
acceptTypes?: number[];
/** input accept如 .pdf,.doc 或 audio/* */
accept?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: '', modelValue: '',
maxCount: 1, maxCount: 1,
acceptTypes: () => [6],
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -131,19 +143,27 @@ function handleRemove() {
return true; return true;
} }
function onGallerySelect(urls: string[]) {
const url = urls[0] || '';
if (!url) return;
syncFileListFromValue(url);
updateModelValue();
}
const showUploadButton = computed( const showUploadButton = computed(
() => fileList.value.filter((item) => item.status !== 'error').length === 0, () => fileList.value.filter((item) => item.status !== 'error').length === 0,
); );
</script> </script>
<template> <template>
<div class="upload-oss-file-wrap">
<Upload <Upload
:file-list="fileList" :file-list="fileList"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:custom-request="customRequest" :custom-request="customRequest"
:max-count="maxCount" :max-count="maxCount"
list-type="text" list-type="text"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf" :accept="accept"
@remove="handleRemove" @remove="handleRemove"
> >
<Button v-if="showUploadButton"> <Button v-if="showUploadButton">
@@ -153,4 +173,20 @@ const showUploadButton = computed(
</template> </template>
</Button> </Button>
</Upload> </Upload>
<GalleryPickLink
v-if="showUploadButton"
:max-count="1"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>
</div>
</template> </template>
<style scoped>
.upload-oss-file-wrap {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* 通用 Tag 选择器(带头像)
*
* - 基于 antd Selectmultiple 模式默认开启,单选传 multiple=false
* - 已选项以 Tag 形式展示头像+名字:
* - 有 avatar 用图片头像
* - 无 avatar 用文本头像(取 label 首字符)
* - 下拉选项同样展示头像+名字
* - 选项数据options: { value, label, avatar? }[]
*/
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Select, Tag } from 'ant-design-vue';
defineOptions({
name: 'UserTagSelect',
inheritAttrs: false,
});
/** 单个选项结构 */
export interface UserTagOption {
value: string | number;
label: string;
avatar?: string;
}
const props = withDefaults(
defineProps<{
/** 已选值数组(多选)或单值(单选时仍用数组包装) */
modelValue?: (string | number)[];
/** 选项列表 */
options: UserTagOption[];
/** 是否多选,默认 true */
multiple?: boolean;
/** 最大选择数,超出截断 */
maxCount?: number;
placeholder?: string;
allowClear?: boolean;
showSearch?: boolean;
disabled?: boolean;
}>(),
{
modelValue: () => [],
multiple: true,
maxCount: 99,
placeholder: '请选择',
allowClear: true,
showSearch: true,
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [v: (string | number)[]];
}>();
// 双向绑定useVModel 自动处理 modelValue 的读写
const innerValue = useVModel(props, 'modelValue', emit, {
defaultValue: [] as (string | number)[],
passive: true,
});
/** 用于 Select 的 mode单选时不传 mode */
const selectMode = computed(() => (props.multiple ? 'multiple' : undefined));
/** 按 value 索引选项,方便 tagRender/option 插槽快速查头像与名字 */
const optionMap = computed(() => {
const map = new Map<string | number, UserTagOption>();
for (const opt of props.options) {
map.set(opt.value, opt);
}
return map;
});
/** 取某 value 对应的展示名 */
function labelOf(value: string | number) {
return optionMap.value.get(value)?.label || String(value);
}
/** 取某 value 对应的头像 */
function avatarOf(value: string | number) {
return optionMap.value.get(value)?.avatar;
}
/** 取首字符(文本头像) */
function firstChar(label?: string) {
return (label || '?').trim().charAt(0) || '?';
}
/** 选项过滤:按 label 包含输入关键字 */
function filterOption(input: string, option: any) {
const v = String(option?.label || '').toLowerCase();
return v.includes((input || '').toLowerCase());
}
/**
* change 处理:
* - 单选:把单值包成数组
* - 多选:超 maxCount 截断
*/
function onChange(v: any) {
if (!props.multiple) {
innerValue.value = v == null ? [] : [v];
return;
}
if (Array.isArray(v) && v.length > props.maxCount) {
v = v.slice(0, props.maxCount);
}
innerValue.value = v;
}
</script>
<template>
<Select
:value="innerValue as any"
:mode="selectMode"
:options="options"
:placeholder="placeholder"
:allow-clear="allowClear"
:show-search="showSearch"
:filter-option="filterOption"
:disabled="disabled"
:max-tag-count="10"
option-filter-prop="label"
style="width: 100%"
@change="onChange"
>
<!-- 多选已选项展示头像+名字 -->
<template v-if="multiple" #tagRender="{ value, closable, onClose }">
<Tag :closable="closable" @close="onClose" class="user-tag-chip">
<Avatar :size="16" :src="avatarOf(value)">
{{ firstChar(labelOf(value)) }}
</Avatar>
<span class="user-tag-name">{{ labelOf(value) }}</span>
</Tag>
</template>
<!-- 下拉选项展示头像+名字 -->
<template #option="{ label, avatar }">
<div class="user-tag-option">
<Avatar :size="20" :src="avatar">{{ firstChar(label) }}</Avatar>
<span>{{ label }}</span>
</div>
</template>
</Select>
</template>
<style scoped>
.user-tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
}
.user-tag-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-tag-option {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { BlockSchema } from './types';
import MIcon from '#/components/icon/icon.vue';
/**
* 左侧组件库:渲染所有可拖拽块类型
* 用户从这里拖动到中间画布SortableJS 用 pull:clone 模式,左侧库不会减少)
*/
defineProps<{
/** 所有可拖拽块定义 */
blocks: BlockSchema[];
}>();
</script>
<template>
<div class="block-library">
<div class="panel-header">
<span class="title">组件库</span>
<span class="hint">拖入画布</span>
</div>
<div class="block-list">
<div
v-for="block in blocks"
:key="block.type"
class="block-item"
:data-block-type="block.type"
>
<MIcon v-if="block.icon" :icon="block.icon" size="16" />
<span class="block-label">{{ block.label }}</span>
</div>
<div v-if="blocks.length === 0" class="empty-hint">暂无可拖入组件</div>
</div>
</div>
</template>
<style scoped lang="scss">
.block-library {
display: flex;
flex-direction: column;
width: 200px;
height: 100%;
border-right: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-fill-quaternary, #fafbfc);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
}
}
.block-list {
flex: 1;
overflow-y: auto;
padding: 12px 8px;
}
.block-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px dashed var(--ant-color-border, #c9cdd4);
border-radius: 4px;
background: var(--ant-color-bg-container, #fff);
font-size: 13px;
color: var(--ant-color-text-secondary, #4e5969);
cursor: grab;
user-select: none;
transition: all 0.15s;
&:hover {
border-color: var(--ant-color-primary, #165dff);
color: var(--ant-color-primary, #165dff);
background: var(--ant-color-primary-bg, #f2f7ff);
}
&:active {
cursor: grabbing;
}
.block-label {
flex: 1;
}
}
.empty-hint {
padding: 24px 12px;
text-align: center;
color: var(--ant-color-text-quaternary, #c9cdd4);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema } from './types';
import { computed } from 'vue';
import MIcon from '#/components/icon/icon.vue';
/**
* 中间画布:按 targetArrayPath 分组渲染块实例
* 每个分组是一个 SortableJS 接收区,可拖入、排序、删除
*/
const props = defineProps<{
/** 所有块定义(按 type 查找对应 schema */
blockSchemas: BlockSchema[];
/** 所有块实例(已拖入画布的) */
instances: BlockInstance[];
/** 当前选中的块实例 ID高亮显示 */
selectedInstanceId?: string;
}>();
const emit = defineEmits<{
/** 选中块实例(点击时触发) */
select: [instanceId: string];
/** 删除块实例 */
remove: [instanceId: string];
}>();
/**
* 按 targetArrayPath 分组的渲染结构
* 每个分组对应一个 SortableJS 接收区
*/
const groupedRender = computed(() => {
// 收集所有出现的 targetArrayPath按 blockSchemas 顺序排序
const orderedPaths: string[] = [];
const labelMap: Record<string, { label: string; maxCount?: number }> = {};
for (const schema of props.blockSchemas) {
if (!orderedPaths.includes(schema.targetArrayPath)) {
orderedPaths.push(schema.targetArrayPath);
labelMap[schema.targetArrayPath] = {
label: schema.targetArrayPath,
maxCount: schema.maxCount,
};
}
}
// 把 instances 按目标路径分组
const grouped: Record<string, BlockInstance[]> = {};
for (const inst of props.instances) {
const schema = props.blockSchemas.find((s) => s.type === inst.type);
if (!schema) continue;
const path = schema.targetArrayPath;
if (!grouped[path]) grouped[path] = [];
grouped[path].push(inst);
}
return orderedPaths.map((path) => ({
path,
label: labelMap[path]?.label ?? path,
maxCount: labelMap[path]?.maxCount,
items: grouped[path] ?? [],
}));
});
/**
* 获取块实例的 schema 定义
*/
function getSchema(type: string): BlockSchema | undefined {
return props.blockSchemas.find((s) => s.type === type);
}
/**
* 获取块实例的简要描述(属性面板编辑的字段值预览)
*/
function getInstanceSummary(inst: BlockInstance): string {
const schema = getSchema(inst.type);
if (!schema) return '';
const firstField = schema.fields[0];
if (!firstField) return '';
// 嵌套路径取最末级
const path = firstField.name.split('.');
let value: any = inst.data;
for (const key of path) {
value = value?.[key];
}
return value ? String(value) : '(未填写)';
}
function handleClick(instanceId: string) {
emit('select', instanceId);
}
function handleRemove(instanceId: string) {
emit('remove', instanceId);
}
</script>
<template>
<div class="canvas">
<div class="panel-header">
<span class="title">画布</span>
<span class="hint">拖入 / 排序 / 点击编辑属性</span>
</div>
<div class="canvas-body">
<div
v-for="group in groupedRender"
:key="group.path"
class="canvas-group"
>
<div class="group-header">
<span class="group-label">{{ group.label }}</span>
<span class="group-count">
{{ group.items.length }}{{ group.maxCount ? `/${group.maxCount}` : '' }}
</span>
</div>
<div
class="group-dropzone"
:data-target-array-path="group.path"
>
<div
v-for="inst in group.items"
:key="inst.id"
class="instance-item"
:class="{ selected: inst.id === selectedInstanceId }"
:data-instance-id="inst.id"
@click.stop="handleClick(inst.id)"
>
<div class="instance-info">
<MIcon
v-if="getSchema(inst.type)?.icon"
:icon="getSchema(inst.type)!.icon"
size="14"
/>
<div class="instance-text">
<div class="instance-label">{{ getSchema(inst.type)?.label }}</div>
<div class="instance-summary">{{ getInstanceSummary(inst) }}</div>
</div>
</div>
<button
type="button"
class="instance-remove"
title="删除"
@click.stop="handleRemove(inst.id)"
>
×
</button>
</div>
<div v-if="group.items.length === 0" class="empty-dropzone">
拖入组件
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.canvas {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
background: var(--ant-color-bg-container, #fff);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
}
}
.canvas-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.canvas-group {
margin-bottom: 20px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
.group-label {
color: var(--ant-color-text-secondary, #4e5969);
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
}
.group-count {
color: var(--ant-color-text-tertiary, #86909c);
}
}
.group-dropzone {
min-height: 60px;
padding: 8px;
border: 2px dashed var(--ant-color-border, #d9d9d9);
border-radius: 6px;
background: var(--ant-color-fill-quaternary, #fafbfc);
}
.empty-dropzone {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
color: var(--ant-color-text-quaternary, #c9cdd4);
font-size: 12px;
}
.instance-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
border-radius: 4px;
background: var(--ant-color-bg-container, #fff);
cursor: pointer;
transition: all 0.15s;
&:last-child {
margin-bottom: 0;
}
&:hover {
border-color: var(--ant-color-primary, #165dff);
}
&.selected {
border-color: var(--ant-color-primary, #165dff);
background: var(--ant-color-primary-bg, #f2f7ff);
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
}
}
.instance-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.instance-text {
flex: 1;
min-width: 0;
}
.instance-label {
font-size: 13px;
color: var(--ant-color-text, #1d2129);
font-weight: 500;
}
.instance-summary {
font-size: 11px;
color: var(--ant-color-text-tertiary, #86909c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.instance-remove {
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ant-color-text-tertiary, #86909c);
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--ant-color-error, #f53f3f);
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button, message } from 'ant-design-vue';
/**
* JSON 实时预览面板
* - 显示最终生成的 JSON 字符串(语法高亮,简单的 key/string 高亮)
* - 提供「复制 JSON」按钮一键拷贝到剪贴板
*/
const props = defineProps<{
/** 最终生成的 JSON 数据 */
value: Record<string, any>;
}>();
const formattedJson = computed(() => {
try {
return JSON.stringify(props.value, null, 2);
} catch {
return '// JSON 序列化失败';
}
});
/**
* 简单的 JSON 语法高亮
* 匹配 key / string / number / boolean 并套用不同颜色 span
*/
const highlightedHtml = computed(() => {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const json = escape(formattedJson.value);
return json
.replace(
/("(?:\\.|[^"\\])*")(\s*:)/g,
'<span class="json-key">$1</span>$2',
)
.replace(
/:\s*("(?:\\.|[^"\\])*")/g,
': <span class="json-string">$1</span>',
)
.replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="json-bool">$1</span>');
});
async function handleCopy() {
try {
await navigator.clipboard.writeText(formattedJson.value);
message.success('JSON 已复制');
} catch {
message.error('复制失败,请手动选择');
}
}
</script>
<template>
<div class="json-preview">
<div class="panel-header">
<span class="title">JSON 预览</span>
<Button size="small" type="link" @click="handleCopy">复制 JSON</Button>
</div>
<pre class="json-body" v-html="highlightedHtml" />
</div>
</template>
<style scoped lang="scss">
.json-preview {
display: flex;
flex-direction: column;
width: 280px;
height: 100%;
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
/* JSON 代码面板保留 VS Code Dark+ 风格暗底(无论浅色/暗色主题) */
background: #1e1e1e;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #374151;
font-size: 13px;
.title {
font-weight: 600;
color: #e5e7eb;
}
}
.json-body {
flex: 1;
margin: 0;
padding: 12px;
overflow: auto;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-all;
}
/* JSON 语法高亮颜色 */
:deep(.json-key) {
color: #9cdcfe;
}
:deep(.json-string) {
color: #ce9178;
}
:deep(.json-number) {
color: #b5cea8;
}
:deep(.json-bool) {
color: #569cd6;
}
</style>

View File

@@ -0,0 +1,348 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema, FixedField, FormFieldSchema } from './types';
import { computed, watch } from 'vue';
import { Empty, Input, InputNumber, Radio, RadioGroup, Select, Textarea } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
/**
* 右侧属性面板:根据选中块的字段 schema 渲染表单
* - 顶层固定字段fixedFields渲染在顶部不可删除
* - 选中块的 fields渲染在下方
* - 图片类型字段用 GalleryPickLink透传 acceptTypes
*/
const props = defineProps<{
/** 顶层固定字段定义 */
fixedFields?: FixedField[];
/** 顶层固定字段当前值 */
fixedValues?: Record<string, any>;
/** 当前选中的块定义(可能为空) */
selectedBlockSchema?: BlockSchema;
/** 当前选中的块实例(可能为空) */
selectedInstance?: BlockInstance;
}>();
const emit = defineEmits<{
/** 顶层固定字段值变化 */
'update:fixedValues': [value: Record<string, any>];
/** 选中块实例数据变化 */
'update:instance': [instance: BlockInstance];
}>();
/**
* 设置顶层固定字段的值
* 支持 a.b.c 嵌套路径
*/
function setFixedValue(path: string, value: any) {
const next = { ...(props.fixedValues || {}) };
setByPath(next, path, value);
emit('update:fixedValues', next);
}
/**
* 设置选中块实例的字段值
*/
function setInstanceValue(path: string, value: any) {
if (!props.selectedInstance) return;
const nextData = { ...props.selectedInstance.data };
setByPath(nextData, path, value);
emit('update:instance', {
...props.selectedInstance,
data: nextData,
});
}
/**
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
*/
function setByPath(obj: Record<string, any>, path: string, value: any) {
const keys = path.split('.');
let cursor: any = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]!;
if (!cursor[k] || typeof cursor[k] !== 'object') {
cursor[k] = {};
} else {
cursor[k] = { ...cursor[k] };
}
cursor = cursor[k];
}
cursor[keys[keys.length - 1]!] = value;
}
/**
* 按 a.b.c 路径读取对象嵌套属性
*/
function getByPath(obj: Record<string, any> | undefined, path: string): any {
if (!obj) return undefined;
const keys = path.split('.');
let cursor: any = obj;
for (const k of keys) {
if (cursor == null) return undefined;
cursor = cursor[k];
}
return cursor;
}
const hasAnyField = computed(() => {
return (
(props.fixedFields && props.fixedFields.length > 0) ||
(props.selectedBlockSchema && props.selectedBlockSchema.fields.length > 0)
);
});
// 当选中的块变化时,触发 watch 让父级感知(用于属性面板切换的动画/数据初始化)
watch(
() => props.selectedInstance?.id,
() => {
// 仅做依赖追踪,实际更新在用户交互时通过 emit 触发
},
);
</script>
<template>
<div class="property-panel">
<div class="panel-header">
<span class="title">属性</span>
<span v-if="selectedBlockSchema" class="hint">{{ selectedBlockSchema.label }}</span>
</div>
<div class="panel-body">
<!-- 顶层固定字段 -->
<div v-if="fixedFields && fixedFields.length > 0" class="field-section">
<div class="section-title">基础信息</div>
<div
v-for="field in fixedFields"
:key="field.name"
class="form-row"
>
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<!-- 文本 -->
<Input
v-if="!field.type || field.type === 'string' || field.type === 'url'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
allow-clear
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 数字 -->
<InputNumber
v-else-if="field.type === 'number'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
size="small"
style="width: 100%"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
:rows="3"
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
:value="getByPath(fixedValues, field.name)"
:options="field.options"
:placeholder="field.placeholder"
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 单选 -->
<RadioGroup
v-else-if="field.type === 'radio'"
:value="getByPath(fixedValues, field.name)"
size="small"
@update:value="setFixedValue(field.name, $event)"
>
<Radio
v-for="opt in field.options"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</RadioGroup>
<!-- 图片素材 -->
<div v-else-if="field.type === 'image'" class="image-picker-row">
<Input
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder || '从素材库选择图片'"
size="small"
read-only
/>
<GalleryPickLink
:accept-types="field.acceptTypes || [0]"
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
/>
</div>
</div>
</div>
<!-- 选中块的属性字段 -->
<div v-if="selectedBlockSchema && selectedInstance" class="field-section">
<div class="section-title">{{ selectedBlockSchema.label }}属性</div>
<div
v-for="field in selectedBlockSchema.fields"
:key="field.name"
class="form-row"
>
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<Input
v-if="!field.type || field.type === 'string' || field.type === 'url'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
allow-clear
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<InputNumber
v-else-if="field.type === 'number'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
size="small"
style="width: 100%"
@update:value="setInstanceValue(field.name, $event)"
/>
<Textarea
v-else-if="field.type === 'textarea'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
:rows="3"
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<Select
v-else-if="field.type === 'select'"
:value="getByPath(selectedInstance.data, field.name)"
:options="field.options"
:placeholder="field.placeholder"
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<RadioGroup
v-else-if="field.type === 'radio'"
:value="getByPath(selectedInstance.data, field.name)"
size="small"
@update:value="setInstanceValue(field.name, $event)"
>
<Radio
v-for="opt in field.options"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</RadioGroup>
<div v-else-if="field.type === 'image'" class="image-picker-row">
<Input
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder || '从素材库选择图片'"
size="small"
read-only
/>
<GalleryPickLink
:accept-types="field.acceptTypes || [0]"
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
/>
</div>
</div>
</div>
<!-- 未选中任何块时的提示 -->
<Empty
v-if="!hasAnyField"
description="点击画布中的块以编辑属性"
:image-style="{ height: '60px' }"
style="margin-top: 60px"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.property-panel {
display: flex;
flex-direction: column;
width: 280px;
height: 100%;
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-bg-container, #fff);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.field-section {
margin-bottom: 24px;
}
.section-title {
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
font-size: 12px;
font-weight: 600;
color: var(--ant-color-text-secondary, #4e5969);
}
.form-row {
margin-bottom: 12px;
}
.form-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--ant-color-text-secondary, #4e5969);
.required {
margin-left: 2px;
color: var(--ant-color-error, #f53f3f);
}
}
.image-picker-row {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,522 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema, FixedField } from './types';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import type Sortable from 'sortablejs';
import type { SortableOptions } from 'sortablejs';
import { Button, message } from 'ant-design-vue';
import BlockLibrary from './BlockLibrary.vue';
import Canvas from './Canvas.vue';
import JsonPreview from './JsonPreview.vue';
import PropertyPanel from './PropertyPanel.vue';
/**
* 通用拖拽式可视化编辑器
*
* 用途:把「左侧组件库 → 中间画布拖拽组装 → 右侧属性面板 → 自动生成 JSON」
* 做成 schema 驱动的通用组件,未来 OA / 工单 / 通知等任何场景需要可视化拼装 JSON 都能复用。
*
* 核心机制:
* - 左侧库 + 画布各分组共用同一个 SortableJS grouppull:clone 模式:左侧库克隆到画布)
* - 拖入 / 排序 / 删除时同步内部 instances 状态
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSONemit 给父级
*
* SortableJS 用法参照项目内 views/system/role/components/quick-nav-transfer.vue
*/
const props = withDefaults(
defineProps<{
/** 双向绑定的 JSON 数据 */
modelValue: Record<string, any>;
/** 块定义 schema决定左侧能拖入哪些块、各自有哪些字段 */
schema: BlockSchema[];
/** 是否显示 JSON 预览面板 */
showJsonPreview?: boolean;
/** 顶层固定字段(不可拖拽,固定显示在属性面板顶部) */
fixedFields?: FixedField[];
}>(),
{
showJsonPreview: true,
fixedFields: () => [] as FixedField[],
},
);
const emit = defineEmits<{
'update:modelValue': [value: Record<string, any>];
}>();
// ====== 内部状态 ======
/** 所有块实例(按拖入顺序) */
const instances = ref<BlockInstance[]>([]);
/** 当前选中的块实例 ID */
const selectedInstanceId = ref<string>('');
/** 顶层固定字段值 */
const fixedValues = ref<Record<string, any>>({});
/** 顶层固定字段 + 块数组组装后的最终 JSON双向同步给父级 */
const assembledJson = computed<Record<string, any>>(() => {
const result: Record<string, any> = { ...fixedValues.value };
// 按 targetArrayPath 收集块实例数据
for (const schema of props.schema) {
const path = schema.targetArrayPath;
const items = instances.value
.filter((inst) => inst.type === schema.type)
.map((inst) => {
// 深拷贝避免引用问题
const data = JSON.parse(JSON.stringify(inst.data));
// 移除内部使用的 id 字段(如果有)
delete data._blockType;
return data;
});
if (items.length > 0) {
// 设置嵌套路径
setByPath(result, path, items);
}
}
return result;
});
/** 当前选中的块实例对象 */
const selectedInstance = computed<BlockInstance | undefined>(() => {
return instances.value.find((i) => i.id === selectedInstanceId.value);
});
/** 当前选中的块 schema */
const selectedBlockSchema = computed<BlockSchema | undefined>(() => {
if (!selectedInstance.value) return undefined;
return props.schema.find((s) => s.type === selectedInstance.value!.type);
});
// ====== SortableJS 实例管理 ======
const editorRootRef = ref<HTMLElement>();
const libraryRef = ref<HTMLElement>();
/** 画布中各分组的 dropzone DOM 引用key = targetArrayPath */
const dropzoneRefs = ref<Record<string, HTMLElement>>({});
const librarySortable = ref<Sortable | null>(null);
const dropzoneSortables = ref<Record<string, Sortable>>({});
/**
* 动态导入 SortableJS参照 quick-nav-transfer.vue 模式)
*/
async function createSortable(
el: HTMLElement,
options: SortableOptions = {},
): Promise<Sortable> {
const mod = await import(
// @ts-expect-error sortablejs modular esm path
'sortablejs/modular/sortable.complete.esm.js'
);
return mod.default.create(el, {
animation: 200,
...options,
}) as Sortable;
}
/**
* 初始化 SortableJS 实例
* 1. 左侧组件库pull:clone 模式(拖出后元素保留)
* 2. 画布各分组pull:clone/put跨组可移动
*/
async function initSortables() {
destroySortables();
await nextTick();
if (libraryRef.value) {
librarySortable.value = await createSortable(libraryRef.value, {
group: {
name: 'template-editor',
pull: 'clone',
put: false, // 左侧库不接受拖入
},
sort: false, // 左侧库不排序
onEnd: handleLibraryDragEnd,
});
}
// 通过 querySelector 直接查询画布中的所有 dropzone DOM避免父子组件 DOM 引用传递)
const editorRoot = editorRootRef.value;
const dropzoneEls = editorRoot?.querySelectorAll('.group-dropzone[data-target-array-path]') ?? [];
dropzoneEls.forEach((el) => {
const path = el.getAttribute('data-target-array-path');
if (!path) return;
dropzoneRefs.value[path] = el as HTMLElement;
void createSortable(el as HTMLElement, {
group: {
name: 'template-editor',
pull: true,
put: true,
},
onAdd: (evt) => handleDropzoneAdd(evt, path),
onRemove: (evt) => handleDropzoneRemove(evt, path),
onUpdate: (evt) => handleDropzoneUpdate(evt, path),
}).then((s) => {
dropzoneSortables.value[path] = s;
});
});
}
function destroySortables() {
librarySortable.value?.destroy();
librarySortable.value = null;
for (const sortable of Object.values(dropzoneSortables.value)) {
sortable.destroy();
}
dropzoneRefs.value = {};
dropzoneSortables.value = {};
}
/**
* 设置 dropzone DOM 引用Canvas 子组件通过回调注入)
*/
function setDropzoneRef(path: string, el: HTMLElement | null) {
if (el) {
dropzoneRefs.value[path] = el;
} else {
delete dropzoneRefs.value[path];
}
}
// ====== 拖拽事件处理(在 SortableJS 修改 DOM 后,同步到 Vue 状态) ======
/**
* 左侧库 → 画布的拖拽结束回调
* SortableJS 会把左侧库的元素 clone 一份移到画布 DOM我们要
* 1. 移除 DOM 中被克隆过去的元素(由 Vue 重新渲染)
* 2. 在 instances 中追加新块
*/
function handleLibraryDragEnd(evt: any) {
// 如果只是库内拖动(没拖到画布),不做处理
if (!evt.to || !evt.to.classList.contains('group-dropzone')) {
return;
}
const blockType = evt.item?.dataset?.blockType;
if (!blockType) return;
// 检查 maxCount 限制
const schema = props.schema.find((s) => s.type === blockType);
if (!schema) return;
const targetPath = schema.targetArrayPath;
const currentCount = instances.value.filter(
(i) => props.schema.find((s) => s.type === i.type)?.targetArrayPath === targetPath,
).length;
if (schema.maxCount && currentCount >= schema.maxCount) {
// 移除 SortableJS 已经克隆到画布 DOM 的元素
evt.item?.remove();
message.warning(`${schema.label}」最多 ${schema.maxCount}`);
return;
}
// 移除 SortableJS 已经克隆到画布 DOM 的元素Vue 会重新渲染)
evt.item?.remove();
// 根据 SortableJS 在画布 DOM 中的位置,计算插入位置
// 简化处理:直接追加到末尾(实现严格的位置同步太复杂,先保留追加行为)
const newInstance: BlockInstance = {
id: generateId(),
type: blockType,
data: schema.defaultValue ? JSON.parse(JSON.stringify(schema.defaultValue)) : {},
};
instances.value = [...instances.value, newInstance];
selectedInstanceId.value = newInstance.id;
}
/**
* 画布分组间或分组内的添加事件(块从其他分组拖入)
*/
function handleDropzoneAdd(_evt: any, _path: string) {
// SortableJS 自动处理 DOM 顺序,但因为 Vue 控制渲染,需要同步状态
syncInstancesFromDom();
}
function handleDropzoneRemove(_evt: any, _path: string) {
syncInstancesFromDom();
}
function handleDropzoneUpdate(_evt: any, _path: string) {
syncInstancesFromDom();
}
/**
* 从画布 DOM 同步块实例状态
* SortableJS 修改了 DOM 后,从 DOM 重新读取 instanceId 顺序
*/
function syncInstancesFromDom() {
const newInstances: BlockInstance[] = [];
for (const path of Object.keys(dropzoneRefs.value)) {
const el = dropzoneRefs.value[path];
if (!el) continue;
const items = el.querySelectorAll('[data-instance-id]');
items.forEach((item) => {
const id = item.getAttribute('data-instance-id');
if (!id) return;
const existing = instances.value.find((i) => i.id === id);
if (existing) {
newInstances.push(existing);
}
});
}
instances.value = newInstances;
}
/**
* 删除块实例
*/
function removeInstance(id: string) {
instances.value = instances.value.filter((i) => i.id !== id);
if (selectedInstanceId.value === id) {
selectedInstanceId.value = '';
}
}
/**
* 选中块实例
*/
function selectInstance(id: string) {
selectedInstanceId.value = id;
}
/**
* 更新顶层固定字段值PropertyPanel 触发)
*/
function updateFixedValues(values: Record<string, any>) {
fixedValues.value = values;
}
/**
* 更新块实例数据PropertyPanel 触发)
*/
function updateInstance(updated: BlockInstance) {
instances.value = instances.value.map((i) =>
i.id === updated.id ? updated : i,
);
}
// ====== 工具函数 ======
function generateId(): string {
return `inst_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
*/
function setByPath(obj: Record<string, any>, path: string, value: any) {
const keys = path.split('.');
let cursor: any = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]!;
if (!cursor[k]) cursor[k] = {};
cursor = cursor[k];
}
cursor[keys[keys.length - 1]!] = value;
}
/**
* 按 a.b.c 路径读取对象嵌套属性
*/
function getByPath(obj: Record<string, any> | undefined, path: string): any {
if (!obj) return undefined;
const keys = path.split('.');
let cursor: any = obj;
for (const k of keys) {
if (cursor == null) return undefined;
cursor = cursor[k];
}
return cursor;
}
// ====== modelValue 双向同步 ======
/**
* 监听 modelValue 变化,反向初始化内部状态(用于编辑场景回显)
*/
watch(
() => props.modelValue,
(val) => {
if (!val || Object.keys(val).length === 0) {
// 重置
instances.value = [];
fixedValues.value = {};
selectedInstanceId.value = '';
return;
}
// 简化处理:只在 modelValue 引用变化时才反解析(避免无限循环)
// 从 modelValue 还原 fixedValues 和 instances
const newFixed: Record<string, any> = {};
for (const field of props.fixedFields || []) {
const v = getByPath(val, field.name);
if (v !== undefined) {
setByPath(newFixed, field.name, v);
}
}
fixedValues.value = newFixed;
// 从各 targetArrayPath 还原 instances
const newInstances: BlockInstance[] = [];
for (const schema of props.schema) {
const arr = getByPath(val, schema.targetArrayPath);
if (Array.isArray(arr)) {
for (const item of arr) {
newInstances.push({
id: generateId(),
type: schema.type,
data: JSON.parse(JSON.stringify(item)),
});
}
}
}
instances.value = newInstances;
},
{ immediate: false, deep: false },
);
/**
* 监听 assembledJson 变化emit 给父级
*/
watch(
assembledJson,
(val) => {
// 浅比较避免不必要的 emit
const current = JSON.stringify(props.modelValue || {});
const next = JSON.stringify(val);
if (current !== next) {
emit('update:modelValue', val);
}
},
{ deep: true },
);
// 监听 schema 变化时重置 SortableJS画布 DOM 可能重建)
watch(
() => props.schema,
() => {
void nextTick(() => initSortables());
},
);
onMounted(async () => {
// 初始化 fixedValues从 props.fixedFields 的 defaultValue
for (const field of props.fixedFields || []) {
if (field.defaultValue !== undefined) {
setByPath(fixedValues.value, field.name, field.defaultValue);
}
}
// 从 modelValue 回显数据
if (props.modelValue && Object.keys(props.modelValue).length > 0) {
for (const field of props.fixedFields || []) {
const v = getByPath(props.modelValue, field.name);
if (v !== undefined) {
setByPath(fixedValues.value, field.name, v);
}
}
for (const schema of props.schema) {
const arr = getByPath(props.modelValue, schema.targetArrayPath);
if (Array.isArray(arr)) {
for (const item of arr) {
instances.value.push({
id: generateId(),
type: schema.type,
data: JSON.parse(JSON.stringify(item)),
});
}
}
}
}
await nextTick();
await initSortables();
});
onUnmounted(() => {
destroySortables();
});
// 暴露给父组件
defineExpose({
initSortables,
});
</script>
<template>
<div ref="editorRootRef" class="template-editor">
<div class="editor-toolbar">
<span class="title">模板编辑器</span>
<Button
size="small"
type="link"
@click="initSortables"
>
重置拖拽
</Button>
</div>
<div class="editor-main">
<!-- 左侧组件库 ref 暴露给 SortableJS 初始化 -->
<div ref="libraryRef">
<BlockLibrary :blocks="schema" />
</div>
<!-- 中间画布 -->
<Canvas
:block-schemas="schema"
:instances="instances"
:selected-instance-id="selectedInstanceId"
@select="selectInstance"
@remove="removeInstance"
/>
<!-- 右侧属性面板 -->
<PropertyPanel
:fixed-fields="fixedFields"
:fixed-values="fixedValues"
:selected-block-schema="selectedBlockSchema"
:selected-instance="selectedInstance"
@update:fixed-values="updateFixedValues"
@update:instance="updateInstance"
/>
<!-- 最右侧JSON 预览可选 -->
<JsonPreview
v-if="showJsonPreview"
:value="assembledJson"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.template-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 480px;
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
border-radius: 6px;
overflow: hidden;
background: var(--ant-color-bg-container, #fff);
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-fill-quaternary, #fafbfc);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
}
.editor-main {
display: flex;
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,99 @@
/**
* 通用拖拽式可视化编辑器类型定义
*
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
*
* 核心概念:
* - BlockSchema可拖拽块定义左侧组件库中展示决定属性面板渲染什么字段
* - FormFieldSchema单个字段定义驱动右侧属性面板表单渲染
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
*/
/**
* 字段类型枚举
* - string单行文本
* - number数字
* - textarea多行文本
* - select下拉选择
* - radio单选按钮组
* - image图片素材选择用 GalleryPickLinkacceptTypes 限定图片)
* - urlURL 链接
*/
export type FormFieldType =
| 'image'
| 'number'
| 'radio'
| 'select'
| 'string'
| 'textarea'
| 'url';
/**
* 单个字段定义(驱动属性面板表单渲染)
*/
export interface FormFieldSchema {
/** 字段名(在 payload 对象中的 key支持 a.b.c 嵌套路径) */
name: string;
/** 中文标签 */
label: string;
/** 字段类型 */
type: FormFieldType;
/** 占位提示 */
placeholder?: string;
/** 是否必填 */
required?: boolean;
/** select / radio 类型的选项列表 */
options?: { label: string; value: any }[];
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
acceptTypes?: number[];
/** 最大长度 */
maxLength?: number;
/** 默认值 */
defaultValue?: any;
}
/**
* 可拖拽块定义(左侧组件库中展示,决定画布上能放什么块、各块有哪些字段)
*/
export interface BlockSchema {
/** 块类型标识(唯一) */
type: string;
/** 块显示名(如「二级标题+文本项」「跳转链接项」) */
label: string;
/** 块图标Iconify */
icon: string;
/** 该块的字段定义(驱动右侧属性面板表单渲染) */
fields: FormFieldSchema[];
/**
* 该块在最终 JSON 中放到的数组路径
* 例如 'horizontal_content_list' 表示拖入的块实例会被收集到 result.horizontal_content_list 数组
* 多个块共用同一个 targetArrayPath 即被收集到同一数组
*/
targetArrayPath: string;
/** 该数组路径下最多允许的块数量(如企微 jump_list 最多 3 项) */
maxCount?: number;
/** 块实例的默认值(拖入时初始化) */
defaultValue?: Record<string, any>;
}
/**
* 画布中的块实例(拖入后生成的实际数据)
*/
export interface BlockInstance {
/** 块实例唯一 ID用于 SortableJS 拖拽排序追踪) */
id: string;
/** 关联的块类型(对应 BlockSchema.type */
type: string;
/** 块实例的当前数据(属性面板编辑后会更新这里) */
data: Record<string, any>;
}
/**
* 顶层固定字段(不可拖拽删除,固定显示在属性面板顶部)
* 用于 card_type / main_title / source 等必填顶层字段
*/
export interface FixedField extends FormFieldSchema {
/** 字段在最终 JSON 中的路径(支持 a.b.c 嵌套,如 'main_title.title' */
name: string;
}

View File

@@ -1,16 +1,15 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue';
import type { FileTypeItem } from '#/api/core/file-gallery'; import type { FileTypeItem } from '#/api/core/file-gallery';
import { getFileTypes } from '#/api/core/file-gallery'; import { getFileTypes } from '#/api/core/file-gallery';
import type { FileGroupOptionItem } from '#/api/core/file-group'; import type { FileGroupOptionItem } from '#/api/core/file-group';
import { useFileGroupCache } from '#/composables/use-file-group-cache'; import { useFileGroupCache } from '#/composables/use-file-group-cache';
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
export const ALL_TYPE_VALUE = -1; export const ALL_TYPE_VALUE = -1;
export const ALL_GROUP_VALUE = 0; export const ALL_GROUP_VALUE = 0;
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
export interface FileGalleryTab { export interface FileGalleryTab {
key: string; key: string;
value: number; value: number;
@@ -18,7 +17,13 @@ export interface FileGalleryTab {
icon?: string; icon?: string;
} }
export function useFileGalleryFilter(storageKey: string) { /**
* 文件库筛选(类型 tab + 分组 + 关键字)
* @param storageKeyInput localStorage 键;支持动态 getter便于按 acceptTypes 隔离记忆
*/
export function useFileGalleryFilter(
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
) {
const fileTypes = ref<FileTypeItem[]>([]); const fileTypes = ref<FileTypeItem[]>([]);
const activeType = ref<number>(ALL_TYPE_VALUE); const activeType = ref<number>(ALL_TYPE_VALUE);
const activeGroupId = ref<number>(ALL_GROUP_VALUE); const activeGroupId = ref<number>(ALL_GROUP_VALUE);
@@ -27,6 +32,10 @@ export function useFileGalleryFilter(storageKey: string) {
const { groupOptions, loadGroupOptions } = useFileGroupCache(); const { groupOptions, loadGroupOptions } = useFileGroupCache();
function resolveStorageKey() {
return toValue(storageKeyInput);
}
const tabs = computed<FileGalleryTab[]>(() => [ const tabs = computed<FileGalleryTab[]>(() => [
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON }, { key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
...fileTypes.value.map((item) => ({ ...fileTypes.value.map((item) => ({
@@ -48,7 +57,7 @@ export function useFileGalleryFilter(storageKey: string) {
]); ]);
function restoreActiveType() { function restoreActiveType() {
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(resolveStorageKey());
if (saved === null) { if (saved === null) {
activeType.value = ALL_TYPE_VALUE; activeType.value = ALL_TYPE_VALUE;
return; return;
@@ -64,7 +73,7 @@ export function useFileGalleryFilter(storageKey: string) {
function setActiveType(value: number) { function setActiveType(value: number) {
activeType.value = value; activeType.value = value;
localStorage.setItem(storageKey, String(value)); localStorage.setItem(resolveStorageKey(), String(value));
} }
function setActiveGroupId(value: number) { function setActiveGroupId(value: number) {

View File

@@ -582,8 +582,25 @@ export const usePrescriptionStore = defineStore('prescription', () => {
} }
}, 300); }, 300);
// 药品操作 /**
* 从商品/药品数据解析处方分类 tab与 activeCategory 对齐)
* 1 中药 / 2 西药 / 3 保健食品 / 5 产品服务包 / 6 非药品 / 7 医疗器械
* 缺省按西药 2避免挂号选药误入中药开方
*/
const resolveDrugCategory = (data: any): number => {
const raw = data?.drug?.type ?? data?.type;
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
return 2;
};
// 药品操作:写入前按药型自动切到对应 tab避免西药落入中药开方组件
const addProducts = (data: any) => { const addProducts = (data: any) => {
const targetCategory = resolveDrugCategory(data);
if (Number(activeCategory.value) !== targetCategory) {
changeCategory(targetCategory);
}
const existItem = currentDrugs.value.find( const existItem = currentDrugs.value.find(
(item) => item.index_id === data.id, (item) => item.index_id === data.id,
); );
@@ -1077,6 +1094,38 @@ export const usePrescriptionStore = defineStore('prescription', () => {
}, },
userStore.currentUser.doctor_id, userStore.currentUser.doctor_id,
); );
// 在线复诊开方后自动连发购药指引与用药温馨提示(小助手发送)
const tipTexts = [
'根据您的病情描述,已为您开具电子处方,请点击上方的‘立即支付’,进行购药;如有商品价格和订单等问题请咨询平台客服。',
'温馨提示:用药前请详细阅读药品说明书,并严格按照线下医嘱用药。如用药过程中出现病情变化或其它不适症状,请立即停药并及时就医。(请您注意,如果您尚未在医院就诊或未曾使用过本次申请的药品,请暂时不要支付订单。我们建议您在医生的指导下使用药品,以确保用药安全。)',
];
tipTexts.forEach((tip, tipIdx) => {
sendMessage({
roomId: chatStore.currentFriend.room_id,
senderId: 'doctor_assistant',
receiverId: chatStore.currentFriend.id,
type: 'text',
content: tip,
});
chatStore.addMessage(
{
id: `${Date.now()}_tip_${tipIdx}`,
room_id: chatStore.currentFriend.room_id,
sender_user_id: 'doctor_assistant',
receiver_user_id: chatStore.currentFriend.id,
message_type: 0,
message_content: tip,
messageTypeName: 'text',
created_at: Date.now() + tipIdx + 1,
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
timestamp: Date.now() + tipIdx + 1,
isSent: true,
read: true,
duration: 0,
},
userStore.currentUser.doctor_id,
);
});
resetForm(); resetForm();
// 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory // 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory
clearLocalPrescriptionCache(); clearLocalPrescriptionCache();
@@ -1240,6 +1289,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
resetInitializationState, resetInitializationState,
getDrugList, getDrugList,
addProducts, addProducts,
resolveDrugCategory,
removeDrug, removeDrug,
updateDrugQuantity, updateDrugQuantity,
increment, increment,

View File

@@ -115,6 +115,23 @@ export function generateObjectName(file: File): string {
return objectName; return objectName;
} }
/**
* 根据 File 推断上传用 Content-Type浏览器可能给空 type
*/
function resolveUploadContentType(file: File): string {
if (file.type && file.type !== 'application/octet-stream') {
return file.type;
}
const name = (file.name || '').toLowerCase();
if (name.endsWith('.pdf')) return 'application/pdf';
if (name.endsWith('.png')) return 'image/png';
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
if (name.endsWith('.gif')) return 'image/gif';
if (name.endsWith('.webp')) return 'image/webp';
if (name.endsWith('.bmp')) return 'image/bmp';
return file.type || 'application/octet-stream';
}
/** /**
* 从后端获取OSS上传签名信息 * 从后端获取OSS上传签名信息
* *
@@ -253,6 +270,12 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
// 注意此字段必须与后端Policy中的条件一致否则会上传失败 // 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read'); formData.append('x-oss-object-acl', 'public-read');
// Content-Type按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
const contentType = resolveUploadContentType(file);
formData.append('Content-Type', contentType);
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
formData.append('Content-Disposition', 'inline');
// file: 要上传的文件 // file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后 // 必须是最后一个字段OSS要求file字段在FormData的最后
formData.append('file', file); formData.append('file', file);

View File

@@ -0,0 +1,160 @@
import { onUnmounted, ref, type Ref } from 'vue';
import { message } from 'ant-design-vue';
export interface UsePasteUploadListenOptions {
/** accept 字符串,如 `.jpg,.png,image/*,application/pdf`;支持 getter */
accept?: string | (() => string);
/** 单文件最大体积(字节);支持 getter */
maxSize?: number | (() => number);
/** 过滤后的文件回调 */
onFiles: (files: File[]) => void | Promise<void>;
}
/**
* 将 accept 规则解析为便于匹配的片段列表
*/
function parseAccept(accept: string): string[] {
return accept
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/**
* 判断文件是否匹配 accept扩展名 / mime / image/* 等)
*/
export function matchAccept(file: File, accept: string): boolean {
const rules = parseAccept(accept);
if (!rules.length) return true;
const name = (file.name || '').toLowerCase();
const type = (file.type || '').toLowerCase();
const ext = name.includes('.') ? `.${name.split('.').pop()}` : '';
return rules.some((rule) => {
if (rule === '*/*') return true;
if (rule.endsWith('/*')) {
const prefix = rule.slice(0, -1); // image/
return type.startsWith(prefix);
}
if (rule.startsWith('.')) {
return ext === rule;
}
return type === rule;
});
}
/**
* 从粘贴事件中收集 Filefiles + items 截图等)
*/
export function collectPasteFiles(event: ClipboardEvent): File[] {
const result: File[] = [];
const seen = new Set<string>();
const push = (file: File | null | undefined) => {
if (!file) return;
const key = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
if (seen.has(key)) return;
seen.add(key);
result.push(file);
};
const files = event.clipboardData?.files;
if (files?.length) {
for (let i = 0; i < files.length; i++) {
push(files.item(i));
}
}
const items = event.clipboardData?.items;
if (items?.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item?.kind === 'file') {
push(item.getAsFile());
}
}
}
return result;
}
/**
* 开始/停止监听窗口粘贴,用于上传组件在拖拽区外主动开启粘贴上传
*/
export function usePasteUploadListen(options: UsePasteUploadListenOptions) {
const listening: Ref<boolean> = ref(false);
let handler: ((e: ClipboardEvent) => void) | null = null;
function stop() {
if (handler) {
window.removeEventListener('paste', handler);
handler = null;
}
listening.value = false;
}
function start() {
stop();
handler = (event: ClipboardEvent) => {
const acceptRaw = options.accept;
const accept =
typeof acceptRaw === 'function' ? acceptRaw() : acceptRaw || '';
const maxSizeRaw = options.maxSize;
const maxSize =
typeof maxSizeRaw === 'function'
? maxSizeRaw()
: (maxSizeRaw ?? 20 * 1024 * 1024);
const raw = collectPasteFiles(event);
if (!raw.length) {
message.warning('剪贴板中没有可上传的文件');
return;
}
const filtered: File[] = [];
for (const file of raw) {
if (accept && !matchAccept(file, accept)) {
continue;
}
if (file.size > maxSize) {
message.warning(`${file.name || '文件'} 超过大小限制`);
continue;
}
// 截图常无名为空,补一个默认名便于上传
if (!file.name) {
const ext =
file.type === 'image/png'
? 'png'
: file.type === 'image/jpeg'
? 'jpg'
: file.type === 'application/pdf'
? 'pdf'
: 'bin';
filtered.push(new File([file], `paste-${Date.now()}.${ext}`, { type: file.type }));
} else {
filtered.push(file);
}
}
if (!filtered.length) {
message.warning('剪贴板文件类型不符合要求');
return;
}
event.preventDefault();
void options.onFiles(filtered);
};
window.addEventListener('paste', handler);
listening.value = true;
}
function toggle() {
if (listening.value) stop();
else start();
}
onUnmounted(() => {
stop();
});
return {
listening,
start,
stop,
toggle,
};
}

View File

@@ -1,13 +1,29 @@
<script setup> <script setup>
/**
* 诊所复诊 type11 follow_up_drug 助理用药确认卡
* 展示问题、药品、是否用过、适用症/补充;可一键导入处方单
*/
import { computed } from 'vue'; import { computed } from 'vue';
import { Button } from 'ant-design-vue';
const props = defineProps({ const props = defineProps({
content: { content: {
type: Object, type: Object,
required: true, required: true,
}, },
/** 是否展示「添加到处方单」(接诊中且有药) */
showAddToRx: {
type: Boolean,
default: false,
},
addToRxLoading: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['add-to-rx']);
const data = computed(() => const data = computed(() =>
typeof props.content === 'string' typeof props.content === 'string'
? (() => { ? (() => {
@@ -27,9 +43,36 @@ const answeredLabel = computed(() => {
return ''; return '';
}); });
/** 是否就诊过:有明确 0/1 才展示 */
const visitedLabel = computed(() => {
const v = data.value.has_visited;
if (v === 1 || v === '1') return '是';
if (v === 0 || v === '0') return '否';
return '';
});
const drugs = computed(() => const drugs = computed(() =>
Array.isArray(data.value.drugs) ? data.value.drugs : [], Array.isArray(data.value.drugs) ? data.value.drugs : [],
); );
/** 适用症文案(有值才展示) */
const illnessInfo = computed(() => {
const v = data.value.illness_info;
return v != null && String(v).trim() ? String(v).trim() : '';
});
/**
* 补充信息:优先独立字段;若 illness_info 已含「;补充:」且无独立字段则不重复展示
*/
const supplementText = computed(() => {
const v = data.value.supplement;
if (v != null && String(v).trim()) return String(v).trim();
return '';
});
const canShowAddBtn = computed(
() => props.showAddToRx && drugs.value.length > 0,
);
</script> </script>
<template> <template>
@@ -63,12 +106,41 @@ const drugs = computed(() =>
<span class="value">{{ answeredLabel }}</span> <span class="value">{{ answeredLabel }}</span>
</div> </div>
<div v-if="visitedLabel" class="answer-row">
<span class="label">是否就诊过</span>
<span class="value">{{ visitedLabel }}</span>
</div>
<div v-if="illnessInfo" class="info-block">
<div class="info-block-label">适用症/症状</div>
<div class="info-block-content">{{ illnessInfo }}</div>
</div>
<div v-if="supplementText" class="info-block">
<div class="info-block-label">补充信息</div>
<div class="info-block-content">{{ supplementText }}</div>
</div>
<div <div
v-else-if="data.answer_status === 'pending'" v-else-if="!answeredLabel && data.answer_status === 'pending'"
class="pending-tip" class="pending-tip"
> >
待患者在小程序端填写用药信息 待患者在小程序端填写用药信息
</div> </div>
<div v-if="canShowAddBtn" class="add-rx-wrap">
<Button
:loading="addToRxLoading"
block
size="small"
type="primary"
ghost
@click.stop="emit('add-to-rx')"
>
<i class="fas fa-plus-circle mr-1"></i>
添加到处方单
</Button>
</div>
</div> </div>
</template> </template>
@@ -122,7 +194,7 @@ const drugs = computed(() =>
} }
.answer-row { .answer-row {
@apply flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30; @apply mb-3 flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
} }
.answer-row .label { .answer-row .label {
@@ -133,7 +205,23 @@ const drugs = computed(() =>
@apply font-medium text-emerald-800 dark:text-emerald-300; @apply font-medium text-emerald-800 dark:text-emerald-300;
} }
.info-block {
@apply mb-3 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-900/50;
}
.info-block-label {
@apply mb-1 text-xs text-slate-500 dark:text-slate-400;
}
.info-block-content {
@apply text-sm leading-relaxed text-slate-800 dark:text-slate-100;
}
.pending-tip { .pending-tip {
@apply rounded-lg border border-dashed border-amber-200 bg-amber-50 px-3 py-2 text-center text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200; @apply rounded-lg border border-dashed border-amber-200 bg-amber-50 px-3 py-2 text-center text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200;
} }
.add-rx-wrap {
@apply mt-3 border-t border-slate-100 pt-3 dark:border-slate-600;
}
</style> </style>

View File

@@ -251,10 +251,15 @@ const canAddRegisterDrugsToRx = computed(() => {
const handleAddRegisterDrugsToPrescription = async () => { const handleAddRegisterDrugsToPrescription = async () => {
if (registerAddToRxLoading.value) return; if (registerAddToRxLoading.value) return;
const cardRegisterId = registerCardDisplay.value?.id; const cardRegisterId = registerCardDisplay.value?.id;
const followUpRegisterId =
isFollowUpDrugAssistant.value && parsedContent.value?.register_id
? parsedContent.value.register_id
: '';
const registerId = const registerId =
prescriptionStore.currentRegisterId || prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id || chatStore.currentFriend?.register_id ||
cardRegisterId; cardRegisterId ||
followUpRegisterId;
if (!registerId) { if (!registerId) {
antdMessage.warning('请先接诊患者'); antdMessage.warning('请先接诊患者');
return; return;
@@ -308,6 +313,22 @@ const isFollowUpDrugAssistant = computed(() => {
return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug'); return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug');
}); });
/**
* 复诊用药卡「添加到处方单」:有药、非本人发送、当前会话有挂号
* 与挂号卡加方共用 handleAddRegisterDrugsToPrescription
*/
const canAddFollowUpDrugsToRx = computed(() => {
if (!isFollowUpDrugAssistant.value || props.isSent) return false;
const pc = parsedContent.value;
const drugs = pc && Array.isArray(pc.drugs) ? pc.drugs : [];
if (!drugs.length) return false;
return !!(
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id ||
pc.register_id
);
});
function fetchRegisterCard() { function fetchRegisterCard() {
if (props.message.message_type !== 10) return; if (props.message.message_type !== 10) return;
const content = getParsedContent( const content = getParsedContent(
@@ -726,9 +747,13 @@ const getSexText = (sex) => {
</div> </div>
</div> </div>
<!-- 主诉 / 所选药品逐药缩略图 + 规格 + 数量对齐小程序 --> <!-- 主诉 / 适用症 / 所选药品逐药缩略图 + 规格 + 数量对齐小程序 -->
<div <div
v-if="registerCardDisplay.chief_complaint || registerCardDrugRows.length" v-if="
registerCardDisplay.chief_complaint ||
registerCardDisplay.illnessInfo ||
registerCardDrugRows.length
"
class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600" class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600"
> >
<div v-if="registerCardDisplay.chief_complaint"> <div v-if="registerCardDisplay.chief_complaint">
@@ -737,6 +762,33 @@ const getSexText = (sex) => {
registerCardDisplay.chief_complaint registerCardDisplay.chief_complaint
}}</span> }}</span>
</div> </div>
<div v-if="registerCardDisplay.illnessInfo">
<span class="font-medium text-gray-600 dark:text-gray-300">适用症/症状:</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerCardDisplay.illnessInfo
}}</span>
</div>
<div
v-if="registerCardDisplay.illnessInfo"
class="flex flex-wrap gap-x-4 gap-y-1 text-gray-700 dark:text-gray-200"
>
<span>
是否就诊过:{{
registerCardDisplay.has_visited === 1 ||
registerCardDisplay.has_visited === '1'
? '是'
: '否'
}}
</span>
<span>
是否使用过药品:{{
registerCardDisplay.has_used_drug === 1 ||
registerCardDisplay.has_used_drug === '1'
? '是'
: '否'
}}
</span>
</div>
<div v-if="registerCardDrugRows.length" class="space-y-2"> <div v-if="registerCardDrugRows.length" class="space-y-2">
<div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div> <div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div>
<div <div
@@ -1044,6 +1096,9 @@ const getSexText = (sex) => {
<FollowUpDrugAssistantCard <FollowUpDrugAssistantCard
v-else-if="message.message_type === 11 && isFollowUpDrugAssistant" v-else-if="message.message_type === 11 && isFollowUpDrugAssistant"
:content="parsedContent" :content="parsedContent"
:show-add-to-rx="canAddFollowUpDrugsToRx"
:add-to-rx-loading="registerAddToRxLoading"
@add-to-rx="handleAddRegisterDrugsToPrescription"
/> />
<!-- 患者就诊经历卡片 (type=11) --> <!-- 患者就诊经历卡片 (type=11) -->

View File

@@ -134,9 +134,12 @@ export const sendMessage = (data) => {
const receiverId = data.receiverId.toString(); const receiverId = data.receiverId.toString();
const normalizedReceiverId = receiverId.startsWith('user-') ? receiverId : `user-${receiverId}`; const normalizedReceiverId = receiverId.startsWith('user-') ? receiverId : `user-${receiverId}`;
// 确保 sender_user_id doctor- 前缀(医生端) // 确保 sender_user_id:小助手保持 doctor_assistant医生端补 doctor- 前缀
const senderId = data.senderId.toString(); const senderId = data.senderId.toString();
const normalizedSenderId = senderId.startsWith('doctor-') ? senderId : `doctor-${senderId}`; const normalizedSenderId =
senderId === 'doctor_assistant' || senderId.startsWith('doctor-')
? senderId
: `doctor-${senderId}`;
const requestData = { const requestData = {
room_id: data.roomId, room_id: data.roomId,

View File

@@ -37,7 +37,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方溯源"> <Modal class="w-[80%] h-[80%]" title="处方溯源">
<PrescriptionSourceContent :data="data" /> <PrescriptionSourceContent :data="data" />
</Modal> </Modal>
</template> </template>

View File

@@ -47,9 +47,14 @@ export const formOptions: VbenFormProps = {
{ {
component: 'RangePicker', component: 'RangePicker',
componentProps: { componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致 format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
}, },
defaultValue: [dayjs().startOf('month'), dayjs()], defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time', fieldName: 'search_time',
label: '时间范围', label: '时间范围',
}, },

View File

@@ -263,7 +263,7 @@ function prescriptionStatusColor() {
} }
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="订单详情"> <Modal class="h-[80%] w-[80%]" title="订单详情">
<div v-if="data" class="flex flex-col gap-4"> <div v-if="data" class="flex flex-col gap-4">
<Space> <Space>
<Button type="primary" size="small" @click="openOrderTrace"> <Button type="primary" size="small" @click="openOrderTrace">

View File

@@ -106,8 +106,13 @@ export const formOptions: VbenFormProps = {
component: 'RangePicker', component: 'RangePicker',
componentProps: { componentProps: {
format: 'YYYY-MM-DD', format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
}, },
defaultValue: [dayjs().startOf('month'), dayjs()], defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time', fieldName: 'search_time',
label: '时间范围', label: '时间范围',
}, },

View File

@@ -18,9 +18,14 @@ export const formOptions: VbenFormProps = {
{ {
component: 'RangePicker', component: 'RangePicker',
componentProps: { componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致 format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
}, },
defaultValue: [dayjs().startOf('month'), dayjs()], defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time', fieldName: 'search_time',
label: '时间范围', label: '时间范围',
}, },

View File

@@ -0,0 +1,12 @@
import { requestClient } from '#/api/request';
/**
* 切换药品单品包邮(非中药商品共用)
* @param drugId 药品 ID
*/
export async function toggleDrugFreeShipping(drugId: number) {
return requestClient.post<{ is_free_shipping: number }>(
'drug-free-shipping/toggle',
{ drug_id: drugId },
);
}

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { message, Tag } from 'ant-design-vue';
import { toggleDrugFreeShipping } from './drug-free-shipping-api';
const props = defineProps<{
/** 列表行:需含 id、is_free_shipping */
row: {
id: number | string;
is_free_shipping?: number;
};
}>();
const emit = defineEmits<{
/** 切换成功后通知父级刷新列表 */
(e: 'changed'): void;
}>();
const loading = ref(false);
/**
* 点击 Tag 切换单品包邮;成功后回写行内状态并通知父级
*/
const onToggle = async () => {
if (loading.value) {
return;
}
const drugId = Number(props.row.id);
if (!drugId) {
return;
}
loading.value = true;
try {
const res = await toggleDrugFreeShipping(drugId);
const next = Number(res?.is_free_shipping ?? 0);
props.row.is_free_shipping = next;
message.success(next === 1 ? '已设为单品包邮' : '已取消单品包邮');
emit('changed');
} finally {
loading.value = false;
}
};
</script>
<template>
<Tag
:color="row.is_free_shipping === 1 ? 'success' : 'default'"
class="cursor-pointer"
:class="{ 'opacity-60': loading }"
@click="onToggle"
>
{{ row.is_free_shipping === 1 ? '包邮' : '不包邮' }}
</Tag>
</template>

View File

@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
}; };
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel"> <Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 新增显示文件选择状态 --> <!-- 新增显示文件选择状态 -->
<div v-if="isFileSelected" class="mb-4 p-3 bg-green-50 border border-green-200 rounded"> <div v-if="isFileSelected" class="mb-4 p-3 bg-green-50 border border-green-200 rounded">
<p class="text-green-700 text-sm">已选择文件{{ selectedFile?.name }}</p> <p class="text-green-700 text-sm">已选择文件{{ selectedFile?.name }}</p>

View File

@@ -102,7 +102,7 @@ const handleChange = (info: { file: UploadFile }) => {
}; };
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel"> <Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 显示文件选择状态提示 --> <!-- 显示文件选择状态提示 -->
<div <div
v-if="isFileSelected" v-if="isFileSelected"

View File

@@ -57,6 +57,12 @@ export const gridOptions: VxeGridProps<RowType> = {
}, },
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 }, { field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'specification', title: '规格', width: 120 }, { field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } }, { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } }, { type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
], ],

View File

@@ -20,6 +20,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue'; import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue'; import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue'; import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue'; import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue'; import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue'; import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -303,6 +304,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }"> <template #supplier="{ row }">
{{ row.supplier?.name || row.source }} {{ row.supplier?.name || row.source }}
</template> </template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }"> <template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag> <Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template> </template>

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
}; };
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel"> <Modal class="w-[80%] h-[80%]" title="上传Excel">
<div <div
v-if="isFileSelected" v-if="isFileSelected"
class="mb-4 rounded border border-green-200 bg-green-50 p-3" class="mb-4 rounded border border-green-200 bg-green-50 p-3"

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 }, { field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'function', title: '器械功能', width: 200 }, { field: 'function', title: '器械功能', width: 200 },
{ field: 'specification', title: '规格型号', width: 120 }, { field: 'specification', title: '规格型号', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } }, { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } }, { type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
], ],

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue'; import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue'; import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue'; import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue'; import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue'; import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue'; import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -250,6 +251,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }"> <template #supplier="{ row }">
{{ row.supplier?.name || row.source }} {{ row.supplier?.name || row.source }}
</template> </template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }"> <template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag> <Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template> </template>

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
}; };
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel"> <Modal class="w-[80%] h-[80%]" title="上传Excel">
<div <div
v-if="isFileSelected" v-if="isFileSelected"
class="mb-4 rounded border border-green-200 bg-green-50 p-3" class="mb-4 rounded border border-green-200 bg-green-50 p-3"

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 }, { field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'function', title: '产品功能', width: 200 }, { field: 'function', title: '产品功能', width: 200 },
{ field: 'specification', title: '规格', width: 120 }, { field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } }, { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } }, { type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
], ],

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue'; import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue'; import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue'; import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue'; import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue'; import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue'; import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -250,6 +251,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }"> <template #supplier="{ row }">
{{ row.supplier?.name || row.source }} {{ row.supplier?.name || row.source }}
</template> </template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }"> <template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag> <Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template> </template>

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', width: 130 }, { field: 'supplier.name', title: '供应商', width: 130 },
{ field: 'function', title: '主要功能', width: 200 }, { field: 'function', title: '主要功能', width: 200 },
{ field: 'specification', title: '规格', width: 120 }, { field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } }, { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } }, { type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
], ],

View File

@@ -12,6 +12,7 @@ import { TableAction } from '#/components/table-action';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue'; import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue'; import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue'; import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue'; import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue'; import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue'; import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -219,6 +220,9 @@ const deleteApi = (row: any) => {
@stock-in="openStockIn" @stock-in="openStockIn"
/> />
</template> </template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }"> <template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag> <Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template> </template>

View File

@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
}; };
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel"> <Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 新增显示文件选择状态 --> <!-- 新增显示文件选择状态 -->
<div <div
v-if="isFileSelected" v-if="isFileSelected"

View File

@@ -55,6 +55,12 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 100, width: 100,
slots: { default: 'is_otc' }, slots: { default: 'is_otc' },
}, },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } }, { field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ {
field: 'min_adjust_price', field: 'min_adjust_price',

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue'; import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue'; import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue'; import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue'; import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue'; import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue'; import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -298,6 +299,9 @@ const openExcelUploadModal = () => {
{{ row.is_otc === 0 ? '处方药' : '非处方药' }} {{ row.is_otc === 0 ? '处方药' : '非处方药' }}
</Tag> </Tag>
</template> </template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }"> <template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag> <Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template> </template>

View File

@@ -1,14 +1,15 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Descriptions, Button, Empty, Image } from 'ant-design-vue'; import { Descriptions, Button, Empty, Image, message } from 'ant-design-vue';
import { useVbenModal } from '@vben/common-ui';
import { getPatientDetail, reception } from '../api/index.ts'; import { getPatientDetail, reception } from '../api/index.ts';
import { message } from 'ant-design-vue';
import { sendMessage } from '#/views/business/chat/utils/request'; import { sendMessage } from '#/views/business/chat/utils/request';
import { useUserStore } from '#/views/business/chat/stores/user'; import { useUserStore } from '#/views/business/chat/stores/user';
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription'; import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
import TransferPrescriptionCard from '#/views/doctor/online-consultation/components/TransferPrescriptionCard.vue'; import TransferPrescriptionCard from '#/views/doctor/online-consultation/components/TransferPrescriptionCard.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { usePrescriptionStore } from '#/store/prescription'; import { usePrescriptionStore } from '#/store/prescription';
import RefusalOfTreatmentModal from '#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const prescriptionStore = usePrescriptionStore(); const prescriptionStore = usePrescriptionStore();
@@ -24,7 +25,7 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['reception-success', 'enter-chat', 'refresh-list', 'transfer-import-success']); const emit = defineEmits(['reception-success', 'enter-chat', 'refresh-list', 'transfer-import-success', 'refuse-success']);
const patientDetail = ref(null); const patientDetail = ref(null);
const loading = ref(false); const loading = ref(false);
@@ -32,6 +33,11 @@ const receptionLoading = ref(false);
const transferPrescription = ref(null); const transferPrescription = ref(null);
const loadingTransfer = ref(false); const loadingTransfer = ref(false);
/** 拒诊弹窗:复用接诊模块 RefusalOfTreatmentModal */
const [RefusalOfTreatmentModals, RefusalOfTreatmentModalApi] = useVbenModal({
connectedComponent: RefusalOfTreatmentModal,
});
// 获取患者详情 // 获取患者详情
const fetchPatientDetail = async () => { const fetchPatientDetail = async () => {
// 至少需要 room_id 或 register_id 其中之一 // 至少需要 room_id 或 register_id 其中之一
@@ -106,6 +112,19 @@ const handleReception = async () => {
} }
}; };
/** 打开拒诊原因弹窗;成功后通知父页刷新列表并清空当前患者 */
const handleRefuse = () => {
if (!props.patient?.register_id) {
message.error('缺少挂号ID');
return;
}
RefusalOfTreatmentModalApi.setData({
values: { register_id: props.patient.register_id },
getPatientListByReception: () => emit('refuse-success'),
});
RefusalOfTreatmentModalApi.open();
};
// 获取转诊信息 // 获取转诊信息
const fetchTransferPrescription = async (registerId) => { const fetchTransferPrescription = async (registerId) => {
if (!registerId) return; if (!registerId) return;
@@ -330,23 +349,34 @@ watch(
</div> </div>
</div> </div>
<!-- 接诊按钮 --> <!-- 接诊 / 拒诊待接诊时并列展示避免只能进聊天卡才能拒诊 -->
<div v-if="!patientDetail.is_recepted" class="action-section border-t border-gray-200 dark:border-gray-700"> <div v-if="!patientDetail.is_recepted" class="action-section border-t border-gray-200 dark:border-gray-700">
<div class="action-row">
<Button <Button
type="primary" type="primary"
:loading="receptionLoading" :loading="receptionLoading"
size="large" size="large"
block class="action-btn"
@click="handleReception" @click="handleReception"
> >
接诊 接诊
</Button> </Button>
<Button
danger
size="large"
class="action-btn"
@click="handleRefuse"
>
拒诊
</Button>
</div>
</div> </div>
<div v-else class="recepted-info bg-green-50 dark:bg-green-900/20"> <div v-else class="recepted-info bg-green-50 dark:bg-green-900/20">
<text class="recepted-text text-green-600 dark:text-green-400">已接诊</text> <text class="recepted-text text-green-600 dark:text-green-400">已接诊</text>
</div> </div>
</div> </div>
<RefusalOfTreatmentModals />
</div> </div>
</template> </template>
@@ -505,6 +535,15 @@ watch(
padding-top: 24px; padding-top: 24px;
} }
.action-row {
display: flex;
gap: 12px;
}
.action-btn {
flex: 1;
}
.recepted-info { .recepted-info {
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;

View File

@@ -227,6 +227,13 @@ const handleRefreshList = () => {
patientListRef.value?.fetchPatientList(); patientListRef.value?.fetchPatientList();
}; };
/** 拒诊成功:刷新列表并清空当前选中,回到待选状态 */
const handleRefuseSuccess = () => {
selectedPatient.value = null;
registerId.value = null;
patientListRef.value?.fetchPatientList();
};
// 结束接诊 // 结束接诊
const handleEndConsultation = () => { const handleEndConsultation = () => {
if (!selectedPatient.value) { if (!selectedPatient.value) {
@@ -517,6 +524,7 @@ const handleAddPatientProductsToPrescription = async () => {
:patient="selectedPatient" :patient="selectedPatient"
:doctor-id="userStore.currentUser?.doctor_id" :doctor-id="userStore.currentUser?.doctor_id"
@reception-success="handleReceptionSuccess" @reception-success="handleReceptionSuccess"
@refuse-success="handleRefuseSuccess"
@enter-chat="handleEnterChat" @enter-chat="handleEnterChat"
@refresh-list="handleRefreshList" @refresh-list="handleRefreshList"
@transfer-import-success="handleTransferImportSuccess" @transfer-import-success="handleTransferImportSuccess"

View File

@@ -1,14 +1,15 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Descriptions, Button, Empty, Image } from 'ant-design-vue'; import { Descriptions, Button, Empty, Image, message } from 'ant-design-vue';
import { useVbenModal } from '@vben/common-ui';
import { getPatientDetail, reception } from '../api/index.ts'; import { getPatientDetail, reception } from '../api/index.ts';
import { message } from 'ant-design-vue';
import { sendMessage } from '#/views/business/chat/utils/request'; import { sendMessage } from '#/views/business/chat/utils/request';
import { useUserStore } from '#/views/business/chat/stores/user'; import { useUserStore } from '#/views/business/chat/stores/user';
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription'; import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
import TransferPrescriptionCard from './TransferPrescriptionCard.vue'; import TransferPrescriptionCard from './TransferPrescriptionCard.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { usePrescriptionStore } from '#/store/prescription'; import { usePrescriptionStore } from '#/store/prescription';
import RefusalOfTreatmentModal from '#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const prescriptionStore = usePrescriptionStore(); const prescriptionStore = usePrescriptionStore();
@@ -24,7 +25,7 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['reception-success', 'enter-chat', 'transfer-import-success']); const emit = defineEmits(['reception-success', 'enter-chat', 'transfer-import-success', 'refuse-success']);
const patientDetail = ref(null); const patientDetail = ref(null);
const loading = ref(false); const loading = ref(false);
@@ -32,6 +33,11 @@ const receptionLoading = ref(false);
const transferPrescription = ref(null); const transferPrescription = ref(null);
const loadingTransfer = ref(false); const loadingTransfer = ref(false);
/** 拒诊弹窗:复用接诊模块 RefusalOfTreatmentModal */
const [RefusalOfTreatmentModals, RefusalOfTreatmentModalApi] = useVbenModal({
connectedComponent: RefusalOfTreatmentModal,
});
// 获取患者详情 // 获取患者详情
const fetchPatientDetail = async () => { const fetchPatientDetail = async () => {
// 至少需要 room_id 或 register_id 其中之一 // 至少需要 room_id 或 register_id 其中之一
@@ -132,6 +138,19 @@ const handleReception = async () => {
} }
}; };
/** 打开拒诊原因弹窗;成功后通知父页刷新列表并清空当前患者 */
const handleRefuse = () => {
if (!props.patient?.register_id) {
message.error('缺少挂号ID');
return;
}
RefusalOfTreatmentModalApi.setData({
values: { register_id: props.patient.register_id },
getPatientListByReception: () => emit('refuse-success'),
});
RefusalOfTreatmentModalApi.open();
};
// 获取性别文本 // 获取性别文本
const getSexText = (sex) => { const getSexText = (sex) => {
if (sex === 1) return '男'; if (sex === 1) return '男';
@@ -330,23 +349,34 @@ watch(
</div> </div>
</div> </div>
<!-- 接诊按钮 --> <!-- 接诊 / 拒诊待接诊时并列展示避免只能进聊天卡才能拒诊 -->
<div v-if="!patientDetail.is_recepted" class="action-section border-t border-gray-200 dark:border-gray-700"> <div v-if="!patientDetail.is_recepted" class="action-section border-t border-gray-200 dark:border-gray-700">
<div class="action-row">
<Button <Button
type="primary" type="primary"
:loading="receptionLoading" :loading="receptionLoading"
size="large" size="large"
block class="action-btn"
@click="handleReception" @click="handleReception"
> >
接诊 接诊
</Button> </Button>
<Button
danger
size="large"
class="action-btn"
@click="handleRefuse"
>
拒诊
</Button>
</div>
</div> </div>
<div v-else class="recepted-info bg-green-50 dark:bg-green-900/20"> <div v-else class="recepted-info bg-green-50 dark:bg-green-900/20">
<text class="recepted-text text-green-600 dark:text-green-400">已接诊</text> <text class="recepted-text text-green-600 dark:text-green-400">已接诊</text>
</div> </div>
</div> </div>
<RefusalOfTreatmentModals />
</div> </div>
</template> </template>
@@ -504,6 +534,15 @@ watch(
padding-top: 24px; padding-top: 24px;
} }
.action-row {
display: flex;
gap: 12px;
}
.action-btn {
flex: 1;
}
.recepted-info { .recepted-info {
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;

View File

@@ -217,6 +217,13 @@ const handleReceptionSuccess = (patient) => {
patientListRef.value?.fetchPatientList(); patientListRef.value?.fetchPatientList();
}; };
/** 拒诊成功:刷新列表并清空当前选中,回到待选状态 */
const handleRefuseSuccess = () => {
selectedPatient.value = null;
registerId.value = null;
patientListRef.value?.fetchPatientList();
};
// 结束接诊 // 结束接诊
const handleEndConsultation = () => { const handleEndConsultation = () => {
if (!selectedPatient.value) { if (!selectedPatient.value) {
@@ -385,6 +392,7 @@ const handleAddPatientProductsToPrescription = async () => {
:patient="selectedPatient" :patient="selectedPatient"
:doctor-id="userStore.currentUser?.doctor_id" :doctor-id="userStore.currentUser?.doctor_id"
@reception-success="handleReceptionSuccess" @reception-success="handleReceptionSuccess"
@refuse-success="handleRefuseSuccess"
@enter-chat="handleEnterChat" @enter-chat="handleEnterChat"
@transfer-import-success="handleTransferImportSuccess" @transfer-import-success="handleTransferImportSuccess"
class="patient-info-panel" class="patient-info-panel"

View File

@@ -0,0 +1,42 @@
import { requestClient } from '#/api/request';
const prefix = 'invoice-center/';
/** 开票申请列表 */
export async function getInvoiceCenterListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 开票详情 */
export async function getInvoiceCenterDetailApi(data: any) {
return requestClient.get<any>(`${prefix}detail`, { params: data });
}
/** 受理 */
export async function acceptInvoiceApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}accept`, data);
}
/** 拒绝 */
export async function rejectInvoiceApi(data: { id: number; reject_reason: string }) {
return requestClient.post<any>(`${prefix}reject`, data);
}
/** 完成开票(上传文件并按需发邮) */
export async function completeInvoiceApi(data: {
id: number;
invoice_code: string;
invoice_files: string[];
}) {
return requestClient.post<any>(`${prefix}complete`, data);
}
/** 重发邮件 */
export async function resendInvoiceEmailApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}resend-email`, data);
}
/** 各状态数量Tab 徽标) */
export async function getInvoiceStatusStatsApi() {
return requestClient.get<Record<string, number>>(`${prefix}status-stats`);
}

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
/**
* 完成开票:填写票据代码 + 拖拽/粘贴上传发票文件(图片/PDF
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { completeInvoiceApi } from '../api';
const invoiceCode = ref('');
const fileUrls = ref<string[]>([]);
const rowId = ref(0);
const receiveType = ref(1);
const email = ref('');
const gridApi = ref<any>();
const emailHint = computed(() => {
if (email.value) {
return `填写了邮箱则会发送至:${email.value};同时可在小程序查收`;
}
return '未填写邮箱则仅小程序可查收;完成后将通知患者';
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const code = invoiceCode.value.trim();
if (!code) {
message.warning('请填写票据代码');
return;
}
if (!fileUrls.value.length) {
message.warning('请上传发票文件');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res: any = await completeInvoiceApi({
id: rowId.value,
invoice_code: code,
invoice_files: fileUrls.value,
});
if (res?.subscribe_warn) {
message.warning(String(res.subscribe_warn));
} else {
message.success('开票完成');
}
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
receiveType.value = Number(data.receive_type || 1);
email.value = String(data.email || '');
gridApi.value = data.gridApi;
invoiceCode.value = '';
fileUrls.value = [];
},
});
</script>
<template>
<Modal title="完成开票" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="text-sm text-gray-500">{{ emailHint }}</div>
<div>
<div class="mb-1 font-medium">票据代码</div>
<Input v-model:value="invoiceCode" placeholder="请输入发票代码/号码" allow-clear />
</div>
<div>
<div class="mb-1 font-medium">发票文件图片或 PDF</div>
<UploadDraggerPaste
v-model="fileUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽发票文件到此处建议上传图片小程序内可直接预览PDF 将通过微信打开)"
:max-count="9"
:max-size-mb="20"
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
/**
* 拒绝开票申请弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import { rejectInvoiceApi } from '../api';
const reason = ref('');
const rowId = ref(0);
const gridApi = ref<any>();
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const text = reason.value.trim();
if (!text) {
message.warning('请填写拒绝原因');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await rejectInvoiceApi({ id: rowId.value, reject_reason: text });
message.success('已拒绝');
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
gridApi.value = data.gridApi;
reason.value = '';
},
});
</script>
<template>
<Modal title="拒绝开票申请" class="w-[480px]">
<div class="py-2">
<div class="mb-2 text-sm text-gray-500">拒绝后患者可重新申请开票</div>
<Input.TextArea v-model:value="reason" :rows="4" placeholder="请填写拒绝原因" />
</div>
</Modal>
</template>

View File

@@ -0,0 +1,47 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 开票中心列表搜索表单
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'Input',
componentProps: {
placeholder: '订单号 / 抬头 / 邮箱',
allowClear: true,
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Input',
componentProps: {
placeholder: '订单号',
allowClear: true,
},
fieldName: 'order_no',
label: '订单号',
},
{
component: 'Select',
componentProps: {
placeholder: '接收方式',
allowClear: true,
options: [
{ label: '全部', value: '' },
{ label: '小程序下载', value: 1 },
{ label: '邮箱接收', value: 2 },
],
},
defaultValue: '',
fieldName: 'receive_type',
label: '接收方式',
},
],
showCollapseButton: false,
submitButtonOptions: { content: '查询' },
submitOnChange: false,
submitOnEnter: true,
};

View File

@@ -0,0 +1,69 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getInvoiceCenterListApi } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
/**
* 开票中心表格配置statusFilter 由页面注入当前 Tab 状态
*/
export function createGridOptions(getStatus: () => string | number): VxeGridProps<RowType> {
return {
checkboxConfig: {
highlight: true,
labelField: '',
},
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'title_type_text', title: '抬头类型', width: 90 },
{ field: 'title_name', title: '抬头名称', minWidth: 120 },
{ field: 'amount', title: '金额', width: 100 },
{ field: 'receive_type_text', title: '接收方式', width: 100 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 260,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const status = getStatus();
return await getInvoiceCenterListApi({
page: page.currentPage,
pageSize: page.pageSize,
status: status === '' || status === 'all' ? '' : status,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-buttons' },
},
showOverflow: false,
};
}

View File

@@ -0,0 +1,316 @@
<script lang="ts" setup>
/**
* 财务开票中心:待受理 / 已受理 / 已完成 / 已拒绝 / 全部
* Tab 记忆 localStorage各状态徽标数量0 不显示)
* 发票文件:图片用 Image 预览PDF 用 Modal + blob iframe
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Badge, Button, Image, Modal, Space, Tabs, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
acceptInvoiceApi,
getInvoiceStatusStatsApi,
resendInvoiceEmailApi,
} from './api';
import CompleteModal from './components/CompleteModal.vue';
import RejectModal from './components/RejectModal.vue';
import { formOptions } from './config/search';
import { createGridOptions } from './config/table';
defineOptions({ name: 'FinanceInvoiceCenter' });
/** Tab 记忆键,刷新后恢复上次选中状态 */
const TAB_STORAGE_KEY = 'finance-invoice-center-tab';
const TAB_KEYS = ['0', '1', '2', '9', 'all'] as const;
/**
* 从 localStorage 读取合法 Tab非法则默认待受理
*/
function readStoredTab(): string {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored && (TAB_KEYS as readonly string[]).includes(stored)) {
return stored;
}
return '0';
}
const activeTab = ref<string>(readStoredTab());
const statusFilter = computed(() =>
activeTab.value === 'all' ? '' : activeTab.value,
);
/** 各状态数量,供徽标展示 */
const statusStats = ref<Record<string, number>>({
'0': 0,
'1': 0,
'2': 0,
'9': 0,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: createGridOptions(() => statusFilter.value),
});
/**
* 列表 + 徽标一并刷新(弹窗回调与操作后复用)
*/
function reloadAll() {
gridApi.reload();
loadStatusStats();
}
/** 传给弹窗的伪 gridApireload 时同步刷新徽标 */
const gridApiWithStats = {
reload: reloadAll,
};
const [RejectModalComp, rejectModalApi] = useVbenModal({
connectedComponent: RejectModal,
});
const [CompleteModalComp, completeModalApi] = useVbenModal({
connectedComponent: CompleteModal,
});
/** 文件预览弹窗 */
const previewVisible = ref(false);
const previewTitle = ref('');
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
/**
* 拉取各状态数量;失败静默,避免打断列表
*/
async function loadStatusStats() {
try {
const res: any = await getInvoiceStatusStatsApi();
const data = res?.data ?? res ?? {};
statusStats.value = {
'0': Number(data['0'] ?? data[0] ?? 0),
'1': Number(data['1'] ?? data[1] ?? 0),
'2': Number(data['2'] ?? data[2] ?? 0),
'9': Number(data['9'] ?? data[9] ?? 0),
};
} catch {
// 徽标非关键路径,忽略错误
}
}
function handleTabChange(key: string | number) {
const next = String(key);
activeTab.value = next;
localStorage.setItem(TAB_STORAGE_KEY, next);
reloadAll();
}
/** 受理 */
async function handleAccept(row: any) {
Modal.confirm({
title: '确认受理该开票申请?',
content: '受理后将写入站内通知(订阅消息在开票完成时发送)',
async onOk() {
await acceptInvoiceApi({ id: row.id });
message.success('受理成功');
reloadAll();
},
});
}
function openReject(row: any) {
rejectModalApi.setData({ id: row.id, gridApi: gridApiWithStats });
rejectModalApi.open();
}
function openComplete(row: any) {
completeModalApi.setData({
id: row.id,
receive_type: row.receive_type,
email: row.email,
gridApi: gridApiWithStats,
});
completeModalApi.open();
}
/** 重发邮件(有邮箱即可,不限 receive_type */
async function handleResend(row: any) {
Modal.confirm({
title: '确认重发邮件?',
content: `将再次发送至 ${row.email || ''}`,
async onOk() {
await resendInvoiceEmailApi({ id: row.id });
message.success('邮件已重发');
reloadAll();
},
});
}
function statusColor(status: number) {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
if (status === 9) return 'error';
return 'default';
}
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 预览发票文件:图片走 ImagePDF fetch 成 blob 后 iframe
*/
async function previewFiles(files: string[]) {
if (!files?.length) {
message.info('暂无文件');
return;
}
const first = files[0];
revokePdfBlob();
previewTitle.value = first.split('/').pop() || '发票文件';
if (isImageUrl(first)) {
previewIsImage.value = true;
previewImageUrl.value = first;
previewVisible.value = true;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
previewVisible.value = true;
try {
const resp = await fetch(first);
if (!resp.ok) throw new Error('文件加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch (e: any) {
message.error(e?.message || '预览失败,可尝试重新上传为图片');
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
}
function onPreviewClose() {
previewVisible.value = false;
revokePdfBlob();
previewImageUrl.value = '';
}
onMounted(() => {
loadStatusStats();
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<Page auto-content-height title="开票中心">
<RejectModalComp />
<CompleteModalComp />
<Tabs :active-key="activeTab" class="mb-2" @change="handleTabChange">
<Tabs.TabPane key="0">
<template #tab>
<Badge :count="statusStats['0']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>待受理</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="1">
<template #tab>
<Badge :count="statusStats['1']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已受理</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="2">
<template #tab>
<Badge :count="statusStats['2']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已完成</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="9">
<template #tab>
<Badge :count="statusStats['9']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已拒绝</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="all" tab="全部" />
</Tabs>
<Grid>
<template #toolbar-buttons />
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ row.status_text }}</Tag>
</template>
<template #action="{ row }">
<Space>
<Button v-if="row.status === 0" type="link" size="small" @click="handleAccept(row)">
受理
</Button>
<Button v-if="row.status === 0" type="link" size="small" danger @click="openReject(row)">
拒绝
</Button>
<Button v-if="row.status === 1" type="link" size="small" @click="openComplete(row)">
完成开票
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="previewFiles(row.invoice_files)"
>
查看文件
</Button>
<Button
v-if="row.status === 2 && row.email"
type="link"
size="small"
@click="handleResend(row)"
>
重发邮件
</Button>
</Space>
</template>
</Grid>
<Modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
width="80%"
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="发票预览"
/>
</Modal>
</Page>
</template>

View File

@@ -60,7 +60,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="申请提现"> <Modal class="w-[80%] h-[80%]" title="申请提现">
<InvalidatedForm /> <InvalidatedForm />
<SettlementForm /> <SettlementForm />
</Modal> </Modal>

View File

@@ -57,7 +57,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="编辑银行卡账户"> <Modal class="w-[80%] h-[80%]" title="编辑银行卡账户">
<Form /> <Form />
</Modal> </Modal>
</template> </template>

View File

@@ -61,7 +61,7 @@ const setAmount = (amount: number) => {
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="申请提现"> <Modal class="w-[80%] h-[80%]" title="申请提现">
<Form /> <Form />
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Button type="primary" @click="setAmount(1000)"> 1000 </Button> <Button type="primary" @click="setAmount(1000)"> 1000 </Button>

View File

@@ -39,7 +39,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="Api访问日志详情"> <Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }" :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"

View File

@@ -55,7 +55,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="监管回调详情"> <Modal class="w-[80%] h-[80%]" title="监管回调详情">
<Spin :spinning="loading"> <Spin :spinning="loading">
<Descriptions bordered :column="2" class="mb-4"> <Descriptions bordered :column="2" class="mb-4">
<Descriptions.Item label="记录ID">{{ data.id }}</Descriptions.Item> <Descriptions.Item label="记录ID">{{ data.id }}</Descriptions.Item>

View File

@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="分账日志详情"> <Modal class="w-[80%] h-[80%]" title="分账日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }" :column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"

View File

@@ -32,7 +32,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="Api访问日志详情"> <Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }" :column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"

View File

@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="操作日志详情"> <Modal class="w-[80%] h-[80%]" title="操作日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }" :column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"

View File

@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="订单日志详情"> <Modal class="w-[80%] h-[80%]" title="订单日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }" :column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"

View File

@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方日志详情"> <Modal class="w-[80%] h-[80%]" title="处方日志详情">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }" :column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"

View File

@@ -37,7 +37,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方溯源"> <Modal class="w-[80%] h-[80%]" title="处方溯源">
<PrescriptionSourceContent :data="data" /> <PrescriptionSourceContent :data="data" />
</Modal> </Modal>
</template> </template>

View File

@@ -28,6 +28,7 @@ import { createAdminGridOptions } from './table-config';
import { getRoleMeta } from './role-meta'; import { getRoleMeta } from './role-meta';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue'; import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue'; import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
import OaAccountModal from '#/views/system/admin/_shared/components/oa-account-modal.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
function formatAdminRegion(row: { function formatAdminRegion(row: {
@@ -121,6 +122,20 @@ const openDoctorCard = (row: { doctor_id?: number }) => {
DoctorCardModalApi.open(); DoctorCardModalApi.open();
}; };
/** 员工 OA 账号弹窗:每个员工可配置各平台的 @ 标识 */
const [OaAccountModals, OaAccountModalApi] = useVbenModal({
connectedComponent: OaAccountModal,
});
/** 打开员工 OA 账号弹窗 */
const openOaAccount = (row: { id: number; nick_name?: string; name?: string }) => {
OaAccountModalApi.setData({
id: row.id,
name: row.nick_name || row.name,
});
OaAccountModalApi.open();
};
const gridEvents: VxeGridListeners<any> = { const gridEvents: VxeGridListeners<any> = {
checkboxChange() { checkboxChange() {
const records = gridApi.grid.getCheckboxRecords(); const records = gridApi.grid.getCheckboxRecords();
@@ -210,6 +225,7 @@ const copyToClipboard = async (text: string) => {
<Form /> <Form />
</Modal> </Modal>
<DoctorCardModals /> <DoctorCardModals />
<OaAccountModals />
<Grid> <Grid>
<template #toolbar-buttons> <template #toolbar-buttons>
<TableAction <TableAction
@@ -368,6 +384,13 @@ const copyToClipboard = async (text: string) => {
confirm: resetPasswordApi.bind(null, row.id), confirm: resetPasswordApi.bind(null, row.id),
}, },
}, },
{
label: 'OA 账号',
type: 'link',
icon: 'ant-design:notification-outlined',
size: 'small',
onClick: openOaAccount.bind(null, row),
},
]" ]"
/> />
</template> </template>

View File

@@ -0,0 +1,235 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Empty, message, Spin, Tabs } from 'ant-design-vue';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import {
getAdminOaAccounts,
saveAdminOaAccounts,
} from '#/views/system/admin/_shared/oa-account/api';
/**
* 员工 OA 账号管理弹窗
*
* 用途在员工管理页的「更多操作」中点击「OA 账号」打开,
* 为该员工配置每个平台的 @ 标识(企业微信 userid、钉钉 userid、飞书 open_id 等)。
*
* 设计要点:
* - 每个启用平台一个 TabTab 内可以添加多条账号(一个员工在一个平台可有多个账号)
* - 账号标识和昵称两个输入框account_id 用于 @account_name 仅用于展示
* - 保存时全量覆盖该员工的所有账号(先软删旧的,再插入新的)
*/
const loading = ref(false);
const saving = ref(false);
const platforms = ref<any[]>([]);
const activeTab = ref('');
const adminId = ref(0);
const adminName = ref('');
/** 按平台分组的账号列表:[platform_code => [{account_id, account_name, key}]] */
const accountsByPlatform = ref<Record<string, Array<{ account_id: string; account_name: string; key: number }>>>({});
let keyCounter = 0;
function genKey() {
keyCounter += 1;
return keyCounter;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!adminId.value) {
message.error('员工 ID 不能为空');
return;
}
saving.value = true;
try {
// 提交时去除 key 字段(后端不需要),过滤空 account_id
const payload: Record<string, any[]> = {};
for (const [platformCode, accounts] of Object.entries(accountsByPlatform.value)) {
const filtered = accounts
.filter((a) => a.account_id.trim() !== '')
.map((a) => ({
account_id: a.account_id.trim(),
account_name: a.account_name,
}));
if (filtered.length > 0) {
payload[platformCode] = filtered;
}
}
await saveAdminOaAccounts(adminId.value, payload);
message.success('保存成功');
modalApi.close();
} catch (e: any) {
message.error(e.message || '保存失败');
} finally {
saving.value = false;
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ id: number; name?: string }>();
adminId.value = data?.id || 0;
adminName.value = data?.name || '';
loadData();
}
},
});
/**
* 加载平台列表 + 该员工的已有账号
*/
async function loadData() {
if (!adminId.value) return;
loading.value = true;
try {
const [platformList, accountList] = await Promise.all([
getOaPlatformList(),
getAdminOaAccounts(adminId.value),
]);
const platformRows: any[] = Array.isArray(platformList)
? platformList
: platformList?.data || [];
platforms.value = platformRows.sort(
(a: any, b: any) => (b.sort || 0) - (a.sort || 0),
);
activeTab.value = platforms.value[0]?.platform_code || '';
// 把后端返回的按平台分组的账号填充到表单
const grouped: typeof accountsByPlatform.value = {};
for (const platform of platforms.value) {
const code = platform.platform_code;
const accounts: any[] = (accountList?.[code] || accountList?.data?.[code]) || [];
grouped[code] = accounts.map((a) => ({
account_id: String(a.account_id || ''),
account_name: String(a.account_name || ''),
key: genKey(),
}));
// 至少留一条空行,便于新增
if (grouped[code].length === 0) {
grouped[code] = [{ account_id: '', account_name: '', key: genKey() }];
}
}
accountsByPlatform.value = grouped;
} catch (e: any) {
message.error(e.message || '加载数据失败');
} finally {
loading.value = false;
}
}
/**
* 添加一行账号输入框
*/
function addAccount(platformCode: string) {
if (!accountsByPlatform.value[platformCode]) {
accountsByPlatform.value[platformCode] = [];
}
accountsByPlatform.value[platformCode].push({
account_id: '',
account_name: '',
key: genKey(),
});
}
/**
* 删除一行账号
*/
function removeAccount(platformCode: string, key: number) {
accountsByPlatform.value[platformCode] = (
accountsByPlatform.value[platformCode] || []
).filter((a) => a.key !== key);
}
/**
* 平台编码 → 显示名
*/
function platformName(code: string): string {
return platforms.value.find((p) => p.platform_code === code)?.name || code;
}
/**
* 平台编码 → 该平台 @ 标识的提示文案
*/
function platformTip(code: string): string {
if (code === 'work_wechat') return '企业微信 userid在通讯录中查看组织内唯一';
if (code === 'work_wechat_app')
return '企业微信 userid应用 API @ 群成员可选;员工 @ 主路径仍用手机号)';
if (code === 'dingtalk') return '钉钉 userid管理后台 → 通讯录 → 成员详情)';
if (code === 'feishu') return '飞书 open_idou_ 开头)';
return '该平台的账号标识';
}
</script>
<template>
<Modal
:title="`员工 OA 账号 - ${adminName || `#${adminId}`}`"
class="w-[50%]"
>
<Spin :spinning="loading || saving">
<div v-if="platforms.length === 0 && !loading" class="py-6">
<Empty description="暂无启用的 OA 平台" />
</div>
<Tabs v-else v-model:active-key="activeTab">
<Tabs.TabPane
v-for="platform in platforms"
:key="platform.platform_code"
:tab="platform.name"
>
<div class="oa-account-tab">
<div class="mb-3 text-sm text-gray-500">
{{ platformTip(platform.platform_code) }}
</div>
<div
v-for="account in accountsByPlatform[platform.platform_code] || []"
:key="account.key"
class="mb-2 flex items-center gap-2"
>
<input
v-model="account.account_id"
class="ant-input ant-input-sm flex-1"
placeholder="账号标识(用于 @"
/>
<input
v-model="account.account_name"
class="ant-input ant-input-sm flex-1"
placeholder="账号昵称(仅用于展示,可选)"
/>
<Button
type="link"
size="small"
danger
@click="removeAccount(platform.platform_code, account.key)"
>
删除
</Button>
</div>
<Button
type="dashed"
size="small"
block
@click="addAccount(platform.platform_code)"
>
+ 添加{{ platformName(platform.platform_code) }}账号
</Button>
</div>
</Tabs.TabPane>
</Tabs>
</Spin>
</Modal>
</template>
<style scoped>
.oa-account-tab {
padding: 8px;
max-height: 360px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,23 @@
import { requestClient } from '#/api/request';
/**
* 员工 OA 账号管理 API
* 路由前缀admin-oa-account/
*/
const prefix = 'admin-oa-account/';
/**
* 获取指定员工的所有 OA 账号(按平台分组)
* 返回结构:['work_wechat' => [{id, account_id, account_name}, ...], ...]
*/
export async function getAdminOaAccounts(adminId: number) {
return requestClient.get<any>(`${prefix}list`, { params: { admin_id: adminId } });
}
/**
* 保存员工的 OA 账号配置(全量同步)
* accounts 结构:{ platform_code: [{account_id, account_name}, ...] }
*/
export async function saveAdminOaAccounts(adminId: number, accounts: Record<string, any[]>) {
return requestClient.post<any>(`${prefix}save`, { admin_id: adminId, accounts });
}

View File

@@ -30,7 +30,7 @@ export const modalFormProps: VbenFormProps = {
rules: 'required', rules: 'required',
}, },
{ {
component: 'VbenTextarea', component: 'Textarea',
componentProps: { componentProps: {
placeholder: '请输入内容', placeholder: '请输入内容',
rows: 4, rows: 4,

View File

@@ -0,0 +1,76 @@
import { requestClient } from '#/api/request';
/**
* OA 群聊管理 API
*
* 群聊用于机器人维度的推送分组N:N 绑定),按平台区分。
* 路由前缀oa-chat/
*
* 后端约定:
* - 标准 CRUDlist/detail/create/update/delete由 BaseController 通过反射自动注册
* - 额外接口 listByPlatform按平台分组的群聊列表机器人表单按平台多选时使用
*/
const prefix = 'oa-chat/';
/**
* 分页查询群聊列表
*/
export async function getOaChatList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取群聊详情
*/
export async function getOaChatInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 新增群聊
*/
export async function createOaChat(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/**
* 编辑群聊
*/
export async function updateOaChat(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/**
* 删除群聊(支持批量)
*/
export async function deleteOaChat(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 按平台分组的群聊列表(机器人表单选群聊用)
*
* 返回结构示例:
* {
* work_wechat: [{ id: 1, name: '财务群' }, ...],
* dingtalk: [{ id: 2, name: '运营群' }, ...],
* feishu: [{ id: 3, name: '产品群' }, ...],
* }
*/
export async function getOaChatListByPlatform() {
return requestClient.get<any>(`${prefix}list-by-platform`);
}
/**
* 从企微 92120 同步客户群(按名称匹配本地记录)
*/
export async function syncOaCustomerGroups(data: { platform_code?: string } = {}) {
return requestClient.post<any>(`${prefix}sync-customer-groups`, data);
}
/**
* 调用企微 appchat/create 创建应用群并落库
*/
export async function createOaAppChat(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create-app-chat`, data);
}

View File

@@ -0,0 +1,34 @@
import { requestClient } from '#/api/request';
/**
* 企微组织架构 APIOA 群聊页「员工」Tab
* 路由前缀oa-ww-org/
*/
const prefix = 'oa-ww-org/';
/**
* 从企微同步组织架构(部门 + 成员)
*/
export async function syncOaWwOrg(data: { platform_code?: string } = {}) {
return requestClient.post<any>(`${prefix}sync-org`, data);
}
/**
* 本地部门树antd Tree
*/
export async function getOaWwDeptTree(params: { platform_code?: string } = {}) {
return requestClient.get<any>(`${prefix}department-tree`, { params });
}
/**
* 员工分页列表
*/
export async function getOaWwUserList(params: {
platform_code?: string;
dept_id?: number;
keyword?: string;
page?: number;
pageSize?: number;
}) {
return requestClient.get<any>(`${prefix}user-list`, { params });
}

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
/**
* 创建企微应用群聊弹窗
* 调 appchat/create成功后回写本地 xk_oa_chatchat_kind=应用群source=接口创建)
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createOaAppChat } from '../api';
const gridApi = ref();
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'name',
label: '群名称',
rules: 'required',
componentProps: { placeholder: '应用群聊名称' },
},
{
component: 'VbenInput',
fieldName: 'owner',
label: '群主 userid',
rules: 'required',
componentProps: { placeholder: '企微成员 userid' },
},
{
component: 'Select',
fieldName: 'userlist',
label: '成员 userid',
rules: 'selectRequired',
help: '至少 2 人(含群主);可输入后回车添加',
componentProps: {
mode: 'tags',
placeholder: '输入 userid 后回车',
tokenSeparators: [',', ' '],
},
},
{
component: 'VbenInput',
fieldName: 'chatid',
label: '指定 chatid',
help: '选填;不填则由企微自动生成',
componentProps: { placeholder: '选填' },
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const userlist = Array.isArray(values.userlist)
? values.userlist
: String(values.userlist || '')
.split(/[,\s]+/)
.filter(Boolean);
modalApi.setState({ loading: true, confirmLoading: true });
try {
await createOaAppChat({
platform_code: 'work_wechat_app',
name: values.name,
owner: values.owner,
userlist,
chatid: values.chatid || '',
});
message.success('应用群创建成功');
gridApi.value?.reload?.() ?? gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
formApi.resetForm();
}
},
});
</script>
<template>
<Modal title="创建应用群聊" class="w-[40%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,244 @@
<script lang="ts" setup>
/**
* OA 群聊页「员工」面板
* 左侧企微部门树自动分类,右侧员工列表;支持同步组织架构与关键词搜索
*/
import { onMounted, ref } from 'vue';
import {
Button,
Empty,
InputSearch,
message,
Pagination,
Spin,
Table,
Tag,
Tree,
} from 'ant-design-vue';
import {
getOaWwDeptTree,
getOaWwUserList,
syncOaWwOrg,
} from '../api/org';
const syncLoading = ref(false);
const treeLoading = ref(false);
const tableLoading = ref(false);
const treeData = ref<any[]>([]);
const selectedDeptId = ref<number | null>(null);
const keyword = ref('');
const users = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
/**
* 同步企微组织架构
*/
async function handleSync() {
syncLoading.value = true;
try {
const res = await syncOaWwOrg({ platform_code: 'work_wechat_app' });
const data = res?.data ?? res ?? {};
message.success(
`同步完成:部门 ${data.dept_count ?? 0},成员 ${data.user_count ?? 0}`,
);
selectedDeptId.value = null;
page.value = 1;
await Promise.all([loadTree(), loadUsers()]);
} catch (e: any) {
message.error(e?.message || '同步失败,请确认平台凭证与通讯录权限');
} finally {
syncLoading.value = false;
}
}
/**
* 加载部门树
*/
async function loadTree() {
treeLoading.value = true;
try {
const res = await getOaWwDeptTree({ platform_code: 'work_wechat_app' });
const data = res?.data ?? res ?? [];
treeData.value = Array.isArray(data) ? data : [];
} catch (e) {
console.error(e);
treeData.value = [];
} finally {
treeLoading.value = false;
}
}
/**
* 加载员工列表
*/
async function loadUsers() {
tableLoading.value = true;
try {
const res = await getOaWwUserList({
platform_code: 'work_wechat_app',
dept_id: selectedDeptId.value || 0,
keyword: keyword.value.trim(),
page: page.value,
pageSize: pageSize.value,
});
const data = res?.data ?? res ?? {};
users.value = Array.isArray(data.items) ? data.items : [];
total.value = Number(data.total || 0);
} catch (e) {
console.error(e);
users.value = [];
total.value = 0;
} finally {
tableLoading.value = false;
}
}
/**
* 选中部门节点
*/
function onSelectDept(keys: (string | number)[]) {
const key = keys[0];
selectedDeptId.value = key !== undefined && key !== null ? Number(key) : null;
page.value = 1;
loadUsers();
}
function onSearch() {
page.value = 1;
loadUsers();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
loadUsers();
}
const columns = [
{ title: 'userid', dataIndex: 'userid', key: 'userid', width: 140 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '手机号', dataIndex: 'mobile', key: 'mobile', width: 120 },
{
title: '主部门',
dataIndex: 'main_department_name',
key: 'main_department_name',
width: 140,
},
{ title: '职位', dataIndex: 'position', key: 'position', width: 120 },
{
title: '匹配系统员工',
key: 'matched',
width: 140,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
},
];
function statusLabel(status: number) {
const map: Record<number, string> = {
1: '已激活',
2: '已禁用',
4: '未激活',
5: '退出企业',
};
return map[status] || String(status);
}
onMounted(async () => {
await loadTree();
await loadUsers();
});
</script>
<template>
<div class="employee-panel flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<Button type="primary" :loading="syncLoading" @click="handleSync">
同步组织架构
</Button>
<InputSearch
v-model:value="keyword"
allow-clear
placeholder="搜索姓名 / userid / 手机号"
class="w-[280px]"
@search="onSearch"
/>
<span class="text-xs text-gray-400">
需平台凭证且应用已开通通讯录读权限匹配仅展示不自动绑定
</span>
</div>
<div class="flex gap-3">
<!-- 左侧部门树固定最大高度内部滚动避免撑高整页 -->
<aside
class="flex w-[260px] shrink-0 flex-col rounded-lg border border-border bg-card p-3"
style="max-height: 560px"
>
<div class="mb-2 text-sm font-medium">组织架构</div>
<Spin :spinning="treeLoading">
<div class="overflow-auto" style="max-height: 500px">
<Empty
v-if="!treeLoading && treeData.length === 0"
description="暂无部门,请先同步"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
/>
<Tree
v-else
:tree-data="treeData"
:selected-keys="
selectedDeptId !== null ? [String(selectedDeptId)] : []
"
default-expand-all
@select="onSelectDept"
/>
</div>
</Spin>
</aside>
<!-- 右侧员工表不设自适应高度内容自然撑开 + 横向滚动 -->
<div class="min-w-0 flex-1 rounded-lg border border-border bg-card p-3">
<Table
size="small"
:columns="columns"
:data-source="users"
:loading="tableLoading"
:pagination="false"
row-key="id"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'matched'">
<Tag v-if="record.matched_admin_id" color="success">
{{ record.matched_admin_name || `#${record.matched_admin_id}` }}
</Tag>
<span v-else class="text-gray-400">未匹配</span>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="record.status === 1 ? 'success' : 'default'">
{{ statusLabel(Number(record.status)) }}
</Tag>
</template>
</template>
</Table>
<div class="mt-3 flex justify-end">
<Pagination
:current="page"
:page-size="pageSize"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createOaChat,
updateOaChat,
} from '#/views/system/oa-chat/api';
import { modalFormProps } from '#/views/system/oa-chat/config/form';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
/**
* OA 群聊新增/编辑弹窗
*
* 关键交互:
* - 打开弹窗时动态拉取平台选项注入 platform_code 单选按钮组
* - 已禁用平台在 label 上加后缀标记并禁用选择(保持与机器人弹窗一致)
* - external_id / sort / status 为选填字段,留空时由后端取默认值
*/
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateOaChat : createOaChat;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
isUpdate.value = update;
formApi.setValues(values);
}
// 每次打开都重新拉取平台列表,保证平台启用状态最新
loadPlatformOptions();
}
},
});
/**
* 拉取平台列表并注入到表单的 platform_code 单选按钮组
* - 显示全部平台(含已禁用的,便于历史数据回显)
* - 已禁用平台在 label 上加后缀标记并禁用选择
*/
async function loadPlatformOptions() {
try {
const list = await getOaPlatformList();
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
const options = rows.map((item) => ({
label: item.enabled === 1 ? item.name : `${item.name}(已禁用)`,
value: item.platform_code,
disabled: item.enabled !== 1,
}));
formApi.updateSchema([
{
fieldName: 'platform_code',
componentProps: { options },
},
]);
} catch (e) {
console.error('加载平台列表失败', e);
}
}
</script>
<template>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}群聊`" class="w-[40%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,108 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 群聊新增/编辑弹窗表单配置
*
* 关键字段:
* - platform_code所属平台RadioGroup 单选,选项由弹窗组件 onOpenChange 时动态注入)
* - name群聊名称用户可读便于识别
* - external_id平台原生群聊 ID选填预留后期接入获取群成员接口时使用
* - sort排序值数字越大越靠前
* - status启用/禁用
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
// 平台数量有限(企业微信/钉钉/飞书),用单选按钮比下拉更直观
component: 'RadioGroup',
componentProps: {
// 选项由弹窗组件 onOpenChange 时通过 formApi.updateSchema 动态注入
options: [],
},
fieldName: 'platform_code',
label: '所属平台',
rules: 'selectRequired',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入群聊名称(便于识别,如:财务群-企业微信)',
},
fieldName: 'name',
label: '群聊名称',
rules: 'required',
},
{
// 1=应用群聊(实时 appchat) 2=客户群(群发需确认)
component: 'RadioGroup',
componentProps: {
options: [
{ label: '客户群', value: 2 },
{ label: '应用群聊', value: 1 },
],
},
defaultValue: 2,
fieldName: 'chat_kind',
label: '群类型',
rules: 'selectRequired',
help: '客户群走群发任务(需员工确认);应用群聊实时推送',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '企微 chat_id / 客户群 chat_id同步后会自动回填',
},
fieldName: 'external_id',
label: '平台原生群聊 ID',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '客户群群主 userid群发时可作 sender 兜底',
},
fieldName: 'owner_userid',
label: '群主 userid',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '数字越大越靠前',
min: 0,
},
defaultValue: 0,
fieldName: 'sort',
label: '排序',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
defaultValue: 1,
fieldName: 'status',
label: '状态',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,52 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 群聊搜索表单配置
*
* 关键字段:
* - platform_code平台编码精确匹配
* - name群聊名称模糊匹配
* - status启用/禁用
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入群聊名称',
},
defaultValue: '',
fieldName: 'name',
label: '群聊名称',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '输入平台编码',
},
defaultValue: '',
fieldName: 'platform_code',
label: '平台编码',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '选择状态',
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
defaultValue: '',
fieldName: 'status',
label: '状态',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,96 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getOaChatList } from '#/views/system/oa-chat/api';
interface RowType {
id: number;
platform_code: string;
name: string;
external_id: string;
icon: string;
sort: number;
status: number;
created_at: string;
}
/**
* 群聊列表表格配置
* 列表展示ID、平台slot 渲染中文名)、群聊名称、外部 ID、排序、状态、创建时间、操作
*/
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{
field: 'platform_code',
align: 'left',
title: '平台',
width: 120,
slots: { default: 'platform_code' },
},
{ field: 'name', align: 'left', title: '群聊名称' },
{
field: 'chat_kind',
align: 'left',
title: '群类型',
width: 110,
slots: { default: 'chat_kind' },
},
{ field: 'external_id', align: 'left', title: '外部群 ID', width: 180 },
{ field: 'owner_userid', align: 'left', title: '群主', width: 120 },
{ field: 'sort', align: 'left', title: '排序', width: 80 },
{
field: 'status',
align: 'left',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'created_at', align: 'left', title: '创建时间', width: 180 },
{
type: 'html',
title: '操作',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOaChatList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
/**
* OA 群聊管理页
* Tab员工组织架构| 群聊localStorage 记住上次选中的 Tab
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tabs, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import { deleteOaChat, syncOaCustomerGroups } from './api';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import FormModalDemo from './components/modal.vue';
import CreateAppChatModalDemo from './components/create-app-chat-modal.vue';
import EmployeePanel from './components/employee-panel.vue';
defineOptions({ name: 'OaChat' });
/** Tab 记忆employee=员工 chat=群聊 */
const TAB_STORAGE_KEY = 'oa_chat_active_tab';
const activeKey = ref('chat');
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored === 'employee' || stored === 'chat') {
activeKey.value = stored;
}
}
function handleTabChange(key: string | number) {
activeKey.value = String(key);
localStorage.setItem(TAB_STORAGE_KEY, String(key));
}
const hasTopTableDropDownActions = ref(false);
const syncLoading = ref(false);
const platformMap = ref<Record<string, any>>({});
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents,
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
const [CreateAppChatModal, createAppChatModalApi] = useVbenModal({
connectedComponent: CreateAppChatModalDemo,
});
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const showCreateAppChat = () => {
createAppChatModalApi.setData({ gridApi });
createAppChatModalApi.open();
};
/**
* 同步企微客户群92120
*/
const handleSyncCustomerGroups = async () => {
syncLoading.value = true;
try {
const res = await syncOaCustomerGroups({ platform_code: 'work_wechat_app' });
const data = res?.data ?? res ?? {};
message.success(
`同步完成:匹配 ${data.matched ?? 0},新建 ${data.created ?? 0},更新 ${data.updated ?? 0},跳过 ${data.skipped ?? 0}`,
);
gridApi.query();
} catch (e: any) {
message.error(e?.message || '同步失败');
} finally {
syncLoading.value = false;
}
};
const deleteApi = (row: any) => {
let ids: any[] = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
}
deleteOaChat({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
async function loadPlatforms() {
try {
const list = await getOaPlatformList();
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
platformMap.value = rows.reduce(
(acc, item) => {
acc[item.platform_code] = item;
return acc;
},
{} as Record<string, any>,
);
} catch (e) {
console.error('加载平台列表失败', e);
}
}
onMounted(() => {
readStoredTab();
loadPlatforms();
});
</script>
<template>
<Page title="OA 群聊管理">
<FormModal />
<CreateAppChatModal />
<Tabs :active-key="activeKey" @change="handleTabChange">
<Tabs.TabPane key="employee" tab="员工">
<EmployeePanel v-if="activeKey === 'employee'" />
</Tabs.TabPane>
<Tabs.TabPane key="chat" tab="群聊">
<Grid v-if="activeKey === 'chat'">
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: showModal.bind(null),
},
{
label: '同步客户群',
type: 'default',
icon: 'ant-design:cloud-sync-outlined',
loading: syncLoading,
onClick: handleSyncCustomerGroups,
},
{
label: '创建应用群',
type: 'default',
icon: 'ant-design:usergroup-add-outlined',
onClick: showCreateAppChat,
},
]"
:drop-down-actions="[
{
label: '批量删除',
icon: 'ant-design:delete-outlined',
ifShow: hasTopTableDropDownActions,
popConfirm: {
title: '确定删除吗',
confirm: deleteApi.bind(null, false),
},
},
]"
/>
</template>
<template #platform_code="{ row }">
<Tag v-if="platformMap[row.platform_code]" color="processing">
{{ platformMap[row.platform_code].name }}
</Tag>
<span v-else>{{ row.platform_code }}</span>
</template>
<template #chat_kind="{ row }">
<Tag :color="row.chat_kind === 1 ? 'blue' : 'orange'">
{{ row.chat_kind === 1 ? '应用群聊' : '客户群' }}
</Tag>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status === 1 ? '启用' : '禁用' }}
</Tag>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
onClick: showModal.bind(null, row, true),
},
{
label: '删除',
type: 'link',
icon: 'ant-design:delete-outlined',
size: 'small',
popConfirm: {
title: '确定删除吗?',
confirm: deleteApi.bind(null, row.id),
},
},
]"
/>
</template>
</Grid>
</Tabs.TabPane>
</Tabs>
</Page>
</template>

View File

@@ -0,0 +1,30 @@
import { requestClient } from '#/api/request';
/**
* OA 定时任务管理 API
*
* 仅 list / detail / update预置 3 条任务不可新增删除。
* 路由前缀oa-cron-task/
*/
const prefix = 'oa-cron-task/';
/**
* 分页查询定时任务列表
*/
export async function getOaCronTaskList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取定时任务详情
*/
export async function getOaCronTaskInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 更新启停 / 封面 / 跳转 / active_types / preset_payloads / 投递对象等
*/
export async function updateOaCronTask(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}

View File

@@ -0,0 +1,347 @@
<script lang="ts" setup>
/**
* OA 定时任务「消息配置」弹窗
* 平台 Tab → 消息类型 Radio → 复用场景 PayloadSchemaForm含 GalleryPickLink acceptTypes
* 保存 active_types + 各平台当前类型的 preset_payloads物化到场景
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Radio, RadioGroup, Tabs, message } from 'ant-design-vue';
import {
getOaCronTaskInfo,
updateOaCronTask,
} from '#/views/system/oa-cron-task/api';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import { getOaMessageTypes } from '#/views/system/oa-scene/api';
import MsgPreview from '#/views/system/oa-scene/components/msg-preview.vue';
import PayloadSchemaForm, {
type PayloadFieldSchema,
} from '#/views/system/oa-scene/components/payload-schema-form.vue';
interface MessageTypeOption {
message_type: string;
name: string;
icon?: string;
need_media?: number;
payload_schema: string | null;
}
const gridApi = ref();
const loading = ref(false);
const detail = ref<Record<string, any>>({});
/** 各平台当前激活的消息类型 */
const activeTypes = ref<Record<string, string>>({});
/** presets[platform][type] = { payload, field_bindings } */
const presets = ref<Record<string, Record<string, any>>>({});
/** 本会话编辑中的 payload按平台对应当前 active 类型) */
const payloadByPlatform = ref<Record<string, Record<string, any>>>({});
const platforms = ref<
Array<{ platform_code: string; name: string; icon?: string }>
>([]);
const activePlatform = ref('');
/** message-types 接口platform → 类型列表(含 schema */
const messageTypesMap = ref<Record<string, MessageTypeOption[]>>({});
/**
* 当前平台可选消息类型presets 已有类型 ∩ 接口返回类型
*/
function typeOptionsOf(platformCode: string) {
const presetKeys = Object.keys(presets.value?.[platformCode] || {});
const apiTypes = messageTypesMap.value[platformCode] || [];
const nameMap = Object.fromEntries(
apiTypes.map((t) => [t.message_type, t.name]),
);
return presetKeys.map((t) => ({
label: nameMap[t] || t,
value: t,
}));
}
/**
* 解析当前平台当前类型的 payload_schema
*/
function currentPayloadSchema(platformCode: string): PayloadFieldSchema[] {
const messageType = activeTypes.value[platformCode];
if (!messageType) return [];
const types = messageTypesMap.value[platformCode] || [];
const typeInfo = types.find((t) => t.message_type === messageType);
if (!typeInfo?.payload_schema) return [];
try {
const parsed = JSON.parse(typeInfo.payload_schema);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** 当前类型 field_bindings 只读提示 */
const currentBindings = computed(() => {
const p = activePlatform.value;
const t = activeTypes.value[p] || '';
const raw = presets.value?.[p]?.[t]?.field_bindings || {};
return Object.entries(raw).map(([path, key]) => ({
path: String(path),
key: String(key),
}));
});
/**
* 切换消息类型:只改 active_types并把编辑区切到该类型已有 payload不丢其它类型 presets
*/
function handleTypeChange(platformCode: string, messageType: string) {
if (!platformCode || !messageType) return;
// 先把当前编辑区写回 presets避免切换丢改
flushPayloadToPresets(platformCode);
activeTypes.value = {
...activeTypes.value,
[platformCode]: messageType,
};
const existing =
presets.value?.[platformCode]?.[messageType]?.payload || {};
payloadByPlatform.value = {
...payloadByPlatform.value,
[platformCode]: { ...existing },
};
}
/**
* 把某平台编辑中的 payload 写回 presets[platform][activeType]
*/
function flushPayloadToPresets(platformCode: string) {
const type = activeTypes.value[platformCode];
if (!platformCode || !type) return;
const payload = payloadByPlatform.value[platformCode] || {};
const platformPresets = { ...(presets.value[platformCode] || {}) };
const prev = { ...(platformPresets[type] || {}) };
platformPresets[type] = {
...prev,
payload: { ...payload },
};
presets.value = {
...presets.value,
[platformCode]: platformPresets,
};
}
/**
* 打开时加载详情、平台、场景 message-types含 payload_schema
*/
async function loadAll(id: number) {
loading.value = true;
try {
const [info, platformList, typesRes] = await Promise.all([
getOaCronTaskInfo(id),
getOaPlatformList(),
getOaMessageTypes(),
]);
detail.value = info || {};
activeTypes.value = { ...(info?.active_types || {}) };
presets.value = { ...(info?.presets || {}) };
const typeMap =
typesRes && typeof typesRes === 'object' && !Array.isArray(typesRes)
? typesRes
: typesRes?.data || {};
messageTypesMap.value = typeMap as Record<string, MessageTypeOption[]>;
const rows = Array.isArray(platformList)
? platformList
: platformList?.data || [];
platforms.value = (Array.isArray(rows) ? rows : [])
.filter((p: any) => info?.presets?.[p.platform_code])
.map((p: any) => ({
platform_code: p.platform_code,
name: p.name,
icon: p.icon,
}));
activePlatform.value = platforms.value[0]?.platform_code || '';
// 初始化各平台编辑区为当前 active 类型的 payload
const payloads: Record<string, Record<string, any>> = {};
for (const p of platforms.value) {
const code = p.platform_code;
let type = activeTypes.value[code] || '';
const presetKeys = Object.keys(presets.value[code] || {});
if (!type || !presetKeys.includes(type)) {
type = presetKeys[0] || '';
if (type) {
activeTypes.value = { ...activeTypes.value, [code]: type };
}
}
payloads[code] = {
...(presets.value?.[code]?.[type]?.payload || {}),
};
}
payloadByPlatform.value = payloads;
} finally {
loading.value = false;
}
}
/**
* 预览用 payloadtemplate_card 需展开)
*/
function previewPayloadOf(platformCode: string): Record<string, any> {
const messageType = activeTypes.value[platformCode];
const payload = { ...(payloadByPlatform.value[platformCode] || {}) };
if (messageType === 'template_card' && payload.template_card) {
return payload.template_card;
}
return payload;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[920px]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const id = detail.value?.id;
if (!id) return;
// 提交前把当前 Tab 编辑区写回
for (const p of platforms.value) {
flushPayloadToPresets(p.platform_code);
}
const presetPayloads: Record<
string,
{ message_type: string; payload: Record<string, any> }
> = {};
for (const p of platforms.value) {
const code = p.platform_code;
const type = activeTypes.value[code];
if (!type) continue;
presetPayloads[code] = {
message_type: type,
payload: {
...(presets.value?.[code]?.[type]?.payload ||
payloadByPlatform.value[code] ||
{}),
},
};
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateOaCronTask({
id,
active_types: activeTypes.value,
preset_payloads: presetPayloads,
});
message.success('消息配置已保存');
gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ id?: number; gridApi?: any }>();
gridApi.value = data?.gridApi;
if (data?.id) {
await loadAll(data.id);
}
},
});
</script>
<template>
<Modal :title="`消息配置:${detail.name || ''}`">
<div v-if="loading" class="py-8 text-center text-gray-400">加载中</div>
<div v-else class="cron-msg-config space-y-4">
<Alert
type="info"
show-icon
message="按平台选择消息类型并编辑发送内容(与 OA 场景同一套 payload_schema。定时命令仍用 field_bindings 与 {var} 填充动态字段。"
/>
<Tabs v-if="platforms.length" v-model:active-key="activePlatform" type="card">
<Tabs.TabPane
v-for="p in platforms"
:key="p.platform_code"
:tab="p.name"
>
<div class="mb-3">
<div class="mb-2 text-sm text-gray-500">消息类型</div>
<RadioGroup
:value="activeTypes[p.platform_code]"
button-style="solid"
@update:value="(v) => handleTypeChange(p.platform_code, String(v))"
>
<Radio.Button
v-for="opt in typeOptionsOf(p.platform_code)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Radio.Button>
</RadioGroup>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<div class="mb-2 font-medium">发送内容</div>
<PayloadSchemaForm
:schema="currentPayloadSchema(p.platform_code)"
:model-value="payloadByPlatform[p.platform_code] || {}"
@update:model-value="
(v) => {
payloadByPlatform = {
...payloadByPlatform,
[p.platform_code]: v || {},
};
}
"
/>
</div>
<div>
<div class="mb-2 font-medium">预览</div>
<MsgPreview
:platform-code="p.platform_code"
:message-type="activeTypes[p.platform_code] || ''"
:payload="previewPayloadOf(p.platform_code)"
/>
<div class="mt-3">
<div class="mb-1 text-sm font-medium">
可替换变量field_bindings
</div>
<div
v-if="
p.platform_code === activePlatform &&
currentBindings.length === 0
"
class="text-xs text-gray-400"
>
当前类型无字段绑定
</div>
<ul
v-else-if="p.platform_code === activePlatform"
class="m-0 list-disc pl-4 text-xs text-gray-600"
>
<li v-for="b in currentBindings" :key="b.path">
<code>{{ b.key }}</code>
← {{ b.path.replace(/^.*\.payload\./, 'payload.') }}
</li>
</ul>
<div class="mt-1 text-xs text-gray-400">
payload 字符串中也可写
<code>{{ '{count}' }}</code>
/
<code>{{ '{content}' }}</code>
等模板,定时任务传对应 vars 即可替换
</div>
</div>
</div>
</div>
</Tabs.TabPane>
</Tabs>
<div v-else class="py-6 text-center text-gray-400">
当前任务暂无平台 presets请先确认种子数据已写入
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,146 @@
<script lang="ts" setup>
/**
* OA 定时任务「编辑」弹窗:只改任务基础信息
* 启停 / 全局封面 / 全局跳转 / 备注;消息类型与正文改由「消息配置」列独立弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Checkbox, Input, Switch, Textarea, message } from 'ant-design-vue';
import UploadImage from '#/components/form/components/upload-image.vue';
import {
getOaCronTaskInfo,
updateOaCronTask,
} from '#/views/system/oa-cron-task/api';
const gridApi = ref();
const loading = ref(false);
const detail = ref<Record<string, any>>({});
const enabled = ref(false);
const cardImageUrls = ref<string[]>([]);
const jumpUrl = ref('');
const syncCardImage = ref(true);
const syncJumpUrl = ref(true);
const remark = ref('');
/**
* 打开时加载任务基础字段(不含消息类型/payload
*/
async function loadAll(id: number) {
loading.value = true;
try {
const info = await getOaCronTaskInfo(id);
detail.value = info || {};
enabled.value = Number(info?.enabled) === 1;
const cover = String(info?.card_image_url || '');
cardImageUrls.value = cover ? [cover] : [];
jumpUrl.value = String(info?.jump_url || '');
remark.value = String(info?.remark || '');
} finally {
loading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[560px]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const id = detail.value?.id;
if (!id) return;
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 不传 active_types / preset_payloads / robot_ids避免误改其它分弹窗配置
await updateOaCronTask({
id,
enabled: enabled.value ? 1 : 0,
card_image_url: cardImageUrls.value[0] || '',
sync_card_image: syncCardImage.value ? 1 : 0,
jump_url: jumpUrl.value || '',
sync_jump_url: syncJumpUrl.value ? 1 : 0,
remark: remark.value || '',
});
message.success('保存成功');
gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ id?: number; gridApi?: any }>();
gridApi.value = data?.gridApi;
if (data?.id) {
await loadAll(data.id);
}
},
});
</script>
<template>
<Modal :title="`编辑任务:${detail.name || ''}`">
<div v-if="loading" class="py-8 text-center text-gray-400">加载中</div>
<div v-else class="cron-edit space-y-4">
<Alert
v-if="Number(detail.type) === 2"
type="warning"
show-icon
message="主动触发任务:业务事件发生时推送(如申请提现成功)。启停控制是否推送;消息/机器人/发送人请在列表列配置。"
/>
<Alert
v-else
type="info"
show-icon
message="此处只改启停、封面、跳转与备注。消息类型与发送内容请在列表「消息配置」列中编辑。"
/>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span>启停</span>
<Switch
v-model:checked="enabled"
checked-children=""
un-checked-children=""
/>
</div>
<div class="text-gray-500">编码{{ detail.task_code }}</div>
</div>
<div>
<div class="mb-2 font-medium">全局封面图</div>
<UploadImage v-model="cardImageUrls" :max-count="1" :multiple="false" />
<Checkbox v-model:checked="syncCardImage" class="mt-2">
保存时同步到全部消息类型的图片字段不改链接
</Checkbox>
</div>
<div>
<div class="mb-2 font-medium">全局跳转链接</div>
<Input
v-model:value="jumpUrl"
placeholder="卡片/详情跳转 URL与封面图分存"
allow-clear
/>
<Checkbox v-model:checked="syncJumpUrl" class="mt-2">
保存时同步到全部消息类型的链接字段不改图片
</Checkbox>
</div>
<div>
<div class="mb-2 font-medium">备注</div>
<Textarea
v-model:value="remark"
:rows="3"
placeholder="可选备注"
allow-clear
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
/**
* OA 定时任务 — 配置推送机器人弹窗
* 从列表「机器人」列打开;仅保存 robot_ids不改消息类型/图片
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Checkbox, Tabs, message } from 'ant-design-vue';
import {
getOaCronTaskInfo,
updateOaCronTask,
} from '#/views/system/oa-cron-task/api';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import { getOaRobotList } from '#/views/system/oa-robot/api';
const gridApi = ref();
const loading = ref(false);
const detail = ref<Record<string, any>>({});
const robotIds = ref<number[]>([]);
const platforms = ref<Array<{ platform_code: string; name: string }>>([]);
const robotsByPlatform = ref<Record<string, Array<{ id: number; name: string }>>>({});
const activePlatform = ref('');
/**
* 打开时加载任务已绑机器人与全量机器人列表
*/
async function loadAll(id: number) {
loading.value = true;
try {
const [info, platformList, robotRes] = await Promise.all([
getOaCronTaskInfo(id),
getOaPlatformList(),
getOaRobotList({ page: 1, pageSize: 500 }),
]);
detail.value = info || {};
robotIds.value = Array.isArray(info?.robot_ids)
? info.robot_ids.map(Number)
: [];
const rows = Array.isArray(platformList)
? platformList
: platformList?.data || [];
platforms.value = (Array.isArray(rows) ? rows : []).map((p: any) => ({
platform_code: p.platform_code,
name: p.name,
}));
if (!activePlatform.value && platforms.value.length) {
activePlatform.value = platforms.value[0].platform_code;
}
const robotItems = robotRes?.items || robotRes?.data || robotRes || [];
const list = Array.isArray(robotItems) ? robotItems : [];
const map: Record<string, Array<{ id: number; name: string }>> = {};
for (const r of list) {
const code = String(r.platform_code || '');
if (!map[code]) map[code] = [];
map[code].push({ id: Number(r.id), name: String(r.name || r.id) });
}
robotsByPlatform.value = map;
} finally {
loading.value = false;
}
}
function toggleRobot(id: number, checked: boolean) {
const set = new Set(robotIds.value);
if (checked) set.add(id);
else set.delete(id);
robotIds.value = [...set];
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[560px]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const id = detail.value?.id;
if (!id) return;
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 只传 robot_ids后端未传 targets 时保留原值
await updateOaCronTask({ id, robot_ids: robotIds.value });
message.success('机器人配置已保存');
gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ id?: number; gridApi?: any }>();
gridApi.value = data?.gridApi;
if (data?.id) {
await loadAll(data.id);
}
},
});
</script>
<template>
<Modal :title="`配置推送机器人:${detail.name || ''}`">
<div v-if="loading" class="py-8 text-center text-gray-400">加载中</div>
<div v-else>
<Tabs v-if="platforms.length" v-model:active-key="activePlatform" type="card">
<Tabs.TabPane
v-for="p in platforms"
:key="p.platform_code"
:tab="p.name"
>
<div
v-if="!(robotsByPlatform[p.platform_code] || []).length"
class="text-sm text-gray-400"
>
该平台暂无机器人请先在OA机器人管理添加
</div>
<div v-else class="flex flex-col gap-1">
<Checkbox
v-for="r in robotsByPlatform[p.platform_code]"
:key="r.id"
:checked="robotIds.includes(r.id)"
@change="(e: any) => toggleRobot(r.id, !!e?.target?.checked)"
>
{{ r.name }}
</Checkbox>
</div>
</Tabs.TabPane>
</Tabs>
<div v-else class="text-sm text-gray-400">暂无平台数据</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,284 @@
<script lang="ts" setup>
/**
* OA 定时任务 — 配置发送用户弹窗(按平台分流)
* - work_wechat_appSceneTargetsPanel个人/客户群 targets
* - 其它 webhook 平台:按平台勾选 @ 员工at_users.admin_ids
* 仅展示当前任务已绑机器人所属平台的 Tab
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Checkbox, Tabs, message } from 'ant-design-vue';
import UserTagSelect from '#/components/form/components/user-tag-select.vue';
import {
getOaCronTaskInfo,
updateOaCronTask,
} from '#/views/system/oa-cron-task/api';
import { getAdminList } from '#/views/system/admin/api';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import { getOaRobotList } from '#/views/system/oa-robot/api';
import SceneTargetsPanel from '#/views/system/oa-scene/components/scene-targets-panel.vue';
type AtSlot = { admin_ids: number[]; userids: string[] };
const gridApi = ref();
const loading = ref(false);
const detail = ref<Record<string, any>>({});
const targets = ref<any[]>([]);
const selectedAtUsers = ref<Record<string, AtSlot>>({});
const adminList = ref<Array<{ id: number; name: string; avatar?: string }>>(
[],
);
const platforms = ref<Array<{ platform_code: string; name: string }>>([]);
const activePlatform = ref('');
/** 已绑机器人所属平台编码(决定展示哪些 Tab */
const boundPlatformCodes = ref<string[]>([]);
const visiblePlatforms = computed(() =>
platforms.value.filter((p) =>
boundPlatformCodes.value.includes(p.platform_code),
),
);
function isWorkWechatApp(code: string) {
return code === 'work_wechat_app';
}
/**
* 规范化详情 at_users兼容旧纯数组
*/
function normalizeAtUsers(
raw: Record<string, any> | undefined,
): Record<string, AtSlot> {
const result: Record<string, AtSlot> = {};
for (const [code, val] of Object.entries(raw || {})) {
if (Array.isArray(val)) {
result[code] = {
admin_ids: val.map((id) => Number(id)).filter((id) => id > 0),
userids: [],
};
} else {
result[code] = {
admin_ids: ((val as any)?.admin_ids || [])
.map((id: any) => Number(id))
.filter((id: number) => id > 0),
userids: ((val as any)?.userids || [])
.map((u: any) => String(u))
.filter((u: string) => u !== ''),
};
}
}
return result;
}
function adminIdsOf(platformCode: string): number[] {
return selectedAtUsers.value[platformCode]?.admin_ids || [];
}
function useridsOf(platformCode: string): string[] {
return selectedAtUsers.value[platformCode]?.userids || [];
}
function setAdminIds(platformCode: string, ids: (string | number)[]) {
selectedAtUsers.value = {
...selectedAtUsers.value,
[platformCode]: {
admin_ids: ids.map((n) => Number(n)).filter((n) => n > 0),
userids: useridsOf(platformCode),
},
};
}
function toggleAllAtUsers(platformCode: string, e: any) {
const checked = !!e?.target?.checked;
setAdminIds(
platformCode,
checked ? adminList.value.map((a) => a.id) : [],
);
}
/**
* 打开时加载投递目标、@ 人、已绑机器人平台
*/
async function loadAll(id: number) {
loading.value = true;
try {
const [info, platformList, robotRes, adminRes] = await Promise.all([
getOaCronTaskInfo(id),
getOaPlatformList(),
getOaRobotList({ page: 1, pageSize: 500 }),
getAdminList({ page: 1, pageSize: 500 }),
]);
detail.value = info || {};
targets.value = Array.isArray(info?.targets) ? [...info.targets] : [];
selectedAtUsers.value = normalizeAtUsers(info?.at_users);
const robotIds = new Set(
(Array.isArray(info?.robot_ids) ? info.robot_ids : []).map(Number),
);
const robotItems = robotRes?.items || robotRes?.data || robotRes || [];
const list = Array.isArray(robotItems) ? robotItems : [];
const codes = new Set<string>();
for (const r of list) {
if (robotIds.has(Number(r.id))) {
const code = String(r.platform_code || '');
if (code) codes.add(code);
}
}
boundPlatformCodes.value = [...codes];
const rows = Array.isArray(platformList)
? platformList
: platformList?.data || [];
platforms.value = (Array.isArray(rows) ? rows : [])
.filter((p: any) => codes.has(String(p.platform_code)))
.map((p: any) => ({
platform_code: p.platform_code,
name: p.name,
}));
// 保证绑定了机器人但平台列表缺失时仍有 Tab
for (const code of codes) {
if (!platforms.value.some((p) => p.platform_code === code)) {
platforms.value.push({ platform_code: code, name: code });
}
}
activePlatform.value = platforms.value[0]?.platform_code || '';
const adminRows = adminRes?.items || adminRes?.data || adminRes || [];
adminList.value = (Array.isArray(adminRows) ? adminRows : []).map(
(a: any) => ({
id: a.id,
name: a.nick_name || a.name || a.username || String(a.id),
avatar: a.avatar,
}),
);
} finally {
loading.value = false;
}
}
/**
* 打包提交用的 at_users仅含有勾选的平台
*/
function buildAtUsersPayload(): Record<string, AtSlot> {
const out: Record<string, AtSlot> = {};
for (const [code, slot] of Object.entries(selectedAtUsers.value)) {
if (isWorkWechatApp(code)) continue;
const adminIds = (slot.admin_ids || []).filter((id) => id > 0);
const userids = (slot.userids || []).filter((u) => u !== '');
if (adminIds.length === 0 && userids.length === 0) continue;
out[code] = { admin_ids: adminIds, userids };
}
return out;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[760px]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const id = detail.value?.id;
if (!id) return;
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 同时保存 targets应用 API与 at_userswebhook @ 人)
await updateOaCronTask({
id,
targets: targets.value,
at_users: buildAtUsersPayload(),
});
message.success('发送用户配置已保存');
gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ id?: number; gridApi?: any }>();
gridApi.value = data?.gridApi;
if (data?.id) {
await loadAll(data.id);
}
},
});
</script>
<template>
<Modal :title="`配置发送用户:${detail.name || ''}`">
<div v-if="loading" class="py-8 text-center text-gray-400">加载中</div>
<div v-else class="space-y-3">
<Alert
type="info"
show-icon
message="按平台分流:企微应用 API 配置投递人/群;群机器人(企微/钉钉/飞书)配置要 @ 的员工。仅展示已绑定机器人的平台。"
/>
<div v-if="visiblePlatforms.length === 0" class="text-sm text-gray-400">
请先在机器人列绑定至少一个推送机器人
</div>
<Tabs
v-else
v-model:active-key="activePlatform"
type="card"
>
<Tabs.TabPane
v-for="p in visiblePlatforms"
:key="p.platform_code"
:tab="p.name"
>
<template v-if="isWorkWechatApp(p.platform_code)">
<div class="mb-2 text-sm text-gray-500">
企微应用 API 投递目标个人 / 客户群
</div>
<SceneTargetsPanel
v-model="targets"
:admin-list="adminList"
platform-code="work_wechat_app"
/>
</template>
<template v-else>
<div class="mb-2 flex items-center justify-between">
<span class="text-sm text-gray-500">
@ 系统员工
<span v-if="p.platform_code === 'feishu'" class="text-orange-500">
飞书 webhook 将解析为 OA open_id
</span>
</span>
<Checkbox
:checked="
adminList.length > 0 &&
adminIdsOf(p.platform_code).length === adminList.length
"
@change="(e: any) => toggleAllAtUsers(p.platform_code, e)"
>
全选
</Checkbox>
</div>
<div v-if="adminList.length === 0" class="text-sm text-gray-400">
暂无员工可选
</div>
<UserTagSelect
v-else
:model-value="adminIdsOf(p.platform_code)"
:options="
adminList.map((a) => ({
value: a.id,
label: a.name,
avatar: a.avatar || '',
}))
"
placeholder="选择要 @ 的员工(可多选)"
@update:model-value="(v) => setAdminIds(p.platform_code, v)"
/>
</template>
</Tabs.TabPane>
</Tabs>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,98 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 编辑弹窗表单(遗留 schema实际编辑走 components/modal.vue 自定义布局)
* task_code / name 只读;封面图与跳转链接分存
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-1',
commonConfig: {
formItemClass: 'col-span-1',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
component: 'VbenInput',
fieldName: 'name',
label: '任务名称',
componentProps: {
disabled: true,
},
},
{
component: 'VbenInput',
fieldName: 'task_code',
label: '任务编码',
componentProps: {
disabled: true,
},
help: '与 Artisan 命令绑定,不可修改',
},
{
component: 'VbenInput',
fieldName: 'scene_code',
label: '场景编码',
rules: 'required',
help: '对应 OA 场景 scene_code推送走 OaNotifyService.dispatch',
componentProps: {
placeholder: '如 unpaid_unshipped_hourly',
disabled: true,
},
},
{
component: 'RadioGroup',
fieldName: 'enabled',
label: '启停',
rules: 'selectRequired',
defaultValue: 0,
componentProps: {
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
{
component: 'UploadImage',
fieldName: 'card_image_urls',
label: '卡片图',
help: '作为 vars.card_image与 jump_url 分存',
componentProps: {
maxCount: 1,
multiple: false,
acceptTypes: [1],
},
},
{
component: 'VbenInput',
fieldName: 'jump_url',
label: '跳转链接',
help: '作为 vars.jump_url与 card_image_url 分存',
componentProps: {
placeholder: 'https://...',
},
},
{
component: 'Textarea',
fieldName: 'remark',
label: '备注',
componentProps: {
rows: 3,
placeholder: 'crontab 建议等说明',
},
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,46 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 列表顶部搜索表单
* 按任务名 / 编码 / 启停筛选,方便运维定位
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '任务名称',
},
fieldName: 'name',
label: '任务名称',
},
{
component: 'VbenInput',
componentProps: {
placeholder: 'task_code',
},
fieldName: 'task_code',
label: '任务编码',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
placeholder: '启停状态',
},
fieldName: 'enabled',
label: '启停',
},
],
showCollapseButton: false,
submitButtonOptions: {
content: '查询',
},
submitOnChange: false,
submitOnEnter: true,
};

View File

@@ -0,0 +1,117 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getOaCronTaskList } from '#/views/system/oa-cron-task/api';
interface RowType {
id: number;
task_code: string;
name: string;
enabled: number;
/** 1定时 2主动触发 */
type: number;
scene_code: string;
card_image_url: string;
jump_url: string;
active_types_summary: string;
robot_names: string;
target_summary: string;
remark: string;
created_at: string;
updated_at: string;
}
/**
* OA 推送任务列表表格(定时 + 主动触发)
* 启停用 Switch消息配置/机器人/发送用户列可点击打开独立配置弹窗;卡片图缩略图展示
*/
export const gridOptions: VxeGridProps<RowType> = {
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 70 },
{ field: 'name', align: 'left', title: '任务名称', minWidth: 160 },
{ field: 'task_code', align: 'left', title: '任务编码', minWidth: 180 },
{
field: 'type',
align: 'left',
title: '触发方式',
width: 110,
slots: { default: 'task_type' },
},
{
field: 'enabled',
align: 'left',
title: '启停',
width: 100,
slots: { default: 'enabled' },
},
{ field: 'scene_code', align: 'left', title: '场景编码', minWidth: 160 },
{
field: 'card_image_url',
align: 'left',
title: '卡片图',
width: 100,
slots: { default: 'card_image' },
},
{
field: 'active_types_summary',
align: 'left',
title: '消息配置',
minWidth: 180,
slots: { default: 'message_config' },
},
{
field: 'robot_names',
align: 'left',
title: '机器人',
minWidth: 140,
slots: { default: 'robots' },
},
{
field: 'target_summary',
align: 'left',
title: '发送用户',
minWidth: 120,
slots: { default: 'send_users' },
},
{ field: 'remark', align: 'left', title: '备注', minWidth: 180 },
{ field: 'updated_at', align: 'left', title: '更新时间', width: 180 },
{
type: 'html',
title: '操作',
slots: { default: 'action' },
width: 100,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOaCronTaskList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
/**
* OA 推送任务管理页(定时 crontab + 主动触发如提现)
* 列表 Switch 启停;列点击配置消息/机器人/发送用户;编辑弹窗只管任务基础信息
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, Switch, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { updateOaCronTask } from './api';
import FormModalDemo from './components/modal.vue';
import MessageConfigModalDemo from './components/message-config-modal.vue';
import RobotsModalDemo from './components/robots-modal.vue';
import TargetsModalDemo from './components/targets-modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'OaCronTask' });
const gridEvents: VxeGridListeners<any> = {};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents,
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
const [MessageConfigModal, messageConfigModalApi] = useVbenModal({
connectedComponent: MessageConfigModalDemo,
});
const [RobotsModal, robotsModalApi] = useVbenModal({
connectedComponent: RobotsModalDemo,
});
const [TargetsModal, targetsModalApi] = useVbenModal({
connectedComponent: TargetsModalDemo,
});
/**
* 打开任务基础信息编辑(启停/封面/跳转/备注)
*/
function openEdit(row: Record<string, any>) {
formModalApi.setData({ id: row.id, gridApi });
formModalApi.open();
}
/**
* 打开消息配置(类型 + payload_schema 内容)
*/
function openMessageConfig(row: Record<string, any>) {
messageConfigModalApi.setData({ id: row.id, gridApi });
messageConfigModalApi.open();
}
/**
* 打开配置机器人弹窗
*/
function openRobots(row: Record<string, any>) {
robotsModalApi.setData({ id: row.id, gridApi });
robotsModalApi.open();
}
/**
* 打开配置发送用户弹窗
*/
function openTargets(row: Record<string, any>) {
targetsModalApi.setData({ id: row.id, gridApi });
targetsModalApi.open();
}
/**
* 列表行内切换启停(立即落库,避免依赖保存按钮)
*/
async function handleEnabledChange(
row: Record<string, any>,
checked: boolean | string | number,
) {
const next = checked === true || checked === 1 || checked === '1' ? 1 : 0;
const old = Number(row.enabled) === 1 ? 1 : 0;
if (next === old) {
return;
}
row.enabled = next;
try {
await updateOaCronTask({ id: row.id, enabled: next });
message.success(next === 1 ? '已启用' : '已禁用');
} catch {
row.enabled = old;
}
}
</script>
<template>
<Page auto-content-height>
<FormModal />
<MessageConfigModal />
<RobotsModal />
<TargetsModal />
<Grid>
<template #task_type="{ row }">
<Tag :color="Number(row.type) === 2 ? 'orange' : 'blue'">
{{ Number(row.type) === 2 ? '主动触发' : '定时' }}
</Tag>
</template>
<template #enabled="{ row }">
<Switch
:checked="Number(row.enabled) === 1"
checked-children=""
un-checked-children=""
@change="(checked) => handleEnabledChange(row, checked)"
/>
</template>
<template #card_image="{ row }">
<Image
v-if="row.card_image_url"
:src="row.card_image_url"
:width="40"
:height="40"
style="object-fit: cover; border-radius: 4px"
/>
<span v-else class="text-gray-400">未配置</span>
</template>
<template #message_config="{ row }">
<a class="cursor-pointer text-primary" @click="openMessageConfig(row)">
{{ row.active_types_summary || '点击配置' }}
</a>
</template>
<template #robots="{ row }">
<a class="cursor-pointer text-primary" @click="openRobots(row)">
{{ row.robot_names || '未配置点击配置' }}
</a>
</template>
<template #send_users="{ row }">
<a class="cursor-pointer text-primary" @click="openTargets(row)">
{{ row.target_summary || '未配置点击配置' }}
</a>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'link',
onClick: () => openEdit(row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,21 @@
import { requestClient } from '#/api/request';
/**
* OA 通知发送日志 API只读
* 路由前缀oa-notify-log/
*/
const prefix = 'oa-notify-log/';
/**
* 分页查询日志列表
*/
export async function getOaNotifyLogList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取日志详情
*/
export async function getOaNotifyLogInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}

View File

@@ -0,0 +1,61 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 日志搜索表单配置
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入场景编码',
},
defaultValue: '',
fieldName: 'scene_code',
label: '场景编码',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '输入平台编码',
},
defaultValue: '',
fieldName: 'platform_code',
label: '平台编码',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '选择状态',
options: [
{ label: '成功', value: 1 },
{ label: '失败', value: 0 },
],
},
defaultValue: '',
fieldName: 'status',
label: '状态',
},
{
// 发送类型筛选normal 业务正常发送 / test 机器人测试发送
component: 'VbenSelect',
componentProps: {
placeholder: '选择发送类型',
options: [
{ label: '正常发送', value: 'normal' },
{ label: '测试发送', value: 'test' },
],
},
defaultValue: '',
fieldName: 'send_type',
label: '发送类型',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,78 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getOaNotifyLogList } from '#/views/system/oa-notify-log/api';
interface RowType {
id: number;
scene_id: number;
scene_code: string;
robot_id: number;
platform_code: string;
message_type: string;
send_type: string;
request_payload: string;
response_payload: string;
status: number;
error_msg: string;
cost_ms: number;
created_at: string;
}
/**
* 日志列表表格配置(只读,无操作列)
*/
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'scene_code', align: 'left', title: '场景编码', width: 180 },
{ field: 'platform_code', align: 'left', title: '平台', width: 120, slots: { default: 'platform_code' } },
{ field: 'robot_id', align: 'left', title: '机器人ID', width: 100 },
{ field: 'message_type', align: 'left', title: '消息类型', width: 100, slots: { default: 'message_type' } },
{ field: 'send_type', align: 'left', title: '发送类型', width: 100, slots: { default: 'send_type' } },
{ field: 'request_payload', align: 'left', title: '请求体', slots: { default: 'request_payload' } },
{ field: 'status', align: 'left', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'error_msg', align: 'left', title: '错误信息', slots: { default: 'error_msg' } },
{ field: 'cost_ms', align: 'left', title: '耗时(ms)', width: 100 },
{ field: 'created_at', align: 'left', title: '发送时间', width: 180 },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOaNotifyLogList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,144 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Tag, Tooltip } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
/**
* OA 通知发送日志列表(只读,仅查看)
*/
defineOptions({ name: 'OaNotifyLog' });
/** 平台编码 → 显示名映射 */
const platformMap = ref<Record<string, string>>({});
/**
* 消息类型 → {名称, Tag 颜色} 映射
* 顺带补全现有 slot 只处理 text/markdown 两类的不足,覆盖所有 OA 支持的消息类型
*/
const MESSAGE_TYPE_MAP: Record<string, { name: string; color: string }> = {
text: { name: '文本', color: 'processing' },
markdown: { name: 'MD', color: 'warning' },
image: { name: '图片', color: 'cyan' },
news: { name: '图文', color: 'gold' },
template_card: { name: '卡片', color: 'purple' },
link: { name: '链接', color: 'blue' },
action_card: { name: '动作卡', color: 'magenta' },
actionCard: { name: '动作卡', color: 'magenta' },
interactive: { name: '交互', color: 'geekblue' },
feed_card: { name: '流式', color: 'volcano' },
};
/**
* 取消息类型展示信息(未命中时回退为「未知」+ 灰色 Tag
*/
function getMessageTypeInfo(type: string): { name: string; color: string } {
return MESSAGE_TYPE_MAP[type] ?? { name: type || '未知', color: 'default' };
}
const [Grid] = useVbenVxeGrid({
formOptions,
gridOptions,
});
/**
* 加载平台列表用于列展示
*/
async function loadPlatforms() {
try {
const list = await getOaPlatformList();
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
platformMap.value = rows.reduce(
(acc, item) => {
acc[item.platform_code] = item.name;
return acc;
},
{} as Record<string, string>,
);
} catch (e) {
console.error('加载平台列表失败', e);
}
}
loadPlatforms();
/**
* 解析 JSON 字符串便于展示
*/
function formatPayload(raw: any): string {
if (!raw) return '';
if (typeof raw === 'object') {
return JSON.stringify(raw, null, 2);
}
try {
const parsed = JSON.parse(String(raw));
return JSON.stringify(parsed, null, 2);
} catch {
return String(raw);
}
}
</script>
<template>
<Page auto-content-height title="OA 通知发送日志">
<Grid>
<template #toolbar-buttons>
<span class="text-xs text-gray-400">
日志为只读记录每次 OA 通知发送的请求和响应含成功和失败
</span>
</template>
<template #platform_code="{ row }">
<Tag v-if="platformMap[row.platform_code]" color="processing">
{{ platformMap[row.platform_code] }}
</Tag>
<span v-else>{{ row.platform_code }}</span>
</template>
<!-- 消息类型 MESSAGE_TYPE_MAP 映射覆盖所有 OA 支持的类型 -->
<template #message_type="{ row }">
<Tag :color="getMessageTypeInfo(row.message_type).color">
{{ getMessageTypeInfo(row.message_type).name }}
</Tag>
</template>
<!-- 发送类型normal 正常业务发送 / test 测试发送 -->
<template #send_type="{ row }">
<Tag :color="row.send_type === 'test' ? 'warning' : 'processing'">
{{ row.send_type === 'test' ? '测试' : '正常' }}
</Tag>
</template>
<template #request_payload="{ row }">
<Tooltip>
<template #title>
<pre class="max-w-[400px] overflow-auto text-xs">{{
formatPayload(row.request_payload)
}}</pre>
</template>
<span class="cursor-pointer text-blue-500">查看请求体</span>
</Tooltip>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'error'">
{{ row.status === 1 ? '成功' : '失败' }}
</Tag>
</template>
<template #error_msg="{ row }">
<Tooltip v-if="row.error_msg">
<template #title>
<span class="max-w-[400px] inline-block">{{ row.error_msg }}</span>
</template>
<span class="cursor-pointer text-red-500 line-clamp-1">
{{ row.error_msg }}
</span>
</Tooltip>
<span v-else class="text-gray-400">-</span>
</template>
<template #toolbar-tools></template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,44 @@
import { requestClient } from '#/api/request';
/**
* OA 平台元信息 API多个模块共用机器人下拉、场景 @ 人分组、系统配置 tab
*
* 平台列表必须从接口动态拉取,禁止在前端写死常量数组。
* 路由前缀oa-platform/
*/
const prefix = 'oa-platform/';
/**
* 获取所有平台列表(不分页,前端动态渲染用)
* 返回值含未启用平台,前端根据 enabled 字段决定显示样式
*/
export async function getOaPlatformList() {
return requestClient.get<any>(`${prefix}list`);
}
/**
* 切换平台启用状态(系统配置 tab 中点击开关立即调用)
*/
export async function updateOaPlatformEnabled(id: number, enabled: number) {
return requestClient.post<any>(`${prefix}update-enabled`, { id, enabled });
}
/**
* 获取平台凭证详情(解密明文)
*/
export async function getOaPlatformCredential(id: number) {
return requestClient.get<any>(`${prefix}credential-detail`, { params: { id } });
}
/**
* 更新平台级应用凭证
*/
export async function updateOaPlatformCredential(data: {
id: number;
corp_id?: string;
agent_id?: number;
app_secret?: string;
default_sender?: string;
}) {
return requestClient.post<any>(`${prefix}update-credential`, data);
}

View File

@@ -0,0 +1,155 @@
import { requestClient } from '#/api/request';
/**
* OA 通知模块 API
* 路由前缀统一为 oa-robot/、oa-scene/、oa-platform/、oa-notify-log/、admin-oa-account/
* 命名规范get{Entity}List / get{Entity}Option / get{Entity}Info / create{Entity} / update{Entity} / delete{Entity}
*/
const prefix = 'oa-robot/';
/**
* 分页查询机器人列表
*/
export async function getOaRobotList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取机器人详情(含解密后的 webhook_url 和 secret用于编辑回显
*/
export async function getOaRobotInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 新增机器人
*/
export async function createOaRobot(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/**
* 编辑机器人
*/
export async function updateOaRobot(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/**
* 删除机器人(支持批量)
*/
export async function deleteOaRobot(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 测试发送(验证密钥是否正确,不依赖机器人表)
*
* 入参说明:
* - id行级测试时传入后端按 id 自动取已加密的 webhook_url/secretwebhook_url/secret 仍可显式传,用于表单内测试)
* - test_at_all是否 @ 全员(按平台自动适配:企微/钉钉走 atMobiles飞书走 atUserIds
* - test_mobiles要 @ 的手机号列表(企微/钉钉有效,飞书 webhook 不支持手机号 @
*/
export async function testSendOaRobot(data: {
id?: number;
platform_code: string;
webhook_url?: string;
secret?: string;
corp_id?: string;
chat_id?: string;
agent_id?: number;
use_platform_credential?: number;
test_content?: string;
test_at_all?: boolean;
test_mobiles?: string[];
test_userids?: string[];
/** 应用 API接收员工 userid */
test_userid?: string;
/** 应用 API本地群聊 idxk_oa_chat.id */
test_chat_id?: number;
/** 应用 API客户群确认发送人 */
test_sender_userid?: string;
/** 按绑定场景发送(只发场景配置的一种类型) */
scene_id?: number;
}) {
return requestClient.post<any>(`${prefix}test-send`, data);
}
/**
* 查询机器人已绑定的场景列表(测试发送下拉)
*/
export async function getOaRobotBoundScenes(id: number) {
return requestClient.get<{ id: number; scene_code: string; scene_name: string }[]>(
`${prefix}list-bound-scenes`,
{ params: { id } },
);
}
/**
* 仅同步机器人-群聊绑定关系用于「未绑定」link 入口和「绑定群聊」按钮)
* 与 updateOaRobot 区分:本接口不动 name / status / remark 等基础信息
*/
export async function updateOaRobotChatBind(data: {
id: number;
chat_ids: number[];
}) {
return requestClient.post<any>(`${prefix}bind-chats`, data);
}
/**
* 仅更新 webhook_url 和 secret用于「改地址」入口
*/
export async function updateOaRobotWebhook(data: {
id: number;
webhook_url: string;
secret?: string;
}) {
return requestClient.post<any>(`${prefix}update-webhook`, data);
}
/**
* 仅更新应用 API 凭证corp_id / secret / chat_id / agent_id / use_platform_credential
*/
export async function updateOaRobotAppCredential(data: {
id: number;
corp_id?: string;
secret?: string;
chat_id: string;
agent_id?: number;
use_platform_credential?: number;
}) {
return requestClient.post<any>(`${prefix}update-app-credential`, data);
}
/**
* 克隆机器人(复制平台与凭证策略,不复制场景绑定)
*/
export async function cloneOaRobot(data: { id: number; name?: string }) {
return requestClient.post<any>(`${prefix}clone-robot`, data);
}
/**
* 按平台分组的机器人下拉(克隆源选择用)
*/
export async function getOaRobotGroupedByPlatform() {
return requestClient.get<Record<string, { id: number; name: string }[]>>(
`${prefix}list-grouped-by-platform`,
);
}
/**
* 手动同步应用群聊成员
*/
export async function syncOaRobotChatMembers(data: { id: number }) {
return requestClient.post<any>(`${prefix}sync-chat-members`, data);
}
/**
* 查询已缓存群成员(单个 id 或批量 robot_ids
*/
export async function getOaRobotChatMembers(params: {
id?: number;
robot_ids?: number[] | string;
}) {
return requestClient.get<any>(`${prefix}chat-members`, { params });
}

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
/**
* 企业微信应用 API「改凭证」弹窗
* 仅保存凭证策略(平台默认 / 自定义 corp/secret/agent测试请到列表「测试」弹窗选人/选群
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateOaRobotAppCredential } from '#/views/system/oa-robot/api';
import {
isUsePlatformCredential,
showCustomAppCredential,
} from '#/views/system/oa-robot/config/form';
const gridApi = ref();
const robotId = ref(0);
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
showDefaultActions: false,
schema: [
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '使用平台默认凭证', value: 1 },
{ label: '自定义凭证', value: 0 },
],
},
defaultValue: 1,
fieldName: 'use_platform_credential',
label: '凭证来源',
rules: 'selectRequired',
},
{
component: 'VbenInput',
fieldName: 'corp_id',
label: '企业 ID',
componentProps: { placeholder: 'corpid', autocomplete: 'off' },
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
rules: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
})
? 'required'
: null,
},
},
{
component: 'VbenInputPassword',
fieldName: 'secret',
label: '应用 Secret',
componentProps: {
placeholder: '留空表示不修改;填写则覆盖',
autocomplete: 'new-password',
},
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
},
},
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '应用 AgentId',
componentProps: { placeholder: '企业应用 agentid', min: 1, class: 'w-full' },
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
rules: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
})
? 'required'
: null,
},
},
{
component: 'VbenInput',
fieldName: 'chat_id',
label: '默认群聊 ID',
componentProps: {
placeholder: '可选;场景选群为主。填写后可同步成员',
autocomplete: 'off',
},
},
],
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const usePlatform = Number(values.use_platform_credential ?? 1);
// 自定义凭证时 secret 首次必填;编辑留空表示不改
if (
!isUsePlatformCredential({
platform_code: 'work_wechat_app',
use_platform_credential: usePlatform,
}) &&
!String(values.secret || '').trim()
) {
// 编辑场景允许留空不改;若库中本无 secret 会由后端校验
}
modalApi.setState({ confirmLoading: true });
try {
await updateOaRobotAppCredential({
id: robotId.value,
use_platform_credential: usePlatform,
corp_id: String(values.corp_id || ''),
secret: String(values.secret || ''),
agent_id: Number(values.agent_id || 0) || undefined,
chat_id: String(values.chat_id || ''),
});
message.success('凭证已更新');
gridApi.value?.query?.() || gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ row: any; gridApi: any }>() ?? {};
gridApi.value = data.gridApi;
const row = data.row || {};
robotId.value = Number(row.id || 0);
formApi.setValues({
use_platform_credential: Number(row.use_platform_credential ?? 1),
corp_id: row.corp_id || '',
secret: '',
agent_id: Number(row.agent_id || 0) || undefined,
chat_id: row.chat_id || '',
});
modalApi.setState({
title: `改凭证 - ${row.name || ''}`,
});
},
});
</script>
<template>
<Modal class="w-[40%]">
<Alert
type="info"
show-icon
message="本弹窗仅保存凭证"
description="冒烟测试请关闭后在列表点击「测试」,并选择接收员工或群聊后再发送。"
style="margin-bottom: 16px"
/>
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Checkbox,
message,
TypographyText,
TypographyTitle,
} from 'ant-design-vue';
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
import { updateOaRobotChatBind } from '#/views/system/oa-robot/api';
/**
* OA 机器人「单独绑定群聊」独立弹窗
*
* 与编辑弹窗的区别:本弹窗只同步群聊绑定关系,不动其他字段
* 入口场景:
* - 列表「chat_names」列的「未绑定」link 按钮(专为未绑定机器人提供)
* - 也可被列表操作列「绑定群聊」按钮复用(如有需要)
*/
interface ChatOption {
id: number;
name: string;
}
interface RobotRow {
id: number;
platform_code: string;
name: string;
chat_ids?: number[];
}
/** 当前操作的机器人(由列表行传入) */
const currentRobot = ref<RobotRow | null>(null);
/** 按平台分组的群聊列表(结构:{ platform_code: [{ id, name }, ...] } */
const chatsByPlatform = ref<Record<string, ChatOption[]>>({});
/** 当前已勾选的群聊 ID 列表(回显 + 提交用) */
const selectedChatIds = ref<number[]>([]);
/** 当前机器人所属平台(弹窗内只读展示,不允许跨平台改) */
const currentPlatformCode = ref('');
/** 当前平台下可绑定的群聊列表 */
const chatsOfCurrentPlatform = computed<ChatOption[]>(() => {
return chatsByPlatform.value[currentPlatformCode.value] ?? [];
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!currentRobot.value?.id) {
message.warning('机器人信息缺失');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateOaRobotChatBind({
id: currentRobot.value.id,
chat_ids: selectedChatIds.value,
});
message.success('绑定成功');
const grid = modalApi.getData<{ gridApi?: any }>()?.gridApi;
grid?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ row: RobotRow; gridApi?: any }>();
currentRobot.value = data?.row ?? null;
// 回显已绑定的群聊
const row = currentRobot.value;
currentPlatformCode.value = String(row?.platform_code ?? '');
selectedChatIds.value = Array.isArray(row?.chat_ids)
? row!.chat_ids!.map((id) => Number(id))
: [];
// 动态设置标题,让用户看到正在为哪个机器人绑定
if (row) {
modalApi.setState({
title: `绑定群聊 - ${row.name}`,
confirmLoading: false,
});
}
// 拉取群聊列表
loadChatsByPlatform();
}
},
});
/**
* 拉取所有平台下的群聊列表(一次拉全量,前端按 platform_code 过滤)
*/
async function loadChatsByPlatform() {
try {
const data = await getOaChatListByPlatform();
chatsByPlatform.value = data ?? {};
} catch (e) {
console.error('加载群聊列表失败', e);
chatsByPlatform.value = {};
}
}
</script>
<template>
<Modal title="绑定群聊" class="w-[40%]">
<!-- 提示当前机器人所属平台 + 平台锁定说明不允许跨平台改 -->
<Alert
type="info"
show-icon
message="平台已锁定,仅展示当前机器人所属平台下的群聊"
:description="`机器人所属平台:${currentPlatformCode || '-'}(如需更换平台,请走「编辑」入口)`"
style="margin-bottom: 12px"
/>
<!--
绑定群聊去掉自定义背景块改为简单分区 modal.vue 保持一致
全部走 antd 原生样式暗色一致性由 antd 保证
必须用具名导入 TypographyTitle/TypographyText不要用 Typography.Title 命名空间写法
-->
<div class="chat-bind-section">
<TypographyTitle :level="5" class="chat-bind-title">
绑定群聊
</TypographyTitle>
<TypographyText type="secondary" class="chat-bind-hint">
仅展示{{ currentPlatformCode || '请先选平台' }}平台下的群聊
</TypographyText>
<TypographyText
v-if="chatsOfCurrentPlatform.length === 0"
type="secondary"
class="chat-empty"
>
当前平台下暂无群聊可先到OA群聊管理中新增
</TypographyText>
<Checkbox.Group
v-else
v-model:value="selectedChatIds"
class="chat-checkbox-group"
>
<Checkbox
v-for="chat in chatsOfCurrentPlatform"
:key="chat.id"
:value="chat.id"
>
{{ chat.name }}
</Checkbox>
</Checkbox.Group>
</div>
</Modal>
</template>
<style scoped lang="scss">
/*
* 暗色适配:去掉自定义背景块后,仅保留布局间距相关样式
* 颜色全部由 antd 原生组件自动适配
*/
.chat-bind-section {
margin-top: 4px;
}
.chat-bind-title {
margin-bottom: 4px !important;
font-size: 14px !important;
}
.chat-bind-hint {
font-size: 12px;
}
.chat-empty {
display: block;
font-size: 12px;
padding: 8px 0;
}
.chat-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,631 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Button,
Checkbox,
message,
Tag,
TypographyText,
TypographyTitle,
} from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import {
createOaRobot,
getOaRobotGroupedByPlatform,
getOaRobotInfo,
syncOaRobotChatMembers,
testSendOaRobot,
updateOaRobot,
} from '#/views/system/oa-robot/api';
import {
createFormSchema,
editFormSchema,
isUsePlatformCredential,
isWorkWechatApp,
modalFormProps,
} from '#/views/system/oa-robot/config/form';
/**
* OA 机器人新增/编辑弹窗
*
* 关键交互:
* - 打开弹窗时组件内直接调 getOaPlatformList把 options 合并进 schema 后一次性 setState
* (禁止事后 updateSchema避免与 setState(schema) 竞态导致所属平台选项为空)
* - 用 update 标志区分新增/编辑:新增走 createFormSchema含 webhook/secret编辑走 editFormSchema
* - 「绑定群聊」复选框组:按当前选中的 platform_code 过滤,仅展示该平台下的群聊
* - 应用 API凭证来源 Radio平台默认 / 自定义)+ 可选「克隆机器人」回填
* - 弹窗内有「测试发送」按钮,循环跑该平台支持的所有消息类型
*/
interface TestResultItem {
message_type: string;
name: string;
success: boolean;
message: string;
response: any;
cost_ms: number;
}
interface ChatOption {
id: number;
name: string;
}
const isUpdate = ref(false);
const gridApi = ref();
/** 测试发送按钮的 loading 状态(与底部「确定」的 confirmLoading 分离,避免互相干扰) */
const testing = ref(false);
/** 最近一次测试发送的结果列表(按消息类型分别展示) */
const testResults = ref<TestResultItem[]>([]);
/** 测试发送结果汇总提示 */
const testSummary = ref('');
/** 按平台分组的群聊列表(结构:{ platform_code: [{ id, name }, ...] } */
const chatsByPlatform = ref<Record<string, ChatOption[]>>({});
/** 当前已勾选的群聊 ID 列表(编辑场景下回显用) */
const selectedChatIds = ref<number[]>([]);
/** 当前选中的平台编码(控制群聊列表过滤) */
const currentPlatformCode = ref('');
/** 按平台分组的机器人列表(克隆源下拉用) */
const robotsByPlatform = ref<Record<string, { id: number; name: string }[]>>(
{},
);
/** 同步群成员 loading */
const syncingMembers = ref(false);
/** 最近一次同步的成员数量提示 */
const memberSyncHint = ref('');
/** 应用 API 平台不展示 N:N 群聊绑定 */
const showChatBind = computed(
() => currentPlatformCode.value !== '' && !isWorkWechatApp(currentPlatformCode.value),
);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 合并群聊绑定数组到提交载荷(后端 OaRobotController::notRequest 已包含 chat_ids
const payload = { ...values, chat_ids: selectedChatIds.value };
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateOaRobot : createOaRobot;
submitApi(payload)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
// 必须用 update 标志区分,不能用 if (values):新增时 values={} 也是 truthy会误走编辑分支
const data = modalApi.getData<Record<string, any>>() ?? {};
const values = data.values;
isUpdate.value = !!data.update;
// 组件内直接拉平台列表,合并进 schema 后再 setState避免 updateSchema 竞态
applySchemaWithPlatforms(isUpdate.value).then(() => {
if (isUpdate.value && values && Object.keys(values).length > 0) {
formApi.setValues(values);
currentPlatformCode.value = String(values.platform_code ?? '');
selectedChatIds.value = Array.isArray(values.chat_ids)
? values.chat_ids.map((id: any) => Number(id))
: [];
const memberCount = Array.isArray(values.members)
? values.members.length
: 0;
memberSyncHint.value = isWorkWechatApp(currentPlatformCode.value)
? `已缓存群成员 ${memberCount}`
: '';
} else {
currentPlatformCode.value = '';
selectedChatIds.value = [];
memberSyncHint.value = '';
}
});
loadChatsByPlatform();
testResults.value = [];
testSummary.value = '';
}
},
});
/**
* 当前平台下可绑定的群聊列表
* 切换平台时自动过滤,仅展示该平台下的群聊
*/
const chatsOfCurrentPlatform = computed<ChatOption[]>(() => {
return chatsByPlatform.value[currentPlatformCode.value] ?? [];
});
/**
* 平台切换处理:清理已不属于当前平台的勾选项(避免提交无效绑定)
*/
function handlePlatformChange(value: string) {
currentPlatformCode.value = value;
const allowedIds = new Set(chatsOfCurrentPlatform.value.map((c) => c.id));
selectedChatIds.value = selectedChatIds.value.filter((id) =>
allowedIds.has(id),
);
if (isWorkWechatApp(value)) {
selectedChatIds.value = [];
// 切换到应用 API 时刷新克隆源下拉(按平台过滤)
refreshCloneOptions(value);
} else {
formApi.setValues({ clone_from_id: undefined });
}
memberSyncHint.value = '';
}
/**
* 选择克隆源:拉详情回填凭证策略与 chat_id不覆盖用户已改的名称除非名称为空
*/
async function handleCloneFromChange(cloneId: number | undefined) {
if (!cloneId) return;
try {
const detail = await getOaRobotInfo(Number(cloneId));
if (!detail) return;
const current = await formApi.getValues();
formApi.setValues({
platform_code: detail.platform_code,
use_platform_credential: Number(detail.use_platform_credential ?? 1),
corp_id: detail.corp_id || '',
secret: detail.secret || '',
agent_id: Number(detail.agent_id || 0) || undefined,
chat_id: detail.chat_id || '',
remark: detail.remark || '',
// 名称:空则用「源名+副本」,已有内容不强制覆盖
name:
current.name && String(current.name).trim() !== ''
? current.name
: `${detail.name || '机器人'}副本`,
clone_from_id: Number(cloneId),
});
currentPlatformCode.value = String(detail.platform_code ?? '');
} catch (e) {
console.error('加载克隆源失败', e);
message.error('加载克隆源失败');
}
}
/**
* 按当前平台刷新「克隆机器人」下拉 options
*/
function refreshCloneOptions(platformCode: string) {
const options = (robotsByPlatform.value[platformCode] ?? []).map((r) => ({
label: r.name,
value: r.id,
}));
formApi.updateSchema([
{
fieldName: 'clone_from_id',
componentProps: {
options,
placeholder: '可选,选择后回填凭证与群聊(不含强制改名)',
allowClear: true,
showSearch: true,
optionFilterProp: 'label',
onChange: (val: number | undefined) => {
handleCloneFromChange(val);
},
},
},
]);
}
/**
* 编辑态手动同步群成员(需已保存凭证)
*/
async function handleSyncMembers() {
const values = await formApi.getValues();
if (!values.id) {
message.warning('请先保存机器人后再同步成员');
return;
}
syncingMembers.value = true;
try {
const data = await syncOaRobotChatMembers({ id: Number(values.id) });
memberSyncHint.value = `已同步 ${data?.member_count ?? 0}${data?.chat_name ? `${data.chat_name}` : ''}`;
message.success(memberSyncHint.value);
} finally {
syncingMembers.value = false;
}
}
/**
* 组件内直接调 getOaPlatformList把平台选项合并进 schema 后一次性 setState
* - 显示全部平台(含已禁用的,便于历史数据回显)
* - 已禁用平台在 label 上加后缀并禁用选择
* - 注入 onChange点选平台时同步 currentPlatformCode 并过滤群聊勾选
*
* @param isEdit 是否编辑态(决定用 editFormSchema 还是 createFormSchema
*/
async function applySchemaWithPlatforms(isEdit: boolean) {
let options: { label: string; value: string; disabled: boolean }[] = [];
try {
const list = await getOaPlatformList();
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
options = rows.map((item) => ({
label: item.enabled === 1 ? item.name : `${item.name}(已禁用)`,
value: item.platform_code,
disabled: item.enabled !== 1,
}));
} catch (e) {
console.error('加载平台列表失败', e);
}
// 新增态预拉克隆源列表(按平台分组)
if (!isEdit) {
try {
const grouped = await getOaRobotGroupedByPlatform();
robotsByPlatform.value = grouped ?? {};
} catch (e) {
console.error('加载机器人分组失败', e);
robotsByPlatform.value = {};
}
}
const base = isEdit ? editFormSchema : createFormSchema;
// 深拷贝字段配置,避免污染导出的静态 schema把 options/onChange 写进 platform_code
const schema = base.map((field: any) => {
if (field.fieldName === 'platform_code') {
return {
...field,
componentProps: {
...(field.componentProps || {}),
options,
// RadioGroup 的 change 事件antd 原生e.target.value 取选中值
onChange: (e: any) => {
const next = e?.target?.value ?? '';
if (typeof next === 'string' && next !== '') {
handlePlatformChange(next);
}
},
},
};
}
if (field.fieldName === 'clone_from_id') {
return {
...field,
componentProps: {
...(field.componentProps || {}),
options: [],
onChange: (val: number | undefined) => {
handleCloneFromChange(val);
},
},
};
}
return { ...field };
});
formApi.setState({ schema });
}
/**
* 拉取所有平台下的群聊列表(一次拉全量,前端按 platform_code 过滤)
*/
async function loadChatsByPlatform() {
try {
const data = await getOaChatListByPlatform();
chatsByPlatform.value = data ?? {};
} catch (e) {
console.error('加载群聊列表失败', e);
chatsByPlatform.value = {};
}
}
/**
* 测试发送:循环跑该平台支持的所有消息类型
*
* 入参策略(与 webhook 入口分离后的新逻辑):
* - 编辑态webhook_url/secret 已不在表单中,直接用 row.id 走后端兜底取密钥
* - 新增态:用表单填写的 webhook_url/secret 走(保存前验证密钥有效性)
*
* 单平台最多 8 种类型,需并行调用以缩短等待时间(约 10~20s
* 返回的 results 数组按消息类型分别展示成功/失败
*/
async function handleTestSend() {
const values = await formApi.getValues();
if (!values.platform_code) {
message.warning('请先选择平台');
return;
}
const isApp = isWorkWechatApp(values.platform_code);
if (!isUpdate.value) {
if (isApp) {
if (!values.chat_id) {
message.warning('请填写群聊 ID');
return;
}
if (
!isUsePlatformCredential(values) &&
(!values.corp_id || !values.secret || !values.agent_id)
) {
message.warning('自定义凭证请填写 corp_id、Secret、agent_id');
return;
}
} else if (!values.webhook_url || values.webhook_url === '****') {
message.warning('请填写 webhook 地址');
return;
}
}
testing.value = true;
testResults.value = [];
testSummary.value = '正在发送测试消息,请稍候(最多 8 种类型)...';
try {
const result = await testSendOaRobot(
isUpdate.value
? {
id: values.id,
platform_code: values.platform_code,
test_content: '萧康云医 OA 测试消息',
}
: isApp
? {
platform_code: values.platform_code,
use_platform_credential: Number(
values.use_platform_credential ?? 1,
),
corp_id: values.corp_id || '',
secret: values.secret || '',
agent_id: Number(values.agent_id || 0),
chat_id: values.chat_id,
test_content: '萧康云医 OA 测试消息',
}
: {
platform_code: values.platform_code,
webhook_url: values.webhook_url,
secret: values.secret || '',
test_content: '萧康云医 OA 测试消息',
},
);
if (Array.isArray(result?.results)) {
testResults.value = result.results;
const total = result.total ?? result.results.length;
const succ = result.success_count ?? result.results.filter((r: any) => r.success).length;
const fail = total - succ;
testSummary.value = `${total} 种类型,成功 ${succ} 种,失败 ${fail}`;
if (fail === 0) {
message.success(testSummary.value);
} else if (succ === 0) {
message.error(testSummary.value);
} else {
message.warning(testSummary.value);
}
} else if (result?.success !== undefined) {
testResults.value = [
{
message_type: 'text',
name: '文本消息',
success: result.success,
message: result.message || '',
response: result.response,
cost_ms: 0,
},
];
testSummary.value = result.success ? '测试发送成功' : '测试发送失败';
}
} finally {
testing.value = false;
}
}
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}机器人`"
class="w-[40%]"
>
<Form />
<!-- webhook 平台N:N 绑定群聊应用 API 目标群即 chat_id不展示本区 -->
<div v-if="showChatBind" class="chat-bind-section">
<TypographyTitle :level="5" class="chat-bind-title">
绑定群聊
</TypographyTitle>
<TypographyText type="secondary" class="chat-bind-hint">
仅展示{{ currentPlatformCode || '请先选平台' }}平台下的群聊
</TypographyText>
<TypographyText
v-if="chatsOfCurrentPlatform.length === 0"
type="secondary"
class="chat-empty"
>
当前平台下暂无群聊可先到OA群聊管理中新增
</TypographyText>
<Checkbox.Group
v-else
v-model:value="selectedChatIds"
class="chat-checkbox-group"
>
<Checkbox
v-for="chat in chatsOfCurrentPlatform"
:key="chat.id"
:value="chat.id"
>
{{ chat.name }}
</Checkbox>
</Checkbox.Group>
</div>
<!-- 应用 API同步群成员入口编辑态有 id 时可用 -->
<div
v-if="isWorkWechatApp(currentPlatformCode)"
class="chat-bind-section"
>
<TypographyTitle :level="5" class="chat-bind-title">
群成员
</TypographyTitle>
<TypographyText type="secondary" class="chat-bind-hint">
保存凭证后可同步 appchat 成员供测试发送与场景 @ 勾选
</TypographyText>
<div class="member-sync-row">
<Button
v-if="isUpdate"
size="small"
:loading="syncingMembers"
@click="handleSyncMembers"
>
同步群成员
</Button>
<TypographyText v-if="memberSyncHint" type="secondary">
{{ memberSyncHint }}
</TypographyText>
<TypographyText v-else-if="!isUpdate" type="secondary">
首次保存后会自动同步成员
</TypographyText>
</div>
</div>
<!-- 测试发送结果展示区仅在有过测试时显示 -->
<div v-if="testResults.length > 0 || testSummary" class="test-results">
<Alert
v-if="testSummary"
:type="
testResults.length === 0
? 'info'
: testResults.every((r) => r.success)
? 'success'
: testResults.some((r) => r.success)
? 'warning'
: 'error'
"
:message="testSummary"
show-icon
style="margin-bottom: 12px"
/>
<div v-if="testResults.length > 0" class="result-list">
<div
v-for="item in testResults"
:key="item.message_type"
class="result-item"
>
<div class="result-header">
<Tag :color="item.success ? 'success' : 'error'">
{{ item.success ? '成功' : '失败' }}
</Tag>
<span class="result-name">{{ item.name }}</span>
<span class="result-type">({{ item.message_type }})</span>
<span v-if="item.cost_ms" class="result-cost">{{ item.cost_ms }}ms</span>
</div>
<div v-if="!item.success && item.message" class="result-error">
{{ item.message }}
</div>
</div>
</div>
</div>
<!-- webhook 平台保留表单内测试应用 API 请到列表测试独立弹窗选人/选群 -->
<template v-if="!isWorkWechatApp(currentPlatformCode)" #prepend-footer>
<Button type="default" :loading="testing" @click="handleTestSend">
测试发送全部类型
</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
/*
* 暗色适配:去掉自定义背景块后,仅保留布局间距相关样式
* 颜色全部由 antd 原生组件TypographyTitle / TypographyText / Checkbox自动适配
*/
.chat-bind-section {
margin-top: 16px;
}
.chat-bind-title {
/* 覆盖 TypographyTitle 默认大字号,让其更紧凑 */
margin-bottom: 4px !important;
font-size: 14px !important;
}
.chat-bind-hint {
font-size: 12px;
}
.chat-empty {
display: block;
font-size: 12px;
padding: 8px 0;
}
.chat-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.member-sync-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.test-results {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item {
padding: 8px 12px;
border-radius: 4px;
background: var(--ant-color-fill-quaternary, #f7f8fa);
font-size: 12px;
}
.result-header {
display: flex;
align-items: center;
gap: 6px;
}
.result-name {
font-weight: 500;
color: var(--ant-color-text, #1d2129);
}
.result-type {
color: var(--ant-color-text-tertiary, #86909c);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.result-cost {
margin-left: auto;
color: var(--ant-color-text-tertiary, #86909c);
}
.result-error {
margin-top: 4px;
color: var(--ant-color-error, #f53f3f);
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,309 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import dayjs from 'dayjs';
import {
Alert,
Descriptions,
DescriptionsItem as DescItem,
Tag,
Timeline,
TimelineItem,
TypographyParagraph,
TypographyText,
TypographyTitle,
} from 'ant-design-vue';
/**
* OA 机器人「测试发送结果」独立弹窗
*
* 设计要点:
* - 顶部 Descriptions全局上下文机器人、平台、时间、测试内容、@
* - 底部 Timeline每种消息类型一个节点展示成功/失败 + 真实请求体 + 响应体
* - 请求体来自后端 results[].request渠道 buildPayload 的真实 HTTP body已脱敏
* - 暗色模式全部走 antd 原生组件 + CSS 变量
*
* 数据来源modalApi.getData(),由 test-send-modal.vue 的 onFinished 注入
*/
interface TestResultItem {
message_type: string;
name: string;
success: boolean;
message: string;
/** 真实 HTTP 请求体(后端脱敏后) */
request?: any;
response: any;
cost_ms: number;
}
/** 测试结果列表(由外部传入) */
const testResults = ref<TestResultItem[]>([]);
/** 测试汇总提示(由外部传入) */
const testSummary = ref('');
/** 机器人名称(上下文展示) */
const robotName = ref('');
/** 机器人 ID上下文展示 */
const robotId = ref<number>();
/** 平台编码(上下文展示) */
const platformCode = ref('');
/** 测试内容(上下文展示) */
const testContent = ref('');
/** 是否 @ 全员(上下文展示) */
const testAtAll = ref(false);
/** @ 手机号列表(上下文展示) */
const testMobiles = ref<string[]>([]);
/** 发送完成时间戳(秒,上下文展示) */
const sentAt = ref<number>();
/**
* @ 配置摘要Descriptions 一行展示用)
*/
const atSummary = computed(() => {
if (testAtAll.value) return '@ 全员';
if (testMobiles.value.length === 0) return '无';
return testMobiles.value.join('、');
});
/**
* 格式化时间戳为可读字符串(秒级 → dayjs
*/
function formatTime(ts?: number): string {
return ts ? dayjs(ts * 1000).format('YYYY-MM-DD HH:mm:ss') : '-';
}
/**
* 把对象格式化为可读 JSON 字符串(空对象显示「无」)
* 用于请求体 / 响应体的 <pre> 展示
*/
function formatJson(data: any): string {
if (data === null || data === undefined) return '无';
if (typeof data === 'string') {
// 后端偶发已是 JSON 字符串,尝试美化
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data || '无';
}
}
if (typeof data === 'object' && Object.keys(data).length === 0) return '无';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<any>();
testResults.value = data?.results ?? [];
testSummary.value = data?.summary ?? '';
robotName.value = data?.robotName ?? '';
robotId.value = data?.robotId;
platformCode.value = data?.platformCode ?? '';
testContent.value = data?.testContent ?? '';
testAtAll.value = !!data?.testAtAll;
testMobiles.value = data?.testMobiles ?? [];
sentAt.value = data?.sentAt;
const suffix = robotName.value ? ` - ${robotName.value}` : '';
modalApi.setState({
title: `测试发送结果${suffix}`,
confirmText: '知道了',
});
}
},
});
</script>
<template>
<Modal title="测试发送结果" class="w-[56%]">
<!-- 1. 汇总提示 -->
<Alert
v-if="testSummary"
:type="
testResults.length === 0
? 'info'
: testResults.every((r) => r.success)
? 'success'
: testResults.some((r) => r.success)
? 'warning'
: 'error'
"
:message="testSummary"
show-icon
style="margin-bottom: 16px"
/>
<!-- 2. 发送详情全局上下文 -->
<Descriptions
title="发送详情"
:column="1"
size="small"
bordered
style="margin-bottom: 16px"
>
<DescItem label="机器人">
{{ robotName || '-' }}
<span v-if="robotId" style="color: var(--ant-color-text-tertiary)">
(id: {{ robotId }})
</span>
</DescItem>
<DescItem label="平台">
<Tag v-if="platformCode" color="processing">{{ platformCode }}</Tag>
<span v-else>-</span>
</DescItem>
<DescItem label="发送时间">{{ formatTime(sentAt) }}</DescItem>
<DescItem label="测试内容">
<TypographyParagraph
style="margin: 0; white-space: pre-wrap; word-break: break-all"
>
{{ testContent || '-' }}
</TypographyParagraph>
</DescItem>
<DescItem label="@ 配置">{{ atSummary }}</DescItem>
</Descriptions>
<!-- 3. 各类型发送结果含真实请求体 / 响应体 -->
<TypographyTitle :level="5" style="margin-bottom: 12px">
消息类型发送结果
</TypographyTitle>
<Timeline v-if="testResults.length > 0">
<TimelineItem
v-for="(item, idx) in testResults"
:key="`${item.message_type}-${idx}`"
:color="item.success ? 'green' : 'red'"
>
<!-- 标题行名称 + 类型 Tag + 成功/失败 Tag + 耗时 -->
<div class="timeline-row">
<span class="timeline-name">{{ item.name }}</span>
<Tag class="timeline-type">{{ item.message_type }}</Tag>
<Tag :color="item.success ? 'success' : 'error'">
{{ item.success ? '成功' : '失败' }}
</Tag>
<span v-if="item.cost_ms" class="timeline-cost">
{{ item.cost_ms }}ms
</span>
</div>
<!-- 渠道返回文案成功 errmsg / 失败原因 -->
<div v-if="item.message" class="timeline-msg" :class="{ error: !item.success }">
{{ item.message }}
</div>
<!-- 真实请求体渠道 buildPayload 结果已脱敏 -->
<div class="payload-block">
<TypographyText type="secondary" class="payload-label">
请求体
</TypographyText>
<pre class="payload-pre">{{ formatJson(item.request) }}</pre>
</div>
<!-- 第三方响应体 -->
<div class="payload-block">
<TypographyText type="secondary" class="payload-label">
响应体
</TypographyText>
<pre class="payload-pre">{{ formatJson(item.response) }}</pre>
</div>
</TimelineItem>
</Timeline>
<TypographyText v-else type="secondary">
无发送结果
</TypographyText>
</Modal>
</template>
<style scoped lang="scss">
/*
* 暗色适配:颜色一律用 antd CSS 变量
* pre 限高 + overflow避免超长 JSON尤其 image base64 截断前)撑爆弹窗
*/
.timeline-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.timeline-name {
font-weight: 500;
color: var(--ant-color-text, #1d2129);
}
.timeline-type {
margin: 0;
}
.timeline-cost {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 12px;
}
.timeline-msg {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
color: var(--ant-color-text-secondary, #4e5969);
&.error {
color: var(--ant-color-error, #f53f3f);
}
}
.payload-block {
margin-top: 8px;
}
.payload-label {
display: block;
font-size: 12px;
margin-bottom: 4px;
}
.payload-pre {
margin: 0;
padding: 8px 10px;
max-height: 180px;
overflow: auto;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
border-radius: 4px;
border: 1px solid var(--ant-color-border, #d9d9d9);
background: var(--ant-color-fill-quaternary, rgba(0, 0, 0, 0.02));
color: var(--ant-color-text, #1d2129);
}
/* 暗色:强制浅色字,避免 CSS 变量未注入时落入 #1d2129 黑底黑字 */
html.dark .payload-pre,
.dark .payload-pre {
color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.16);
}
html.dark .timeline-name,
.dark .timeline-name {
color: rgba(255, 255, 255, 0.88);
}
html.dark .timeline-msg:not(.error),
.dark .timeline-msg:not(.error) {
color: rgba(255, 255, 255, 0.65);
}
</style>

View File

@@ -0,0 +1,438 @@
<script lang="ts" setup>
/**
* OA 机器人行级测试发送弹窗
* work_wechat_app必须选接收员工或群聊客户群需再选确认发送员工
* webhook沿用 @ 全员 / 手机号
*/
import { ref, computed, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message, Radio, RadioGroup, Select, Switch, Textarea } from 'ant-design-vue';
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
import { getOaWwUserList } from '#/views/system/oa-chat/api/org';
import {
getOaRobotBoundScenes,
testSendOaRobot,
} from '#/views/system/oa-robot/api';
import { isWorkWechatApp } from '#/views/system/oa-robot/config/form';
interface TestResultItem {
message_type: string;
name: string;
success: boolean;
message: string;
response: any;
cost_ms: number;
}
interface RobotRow {
id: number;
platform_code: string;
name: string;
}
interface OnFinishedPayload {
results: TestResultItem[];
summary: string;
robotName?: string;
robotId?: number;
platformCode?: string;
testContent?: string;
testAtAll?: boolean;
testMobiles?: string[];
sentAt?: number;
}
const isFeishu = computed(() => currentRobot.value?.platform_code === 'feishu');
const isApp = computed(() =>
isWorkWechatApp(currentRobot.value?.platform_code),
);
const currentRobot = ref<RobotRow | null>(null);
const testing = ref(false);
const testContent = ref('萧康云医 OA 测试消息');
const testAtAll = ref(false);
const testMobiles = ref<string[]>([]);
/** 应用 APIperson | group */
const targetMode = ref<'person' | 'group'>('person');
const testUserid = ref<string | undefined>(undefined);
const testChatId = ref<number | undefined>(undefined);
const testSenderUserid = ref<string | undefined>(undefined);
const userOptions = ref<{ label: string; value: string }[]>([]);
const chatOptions = ref<
{ label: string; value: number; chat_kind: number }[]
>([]);
/** 该机器人已绑定的场景(可选:选中则只发场景配置类型) */
const boundScenes = ref<{ id: number; scene_code: string; scene_name: string }[]>(
[],
);
const selectedSceneId = ref<number | undefined>(undefined);
const onFinished = ref<((payload: OnFinishedPayload) => void) | null>(null);
const selectedChatKind = computed(() => {
const hit = chatOptions.value.find((c) => c.value === testChatId.value);
return hit?.chat_kind ?? 0;
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
await handleTestSend();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
row: RobotRow;
onFinished?: (payload: OnFinishedPayload) => void;
}>();
currentRobot.value = data?.row ?? null;
onFinished.value = data?.onFinished ?? null;
testContent.value = '萧康云医 OA 测试消息';
testAtAll.value = false;
testMobiles.value = [];
targetMode.value = 'person';
testUserid.value = undefined;
testChatId.value = undefined;
testSenderUserid.value = undefined;
selectedSceneId.value = undefined;
boundScenes.value = [];
if (currentRobot.value) {
modalApi.setState({
title: `测试发送 - ${currentRobot.value.name}`,
confirmLoading: false,
});
loadBoundScenes(currentRobot.value.id);
if (isWorkWechatApp(currentRobot.value.platform_code)) {
loadAppOptions();
}
}
}
},
});
watch(testAtAll, (val) => {
if (val) {
testMobiles.value = [];
}
});
watch(targetMode, () => {
testUserid.value = undefined;
testChatId.value = undefined;
testSenderUserid.value = undefined;
});
async function loadBoundScenes(robotId: number) {
try {
const res = await getOaRobotBoundScenes(robotId);
const list = Array.isArray(res) ? res : (res as any)?.data || [];
boundScenes.value = (Array.isArray(list) ? list : []).map((s: any) => ({
id: Number(s.id),
scene_code: String(s.scene_code || ''),
scene_name: String(s.scene_name || ''),
}));
} catch (e) {
console.error(e);
boundScenes.value = [];
}
}
async function loadAppOptions() {
try {
const [userRes, chatRes] = await Promise.all([
getOaWwUserList({
platform_code: 'work_wechat_app',
page: 1,
pageSize: 200,
}),
getOaChatListByPlatform(),
]);
const userData = userRes?.data ?? userRes ?? {};
const items = Array.isArray(userData.items) ? userData.items : [];
userOptions.value = items.map((u: any) => ({
label: u.name ? `${u.name}${u.userid}` : u.userid,
value: String(u.userid),
}));
const grouped = chatRes?.data ?? chatRes ?? {};
const list = grouped.work_wechat_app || [];
chatOptions.value = (Array.isArray(list) ? list : []).map((c: any) => ({
label: `${c.name}${Number(c.chat_kind) === 2 ? '(客户群·需确认)' : '(应用群)'}`,
value: Number(c.id),
chat_kind: Number(c.chat_kind ?? 2),
}));
} catch (e) {
console.error(e);
userOptions.value = [];
chatOptions.value = [];
}
}
async function handleTestSend() {
if (!currentRobot.value?.id) {
message.warning('机器人信息缺失');
return;
}
if (!testContent.value.trim()) {
message.warning('请填写测试内容');
return;
}
if (isApp.value) {
// 选了场景时后端优先用场景 targets可不强制弹窗选人/选群
if (!selectedSceneId.value) {
if (targetMode.value === 'person' && !testUserid.value) {
message.warning('请选择接收员工');
return;
}
if (targetMode.value === 'group' && !testChatId.value) {
message.warning('请选择接收群聊');
return;
}
if (
targetMode.value === 'group' &&
selectedChatKind.value === 2 &&
!testSenderUserid.value
) {
message.warning('客户群请选择确认发送员工');
return;
}
}
}
testing.value = true;
modalApi.setState({ confirmLoading: true });
try {
const payload: Record<string, any> = {
id: currentRobot.value.id,
platform_code: currentRobot.value.platform_code,
test_content: testContent.value,
test_at_all: testAtAll.value,
test_mobiles: isFeishu.value ? [] : testMobiles.value,
};
if (selectedSceneId.value) {
payload.scene_id = selectedSceneId.value;
}
if (isApp.value) {
if (targetMode.value === 'person') {
payload.test_userid = testUserid.value;
} else {
payload.test_chat_id = testChatId.value;
if (selectedChatKind.value === 2) {
payload.test_sender_userid = testSenderUserid.value;
}
}
}
const result = await testSendOaRobot(payload);
let results: TestResultItem[] = [];
let summary = '';
if (Array.isArray(result?.results)) {
results = result.results;
const total = result.total ?? result.results.length;
const succ =
result.success_count ??
result.results.filter((r: any) => r.success).length;
const fail = total - succ;
summary = selectedSceneId.value
? `按场景发送:共 ${total} 条,成功 ${succ},失败 ${fail}`
: `${total} 种类型,成功 ${succ} 种,失败 ${fail}`;
} else if (result?.success !== undefined) {
results = [
{
message_type: 'text',
name: '文本消息',
success: result.success,
message: result.message || '',
response: result.response,
cost_ms: 0,
},
];
summary = result.success ? '测试发送成功' : '测试发送失败';
}
modalApi.close();
if (onFinished.value) {
onFinished.value({
results,
summary,
robotName: currentRobot.value.name,
robotId: currentRobot.value.id,
platformCode: currentRobot.value.platform_code,
testContent: testContent.value,
testAtAll: testAtAll.value,
testMobiles: isFeishu.value ? [] : testMobiles.value,
sentAt: Math.floor(Date.now() / 1000),
});
}
} finally {
testing.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="测试发送" class="w-[42%]">
<div class="test-form">
<div class="form-item">
<label class="form-label">测试内容</label>
<Textarea
v-model:value="testContent"
:rows="2"
placeholder="请输入测试内容(如:萧康云医 OA 测试消息)"
/>
</div>
<!-- 绑定场景选中后只发场景配置的消息类型不循环全类型 -->
<div v-if="boundScenes.length > 0" class="form-item">
<label class="form-label">按场景发送可选</label>
<Select
v-model:value="selectedSceneId"
allow-clear
show-search
class="w-full"
placeholder="不选则测试该平台全部消息类型"
:options="
boundScenes.map((s) => ({
label: `${s.scene_name}${s.scene_code}`,
value: s.id,
}))
"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
/>
<div v-if="selectedSceneId" class="form-hint">
将只发送该场景已配置的消息类型与内容(可用上方测试内容覆盖正文)
</div>
</div>
<!-- 应用 API必选投递目标选场景时可作兜底 -->
<template v-if="isApp && !selectedSceneId">
<div class="form-item">
<label class="form-label">接收对象</label>
<RadioGroup v-model:value="targetMode">
<Radio value="person">员工(个人消息)</Radio>
<Radio value="group">群聊</Radio>
</RadioGroup>
</div>
<div v-if="targetMode === 'person'" class="form-item">
<label class="form-label">接收员工</label>
<Select
v-model:value="testUserid"
show-search
allow-clear
:options="userOptions"
placeholder="选择企微员工 userid"
class="w-full"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
/>
</div>
<div v-else class="form-item">
<label class="form-label">接收群聊</label>
<Select
v-model:value="testChatId"
show-search
allow-clear
:options="chatOptions"
placeholder="选择群聊"
class="w-full"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
/>
</div>
<div v-if="targetMode === 'group' && selectedChatKind === 2" class="form-item">
<label class="form-label">确认发送员工</label>
<Select
v-model:value="testSenderUserid"
show-search
allow-clear
:options="userOptions"
placeholder="客户群群发需员工在企微确认"
class="w-full"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
/>
<span class="form-hint">对应企微 add_msg_template.sender非实时</span>
</div>
<Alert
type="info"
show-icon
message="应用 API 测试请在本弹窗选择接收对象保存机器人仅配置凭证"
class="form-alert"
/>
</template>
<template v-else>
<div class="form-item">
<label class="form-label">@ 所有人</label>
<Switch v-model:checked="testAtAll" />
<span class="form-hint">开启后所有消息类型都按 @ 全员发送</span>
</div>
<div v-if="!isFeishu" class="form-item">
<label class="form-label">@ 手机号</label>
<Select
v-model:value="testMobiles"
mode="tags"
:disabled="testAtAll"
placeholder="输入手机号后回车添加"
:token-separators="[',', ' ']"
class="w-full"
/>
</div>
<Alert
v-if="isFeishu"
type="info"
show-icon
message="飞书 webhook 不支持手机号 @ @ 所有人有效"
class="form-alert"
/>
</template>
</div>
</Modal>
</template>
<style scoped lang="scss">
.test-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-weight: 500;
color: var(--ant-color-text, #1d2129);
font-size: 13px;
}
.form-hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 12px;
}
.form-alert {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,348 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, InputPassword, message, Modal as AModal, Tag } from 'ant-design-vue';
import { testSendOaRobot, updateOaRobotWebhook } from '#/views/system/oa-robot/api';
/**
* OA 机器人「单独编辑推送地址」独立弹窗
*
* 设计意图:把 webhook_url 和 secret 的修改从「编辑基础信息」中剥离,
* 避免用户在编辑名称/备注时无意中触发 webhook 重新加密写入。
*
* 关键交互:
* - 顶部 Alert 警示:修改后立即生效,影响所有绑定该机器人的 OA 场景
* - webhook_url 必填(不允许 mask
* - secret 选填placeholder 提示「留空 = 清空加签」
* - 内置「测试发送」按钮:保存前先用新地址测一次,避免改错导致线上通知失效
* - 提交前 Modal.confirm 二次确认,避免误操作
*/
interface TestResultItem {
message_type: string;
name: string;
success: boolean;
message: string;
response: any;
cost_ms: number;
}
interface RobotRow {
id: number;
platform_code: string;
name: string;
}
/** 当前操作的机器人(由列表行传入) */
const currentRobot = ref<RobotRow | null>(null);
/** webhook_url 输入值 */
const webhookUrl = ref('');
/** secret 输入值(空字符串表示清空) */
const secret = ref('');
/** 测试发送 loading */
const testing = ref(false);
/** 最近一次测试发送的结果列表 */
const testResults = ref<TestResultItem[]>([]);
/** 测试发送结果汇总提示 */
const testSummary = ref('');
/**
* 测试发送:用「当前填写」的 webhook_url/secret 临时验证(不依赖数据库)
* 与列表「测试」按钮的区别:列表测试用库里已存的密钥;本弹窗用表单新值
*/
async function handleTestSend() {
if (!currentRobot.value?.id) {
message.warning('机器人信息缺失');
return;
}
if (!currentRobot.value.platform_code) {
message.warning('平台编码缺失');
return;
}
if (!webhookUrl.value || webhookUrl.value === '****') {
message.warning('请填写 webhook 地址');
return;
}
testing.value = true;
testResults.value = [];
testSummary.value = '正在发送测试消息,请稍候(最多 8 种类型)...';
try {
const result = await testSendOaRobot({
// 注意:这里不传 id强制用表单新值不走后端兜底取密钥
platform_code: currentRobot.value.platform_code,
webhook_url: webhookUrl.value,
secret: secret.value || '',
test_content: '萧康云医 OA 测试消息',
});
if (Array.isArray(result?.results)) {
testResults.value = result.results;
const total = result.total ?? result.results.length;
const succ =
result.success_count ??
result.results.filter((r: any) => r.success).length;
const fail = total - succ;
testSummary.value = `${total} 种类型,成功 ${succ} 种,失败 ${fail}`;
if (fail === 0) {
message.success(testSummary.value);
} else if (succ === 0) {
message.error(testSummary.value);
} else {
message.warning(testSummary.value);
}
} else if (result?.success !== undefined) {
testResults.value = [
{
message_type: 'text',
name: '文本消息',
success: result.success,
message: result.message || '',
response: result.response,
cost_ms: 0,
},
];
testSummary.value = result.success ? '测试发送成功' : '测试发送失败';
}
} finally {
testing.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!currentRobot.value?.id) {
message.warning('机器人信息缺失');
return;
}
if (!webhookUrl.value) {
message.warning('请填写 Webhook 地址');
return;
}
if (webhookUrl.value === '****') {
message.warning('请填写完整的 Webhook 地址(不允许 mask');
return;
}
// secret 为 mask 时报错(必须重新输入完整密钥或留空清空)
if (secret.value && secret.value === '****') {
message.warning('请填写完整的加签密钥或留空以清空');
return;
}
// 二次确认:避免误改导致线上 OA 通知失效
AModal.confirm({
title: '确认修改推送地址?',
content:
'修改后将立即生效,会影响所有绑定该机器人的 OA 通知场景。是否继续?',
okType: 'danger',
onOk: async () => {
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateOaRobotWebhook({
id: currentRobot.value!.id,
webhook_url: webhookUrl.value,
secret: secret.value || '',
});
message.success('修改成功');
const grid = modalApi.getData<{ gridApi?: any }>()?.gridApi;
grid?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
});
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ row: RobotRow; gridApi?: any }>();
currentRobot.value = data?.row ?? null;
// 每次打开都重置表单(不回显原 webhook/secret强制用户重新输入避免误操作
webhookUrl.value = '';
secret.value = '';
testResults.value = [];
testSummary.value = '';
if (currentRobot.value) {
modalApi.setState({
title: `编辑推送地址 - ${currentRobot.value.name}`,
confirmLoading: false,
});
}
}
},
});
</script>
<template>
<Modal title="编辑推送地址" class="w-[40%]">
<!-- 顶部警示修改后立即生效影响线上 OA 通知 -->
<Alert
type="warning"
show-icon
message="修改 Webhook 地址后立即生效"
description="会影响所有绑定该机器人的 OA 通知场景。建议先点「测试发送」验证新地址可用后再保存。"
style="margin-bottom: 16px"
/>
<!-- 表单webhook_url 必填 + secret 选填 -->
<div class="webhook-form">
<div class="form-item">
<label class="form-label">
Webhook 地址 <span class="required-mark">*</span>
</label>
<InputPassword
v-model:value="webhookUrl"
placeholder="请粘贴新的 webhook URL"
autocomplete="new-password"
/>
<div class="form-hint">
为防止误操作编辑时不回显原地址需完整粘贴新地址
</div>
</div>
<div class="form-item">
<label class="form-label">加签密钥</label>
<InputPassword
v-model:value="secret"
placeholder="选填,留空表示清空加签密钥"
autocomplete="new-password"
/>
<div class="form-hint">
若原机器人有加签密钥请重新输入完整密钥留空则清空原密钥
</div>
</div>
</div>
<!-- 测试结果展示区 -->
<div v-if="testResults.length > 0 || testSummary" class="test-results">
<Alert
v-if="testSummary"
:type="
testResults.length === 0
? 'info'
: testResults.every((r) => r.success)
? 'success'
: testResults.some((r) => r.success)
? 'warning'
: 'error'
"
:message="testSummary"
show-icon
style="margin-bottom: 12px"
/>
<div v-if="testResults.length > 0" class="result-list">
<div
v-for="item in testResults"
:key="item.message_type"
class="result-item"
>
<div class="result-header">
<Tag :color="item.success ? 'success' : 'error'">
{{ item.success ? '成功' : '失败' }}
</Tag>
<span class="result-name">{{ item.name }}</span>
<span class="result-type">({{ item.message_type }})</span>
<span v-if="item.cost_ms" class="result-cost">
{{ item.cost_ms }}ms
</span>
</div>
<div v-if="!item.success && item.message" class="result-error">
{{ item.message }}
</div>
</div>
</div>
</div>
<!-- 底部按钮取消默认/ 测试发送prepend/ 确定修改confirm -->
<template #prepend-footer>
<Button type="default" :loading="testing" @click="handleTestSend">
测试发送全部类型
</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
/* 暗色适配:全部走 antd CSS 变量 */
.webhook-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-weight: 500;
color: var(--ant-color-text, #1d2129);
font-size: 13px;
}
.required-mark {
color: var(--ant-color-error, #f53f3f);
margin-left: 4px;
}
.form-hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 12px;
}
.test-results {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item {
padding: 8px 12px;
border-radius: 4px;
background: var(--ant-color-fill-quaternary, #f7f8fa);
font-size: 12px;
}
.result-header {
display: flex;
align-items: center;
gap: 6px;
}
.result-name {
font-weight: 500;
color: var(--ant-color-text, #1d2129);
}
.result-type {
color: var(--ant-color-text-tertiary, #86909c);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.result-cost {
margin-left: auto;
color: var(--ant-color-text-tertiary, #86909c);
}
.result-error {
margin-top: 4px;
color: var(--ant-color-error, #f53f3f);
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,33 @@
/**
* OA 平台选项类型(前端动态拉取,禁止写死)
*/
export interface OaPlatformOption {
/** 平台 ID */
id: number;
/** 平台编码(与后端 xk_oa_platform.platform_code 一致) */
platform_code: string;
/** 平台显示名(如 企业微信) */
name: string;
/** 平台图标iconify 图标库) */
icon: string;
/** 1启用 0禁用 */
enabled: number;
/** 排序 */
sort: number;
}
/**
* OA 消息类型选项
*/
export const OA_MESSAGE_TYPE_OPTIONS = [
{ label: '文本', value: 'text' },
{ label: 'Markdown', value: 'markdown' },
];
/**
* 机器人状态选项
*/
export const OA_ROBOT_STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

Some files were not shown because too many files have changed in this diff Show More