Merge remote-tracking branch 'origin/dev/message-oa' into feature/invoice
# Conflicts: # apps/web-antd/src/views/system/system-config/index.vue
This commit is contained in:
@@ -1,10 +1,233 @@
|
||||
---
|
||||
description:
|
||||
description: 萧康云医管理后台(Vben Admin v5 + Vue 3 + TS + Ant Design Vue)代码规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 基础规范(所有项目通用)
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
||||
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
||||
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 # 本模块 API(CRUD)
|
||||
├── 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` 数组
|
||||
|
||||
@@ -61,6 +61,7 @@ export type ComponentType =
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
|
||||
@@ -8,11 +8,17 @@ const props = withDefaults(
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file_type.value 过滤)
|
||||
* 例如图片:[1];文件:[6];语音:[3]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
disabled: false,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,6 +47,7 @@ function onSelect(urls: string[]) {
|
||||
v-model:open="galleryOpen"
|
||||
:multiple="multiple"
|
||||
:max-count="maxCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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';
|
||||
|
||||
@@ -14,10 +14,18 @@ const props = withDefaults(
|
||||
open: boolean;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,6 +42,14 @@ const pageSize = ref(24);
|
||||
const pageSizeOptions = ['12', '24', '48'];
|
||||
const selected = ref<string[]>([]);
|
||||
|
||||
/** 按 acceptTypes 隔离 localStorage,避免图片/视频选择器互相污染 */
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`file-picker-type-${
|
||||
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
|
||||
}`,
|
||||
);
|
||||
|
||||
const {
|
||||
activeType,
|
||||
keyword,
|
||||
@@ -42,14 +58,55 @@ const {
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
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() {
|
||||
if (!canLoadList()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
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;
|
||||
items.value = data?.items ?? [];
|
||||
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(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selected.value = [];
|
||||
page.value = 1;
|
||||
if (!typesLoading.value) {
|
||||
void load();
|
||||
}
|
||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(typesLoading, (val) => {
|
||||
if (!val && props.open) {
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
@@ -143,6 +216,11 @@ function onSearch(value: string) {
|
||||
function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file_type:1=图片) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +233,7 @@ function itemIcon(item: FileGalleryItem) {
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<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>
|
||||
<span class="tab-label">
|
||||
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
||||
@@ -176,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<Spin :spinning="loading || typesLoading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -185,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
|
||||
:class="{ active: isSelected(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">
|
||||
<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 class="gallery-meta">
|
||||
<div class="file-title">
|
||||
|
||||
@@ -30,12 +30,17 @@ interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [1],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -251,6 +256,7 @@ function onTogglePaste() {
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 文件上传(单文件 URL 字符串)
|
||||
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Upload } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
@@ -17,11 +23,17 @@ interface FileItem {
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
maxCount?: number;
|
||||
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
|
||||
acceptTypes?: number[];
|
||||
/** input accept,如 .pdf,.doc 或 audio/* */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [6],
|
||||
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -131,26 +143,50 @@ function handleRemove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const url = urls[0] || '';
|
||||
if (!url) return;
|
||||
syncFileListFromValue(url);
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
const showUploadButton = computed(
|
||||
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<div class="upload-oss-file-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
:accept="accept"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:max-count="1"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-oss-file-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用 Tag 选择器(带头像)
|
||||
*
|
||||
* - 基于 antd Select,multiple 模式默认开启,单选传 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>
|
||||
@@ -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>
|
||||
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal file
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal 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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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>
|
||||
@@ -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>
|
||||
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal file
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal 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 group(pull:clone 模式:左侧库克隆到画布)
|
||||
* - 拖入 / 排序 / 删除时同步内部 instances 状态
|
||||
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSON,emit 给父级
|
||||
*
|
||||
* 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>
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器类型定义
|
||||
*
|
||||
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
|
||||
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
|
||||
*
|
||||
* 核心概念:
|
||||
* - BlockSchema:可拖拽块定义(左侧组件库中展示,决定属性面板渲染什么字段)
|
||||
* - FormFieldSchema:单个字段定义(驱动右侧属性面板表单渲染)
|
||||
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
|
||||
*/
|
||||
|
||||
/**
|
||||
* 字段类型枚举
|
||||
* - string:单行文本
|
||||
* - number:数字
|
||||
* - textarea:多行文本
|
||||
* - select:下拉选择
|
||||
* - radio:单选按钮组
|
||||
* - image:图片素材选择(用 GalleryPickLink,acceptTypes 限定图片)
|
||||
* - url:URL 链接
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -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 { getFileTypes } from '#/api/core/file-gallery';
|
||||
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
||||
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_GROUP_VALUE = 0;
|
||||
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export interface FileGalleryTab {
|
||||
key: string;
|
||||
value: number;
|
||||
@@ -18,7 +17,13 @@ export interface FileGalleryTab {
|
||||
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 activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
@@ -27,6 +32,10 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||
|
||||
function resolveStorageKey() {
|
||||
return toValue(storageKeyInput);
|
||||
}
|
||||
|
||||
const tabs = computed<FileGalleryTab[]>(() => [
|
||||
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
||||
...fileTypes.value.map((item) => ({
|
||||
@@ -48,7 +57,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
]);
|
||||
|
||||
function restoreActiveType() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const saved = localStorage.getItem(resolveStorageKey());
|
||||
if (saved === null) {
|
||||
activeType.value = ALL_TYPE_VALUE;
|
||||
return;
|
||||
@@ -64,7 +73,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
function setActiveType(value: number) {
|
||||
activeType.value = value;
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
localStorage.setItem(resolveStorageKey(), String(value));
|
||||
}
|
||||
|
||||
function setActiveGroupId(value: number) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createAdminGridOptions } from './table-config';
|
||||
import { getRoleMeta } from './role-meta';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.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';
|
||||
|
||||
function formatAdminRegion(row: {
|
||||
@@ -121,6 +122,20 @@ const openDoctorCard = (row: { doctor_id?: number }) => {
|
||||
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> = {
|
||||
checkboxChange() {
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
@@ -210,6 +225,7 @@ const copyToClipboard = async (text: string) => {
|
||||
<Form />
|
||||
</Modal>
|
||||
<DoctorCardModals />
|
||||
<OaAccountModals />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -368,6 +384,13 @@ const copyToClipboard = async (text: string) => {
|
||||
confirm: resetPasswordApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'OA 账号',
|
||||
type: 'link',
|
||||
icon: 'ant-design:notification-outlined',
|
||||
size: 'small',
|
||||
onClick: openOaAccount.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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 等)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 每个启用平台一个 Tab,Tab 内可以添加多条账号(一个员工在一个平台可有多个账号)
|
||||
* - 账号标识和昵称两个输入框: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_id(ou_ 开头)';
|
||||
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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenTextarea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入内容',
|
||||
rows: 4,
|
||||
|
||||
76
apps/web-antd/src/views/system/oa-chat/api/index.ts
Normal file
76
apps/web-antd/src/views/system/oa-chat/api/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* OA 群聊管理 API
|
||||
*
|
||||
* 群聊用于机器人维度的推送分组(N:N 绑定),按平台区分。
|
||||
* 路由前缀:oa-chat/
|
||||
*
|
||||
* 后端约定:
|
||||
* - 标准 CRUD(list/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);
|
||||
}
|
||||
34
apps/web-antd/src/views/system/oa-chat/api/org.ts
Normal file
34
apps/web-antd/src/views/system/oa-chat/api/org.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 企微组织架构 API(OA 群聊页「员工」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 });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 创建企微应用群聊弹窗
|
||||
* 调 appchat/create,成功后回写本地 xk_oa_chat(chat_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>
|
||||
@@ -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>
|
||||
99
apps/web-antd/src/views/system/oa-chat/components/modal.vue
Normal file
99
apps/web-antd/src/views/system/oa-chat/components/modal.vue
Normal 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>
|
||||
108
apps/web-antd/src/views/system/oa-chat/config/form.ts
Normal file
108
apps/web-antd/src/views/system/oa-chat/config/form.ts
Normal 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,
|
||||
};
|
||||
52
apps/web-antd/src/views/system/oa-chat/config/search.ts
Normal file
52
apps/web-antd/src/views/system/oa-chat/config/search.ts
Normal 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,
|
||||
};
|
||||
96
apps/web-antd/src/views/system/oa-chat/config/table.ts
Normal file
96
apps/web-antd/src/views/system/oa-chat/config/table.ts
Normal 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,
|
||||
};
|
||||
230
apps/web-antd/src/views/system/oa-chat/index.vue
Normal file
230
apps/web-antd/src/views/system/oa-chat/index.vue
Normal 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>
|
||||
30
apps/web-antd/src/views/system/oa-cron-task/api/index.ts
Normal file
30
apps/web-antd/src/views/system/oa-cron-task/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览用 payload(template_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>
|
||||
146
apps/web-antd/src/views/system/oa-cron-task/components/modal.vue
Normal file
146
apps/web-antd/src/views/system/oa-cron-task/components/modal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* OA 定时任务 — 配置发送用户弹窗(按平台分流)
|
||||
* - work_wechat_app:SceneTargetsPanel(个人/客户群 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_users(webhook @ 人)
|
||||
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>
|
||||
98
apps/web-antd/src/views/system/oa-cron-task/config/form.ts
Normal file
98
apps/web-antd/src/views/system/oa-cron-task/config/form.ts
Normal 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,
|
||||
};
|
||||
46
apps/web-antd/src/views/system/oa-cron-task/config/search.ts
Normal file
46
apps/web-antd/src/views/system/oa-cron-task/config/search.ts
Normal 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,
|
||||
};
|
||||
117
apps/web-antd/src/views/system/oa-cron-task/config/table.ts
Normal file
117
apps/web-antd/src/views/system/oa-cron-task/config/table.ts
Normal 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,
|
||||
};
|
||||
161
apps/web-antd/src/views/system/oa-cron-task/index.vue
Normal file
161
apps/web-antd/src/views/system/oa-cron-task/index.vue
Normal 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>
|
||||
21
apps/web-antd/src/views/system/oa-notify-log/api/index.ts
Normal file
21
apps/web-antd/src/views/system/oa-notify-log/api/index.ts
Normal 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 } });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
78
apps/web-antd/src/views/system/oa-notify-log/config/table.ts
Normal file
78
apps/web-antd/src/views/system/oa-notify-log/config/table.ts
Normal 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,
|
||||
};
|
||||
144
apps/web-antd/src/views/system/oa-notify-log/index.vue
Normal file
144
apps/web-antd/src/views/system/oa-notify-log/index.vue
Normal 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>
|
||||
44
apps/web-antd/src/views/system/oa-platform/api/index.ts
Normal file
44
apps/web-antd/src/views/system/oa-platform/api/index.ts
Normal 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);
|
||||
}
|
||||
155
apps/web-antd/src/views/system/oa-robot/api/index.ts
Normal file
155
apps/web-antd/src/views/system/oa-robot/api/index.ts
Normal 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/secret(webhook_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:本地群聊 id(xk_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 });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
631
apps/web-antd/src/views/system/oa-robot/components/modal.vue
Normal file
631
apps/web-antd/src/views/system/oa-robot/components/modal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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[]>([]);
|
||||
/** 应用 API:person | 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>
|
||||
@@ -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>
|
||||
33
apps/web-antd/src/views/system/oa-robot/config/constants.ts
Normal file
33
apps/web-antd/src/views/system/oa-robot/config/constants.ts
Normal 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 },
|
||||
];
|
||||
265
apps/web-antd/src/views/system/oa-robot/config/form.ts
Normal file
265
apps/web-antd/src/views/system/oa-robot/config/form.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/**
|
||||
* 机器人表单 Schema 拆分说明
|
||||
*
|
||||
* - createFormSchema:新增用,按 platform_code / use_platform_credential 切换字段
|
||||
* - editFormSchema:编辑基础信息(不含密钥);应用 API 可改 chat_id
|
||||
*
|
||||
* 注意:secret 字段全局唯一(webhook 加签与应用 Secret 复用同一 fieldName)
|
||||
*/
|
||||
|
||||
/** 是否应用 API 平台 */
|
||||
export function isWorkWechatApp(platformCode?: string) {
|
||||
return platformCode === 'work_wechat_app';
|
||||
}
|
||||
|
||||
/** 是否使用平台默认凭证(1=是) */
|
||||
export function isUsePlatformCredential(values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) {
|
||||
return (
|
||||
isWorkWechatApp(values.platform_code) &&
|
||||
Number(values.use_platform_credential ?? 1) === 1
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否展示自定义凭证字段(应用 API + 自定义) */
|
||||
export function showCustomAppCredential(values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) {
|
||||
return (
|
||||
isWorkWechatApp(values.platform_code) &&
|
||||
Number(values.use_platform_credential ?? 1) === 0
|
||||
);
|
||||
}
|
||||
|
||||
const baseSchema = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
formItemClass: 'col-span-6',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [],
|
||||
},
|
||||
fieldName: 'platform_code',
|
||||
label: '所属平台',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入机器人名称(便于识别)',
|
||||
},
|
||||
fieldName: 'name',
|
||||
label: '机器人名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '可选,备注说明(如:财务群-企业微信)',
|
||||
rows: 3,
|
||||
},
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
|
||||
/** 新增时的凭证字段(按平台 + 凭证策略切换展示) */
|
||||
const createCredentialSchema = [
|
||||
{
|
||||
// 仅新增态展示:选择同平台已有机器人,回填凭证策略与 chat_id 等
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '可选,选择后回填凭证与群聊(不含名称)',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
options: [],
|
||||
},
|
||||
fieldName: 'clone_from_id',
|
||||
label: '克隆机器人',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '使用平台默认凭证', value: 1 },
|
||||
{ label: '自定义凭证', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'use_platform_credential',
|
||||
label: '凭证来源',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
rules: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code) ? 'selectRequired' : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请粘贴 webhook URL',
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
fieldName: 'webhook_url',
|
||||
label: 'Webhook 地址',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
!isWorkWechatApp(values.platform_code),
|
||||
rules: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code) ? null : 'required',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '企业微信企业 ID(corpid)',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'corp_id',
|
||||
label: '企业 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => showCustomAppCredential(values),
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => (showCustomAppCredential(values) ? 'required' : null),
|
||||
},
|
||||
},
|
||||
{
|
||||
// webhook 加签密钥 / 应用 Secret 共用 fieldName=secret
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '钉钉/应用API自定义必填;企微 webhook、飞书选填',
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
fieldName: 'secret',
|
||||
label: '密钥',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => {
|
||||
if (isWorkWechatApp(values.platform_code)) {
|
||||
return showCustomAppCredential(values);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => {
|
||||
if (showCustomAppCredential(values)) return 'required';
|
||||
if (values.platform_code === 'dingtalk') return 'required';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '企业应用 agentid',
|
||||
min: 1,
|
||||
class: 'w-full',
|
||||
},
|
||||
fieldName: 'agent_id',
|
||||
label: '应用 AgentId',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => showCustomAppCredential(values),
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => (showCustomAppCredential(values) ? 'required' : null),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可选;兼容旧数据。新流程请在场景中选群',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'chat_id',
|
||||
label: '默认群聊 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 编辑态可改 chat_id(应用 API);改凭证走独立弹窗 */
|
||||
const editAppChatSchema = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可选;场景选群为主。填写后可同步成员',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'chat_id',
|
||||
label: '默认群聊 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const createFormSchema = [...baseSchema, ...createCredentialSchema];
|
||||
export const editFormSchema = [...baseSchema, ...editAppChatSchema];
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: createFormSchema,
|
||||
showDefaultActions: false,
|
||||
};
|
||||
47
apps/web-antd/src/views/system/oa-robot/config/search.ts
Normal file
47
apps/web-antd/src/views/system/oa-robot/config/search.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/**
|
||||
* 机器人搜索表单配置
|
||||
*/
|
||||
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,
|
||||
};
|
||||
76
apps/web-antd/src/views/system/oa-robot/config/table.ts
Normal file
76
apps/web-antd/src/views/system/oa-robot/config/table.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getOaRobotList } from '#/views/system/oa-robot/api';
|
||||
|
||||
interface RowType {
|
||||
id: number;
|
||||
platform_code: string;
|
||||
name: string;
|
||||
webhook_url: string;
|
||||
secret: string;
|
||||
status: number;
|
||||
remark: string;
|
||||
chat_ids: number[];
|
||||
chat_names: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 机器人列表表格配置
|
||||
* 列表中的 webhook_url 和 secret 字段由后端做了 mask('****' 或 ''),不会泄露明文
|
||||
* chat_names:后端按「、」拼接的群聊名称串(展示用),详情接口才返回 chat_ids 数组(编辑回显用)
|
||||
*/
|
||||
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_names', align: 'left', title: '绑定群聊/群ID', slots: { default: 'chat_names' } },
|
||||
{ field: 'webhook_url', align: 'left', title: '凭证', width: 130, slots: { default: 'webhook_status' } },
|
||||
{ field: 'secret', align: 'left', title: '密钥', width: 100, slots: { default: 'secret_status' } },
|
||||
{ field: 'status', align: 'left', title: '状态', width: 100, slots: { default: 'status' } },
|
||||
{ field: 'remark', align: 'left', title: '备注' },
|
||||
{ field: 'created_at', align: 'left', title: '创建时间', width: 180 },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 320, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getOaRobotList({
|
||||
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,
|
||||
};
|
||||
330
apps/web-antd/src/views/system/oa-robot/index.vue
Normal file
330
apps/web-antd/src/views/system/oa-robot/index.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, 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 { deleteOaRobot } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ChatBindModal from './components/chat-bind-modal.vue';
|
||||
import TestSendModal from './components/test-send-modal.vue';
|
||||
import TestResultModal from './components/test-result-modal.vue';
|
||||
import WebhookModal from './components/webhook-modal.vue';
|
||||
import AppCredentialModal from './components/app-credential-modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { isWorkWechatApp } from './config/form';
|
||||
import { getOaRobotInfo } from './api';
|
||||
|
||||
/**
|
||||
* OA 机器人管理列表页
|
||||
* 顶部支持按名称/平台编码/状态搜索
|
||||
* 操作:新增、编辑(基础信息)、改地址(webhook/secret)、绑定群聊、测试、删除(单个/批量)
|
||||
*/
|
||||
defineOptions({ name: 'OaRobot' });
|
||||
|
||||
const hasTopTableDropDownActions = 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 [TestModal, testModalApi] = useVbenModal({
|
||||
connectedComponent: TestSendModal,
|
||||
});
|
||||
|
||||
const [TestResultEditModal, testResultModalApi] = useVbenModal({
|
||||
connectedComponent: TestResultModal,
|
||||
});
|
||||
|
||||
const [WebhookEditModal, webhookModalApi] = useVbenModal({
|
||||
connectedComponent: WebhookModal,
|
||||
});
|
||||
|
||||
const [ChatBindEditModal, chatBindModalApi] = useVbenModal({
|
||||
connectedComponent: ChatBindModal,
|
||||
});
|
||||
|
||||
const [AppCredentialEditModal, appCredentialModalApi] = useVbenModal({
|
||||
connectedComponent: AppCredentialModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增/编辑弹窗
|
||||
* 编辑时拉详情(含 corp_id/chat_id/members),避免列表 mask/缺字段
|
||||
*/
|
||||
const showModal = async (data: any = {}, isUpdate = false) => {
|
||||
let values = data;
|
||||
if (isUpdate && data?.id) {
|
||||
try {
|
||||
values = await getOaRobotInfo(data.id);
|
||||
} catch (e) {
|
||||
console.error('加载机器人详情失败', e);
|
||||
values = data;
|
||||
}
|
||||
}
|
||||
formModalApi.setData({
|
||||
values,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开行级测试发送弹窗
|
||||
* 传入完整 row(含 id、platform_code、name),后端按 id 自动取已加密密钥
|
||||
* 注入 onFinished 回调:发送完成后关闭参数弹窗,打开结果弹窗
|
||||
*
|
||||
* onFinished payload 字段透传给 test-result-modal.vue 的 setData:
|
||||
* - results / summary:核心结果数据
|
||||
* - robotName / robotId / platformCode:机器人上下文
|
||||
* - testContent / testAtAll / testMobiles:测试参数
|
||||
* - sentAt:发送完成时间戳(秒)
|
||||
*/
|
||||
const openTestModal = (row: any) => {
|
||||
testModalApi.setData({
|
||||
row,
|
||||
onFinished: (payload: any) => {
|
||||
// 参数弹窗会在调用 onFinished 前自行关闭,这里只负责打开结果弹窗
|
||||
testResultModalApi.setData(payload);
|
||||
testResultModalApi.open();
|
||||
},
|
||||
});
|
||||
testModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开「改地址」独立弹窗
|
||||
* 仅修改 webhook_url 和 secret,不动其他字段
|
||||
*/
|
||||
const openWebhookModal = (row: any) => {
|
||||
webhookModalApi.setData({ row, gridApi });
|
||||
webhookModalApi.open();
|
||||
};
|
||||
|
||||
/** 打开应用 API「改凭证」弹窗(拉详情拿 agent_id / use_platform_credential) */
|
||||
const openAppCredentialModal = async (row: any) => {
|
||||
let detail = row;
|
||||
if (row?.id) {
|
||||
try {
|
||||
detail = await getOaRobotInfo(row.id);
|
||||
} catch (e) {
|
||||
console.error('加载机器人凭证详情失败', e);
|
||||
}
|
||||
}
|
||||
appCredentialModalApi.setData({ row: detail, gridApi });
|
||||
appCredentialModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开「绑定群聊」独立弹窗
|
||||
* 入口:列表「chat_names」列的「未绑定」link 按钮(也支持手动调用)
|
||||
* 仅同步机器人-群聊关系,不动其他字段
|
||||
*/
|
||||
const openChatBindModal = (row: any) => {
|
||||
chatBindModalApi.setData({ row, gridApi });
|
||||
chatBindModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除机器人(支持单个和批量)
|
||||
*/
|
||||
const deleteApi = (row: any) => {
|
||||
let ids: any[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
|
||||
}
|
||||
deleteOaRobot({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载平台列表(用于表格中 platform_code 列展示成中文名)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
loadPlatforms();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="OA 机器人管理">
|
||||
<FormModal />
|
||||
<TestModal />
|
||||
<TestResultEditModal />
|
||||
<WebhookEditModal />
|
||||
<AppCredentialEditModal />
|
||||
<ChatBindEditModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
: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>
|
||||
<!--
|
||||
绑定群聊列:
|
||||
- 有绑定:直接展示后端拼接好的群聊名称串
|
||||
- 未绑定:显示可点击的「未绑定」link 按钮,点击后打开独立绑定弹窗
|
||||
-->
|
||||
<template #chat_names="{ row }">
|
||||
<!-- 应用 API:展示 chatid,不走 N:N 绑定弹窗 -->
|
||||
<span v-if="isWorkWechatApp(row.platform_code)">
|
||||
{{ row.chat_id || row.chat_names || '未配置群聊' }}
|
||||
</span>
|
||||
<span v-else-if="row.chat_names">{{ row.chat_names }}</span>
|
||||
<TableAction
|
||||
v-else
|
||||
:actions="[
|
||||
{
|
||||
label: '未绑定',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
class: '!px-0',
|
||||
onClick: openChatBindModal.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<!-- Webhook / 应用凭证状态 -->
|
||||
<template #webhook_status="{ row }">
|
||||
<Tag
|
||||
v-if="isWorkWechatApp(row.platform_code)"
|
||||
:color="row.has_app_credential ? 'success' : 'default'"
|
||||
>
|
||||
{{ row.has_app_credential ? '应用已配置' : '应用未配置' }}
|
||||
</Tag>
|
||||
<Tag
|
||||
v-else
|
||||
:color="row.has_webhook || row.webhook_url ? 'success' : 'default'"
|
||||
>
|
||||
{{ row.has_webhook || row.webhook_url ? '已配置' : '未配置' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<!-- Secret 状态列 -->
|
||||
<template #secret_status="{ row }">
|
||||
<Tag :color="row.has_secret || row.secret ? 'success' : 'default'">
|
||||
{{ row.has_secret || row.secret ? '已配置' : '未配置' }}
|
||||
</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: isWorkWechatApp(row.platform_code) ? '改凭证' : '改地址',
|
||||
type: 'link',
|
||||
icon: isWorkWechatApp(row.platform_code)
|
||||
? 'ant-design:key-outlined'
|
||||
: 'ant-design:link-outlined',
|
||||
size: 'small',
|
||||
onClick: isWorkWechatApp(row.platform_code)
|
||||
? openAppCredentialModal.bind(null, row)
|
||||
: openWebhookModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
type: 'link',
|
||||
icon: 'ant-design:thunderbolt-outlined',
|
||||
size: 'small',
|
||||
onClick: openTestModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
131
apps/web-antd/src/views/system/oa-scene/api/index.ts
Normal file
131
apps/web-antd/src/views/system/oa-scene/api/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* OA 场景管理 API
|
||||
* 路由前缀:oa-scene/
|
||||
*/
|
||||
const prefix = 'oa-scene/';
|
||||
|
||||
/**
|
||||
* 分页查询场景列表
|
||||
*/
|
||||
export async function getOaSceneList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景详情(含 robot_ids 和 at_users 字段)
|
||||
*/
|
||||
export async function getOaSceneInfo(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增场景
|
||||
* data 需包含:scene_code、scene_name、message_type、description、status、robot_ids、at_users
|
||||
*/
|
||||
export async function createOaScene(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑场景(scene_code 不允许修改)
|
||||
*/
|
||||
export async function updateOaScene(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除场景(支持批量)
|
||||
*/
|
||||
export async function deleteOaScene(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有平台支持的消息类型列表(含 payload_schema)
|
||||
* 返回结构:{ platform_code: [{ message_type, name, icon, need_media, payload_schema }, ...] }
|
||||
* 前端场景弹窗打开时调用,按平台 Tab 动态渲染消息类型选项 + payload 表单
|
||||
*/
|
||||
export async function getOaMessageTypes() {
|
||||
return requestClient.get<any>(`${prefix}message-types`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景测试发送(同步)
|
||||
* 可传 id(已保存)或草稿 message_config + robot_ids;test_params 覆盖正文类字段
|
||||
* platform_codes(任务 6):可选指定只测试某些平台,未在列表中的机器人直接跳过
|
||||
*/
|
||||
export async function testSendOaScene(data: {
|
||||
id?: number;
|
||||
scene_code?: string;
|
||||
scene_name?: string;
|
||||
message_config?: Record<string, any>;
|
||||
robot_ids?: number[];
|
||||
at_users?: Record<string, any>;
|
||||
targets?: any[];
|
||||
platform_codes?: string[];
|
||||
test_params?: {
|
||||
content?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
media_url?: string;
|
||||
};
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}test-send`, data);
|
||||
}
|
||||
|
||||
/* ===================================================================== *
|
||||
* OA 测试发送手机号记忆库(任务 5)
|
||||
* 路由前缀:oa-test-phone/
|
||||
* 按 admin_id + platform_code 维度隔离,按 use_count 倒序常用排序
|
||||
* ===================================================================== */
|
||||
const phonePrefix = 'oa-test-phone/';
|
||||
|
||||
/** 单条手机号记忆记录 */
|
||||
export interface OaTestPhoneItem {
|
||||
id: number;
|
||||
phone: string;
|
||||
name?: string;
|
||||
use_count: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取当前操作员在某平台下的手机号记忆列表(按常用排序)
|
||||
* @param platformCode 平台编码
|
||||
*/
|
||||
export async function getOaTestPhones(platformCode: string) {
|
||||
return requestClient.get<OaTestPhoneItem[]>(`${phonePrefix}list-by-platform`, {
|
||||
params: { platform_code: platformCode },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次手机号使用(upsert + use_count++)
|
||||
* @param data { platform_code, phone, name? }
|
||||
*/
|
||||
export async function recordOaTestPhone(data: {
|
||||
platform_code: string;
|
||||
phone: string;
|
||||
name?: string;
|
||||
}) {
|
||||
return requestClient.post<OaTestPhoneItem>(`${phonePrefix}record`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量记录多个手机号(一次测试发送可能 @ 多人)
|
||||
*/
|
||||
export async function recordOaTestPhonesBatch(data: {
|
||||
platform_code: string;
|
||||
phones: string[];
|
||||
}) {
|
||||
return requestClient.post<any>(`${phonePrefix}record-batch`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条手机号记忆
|
||||
*/
|
||||
export async function deleteOaTestPhone(id: number) {
|
||||
return requestClient.post<any>(`${phonePrefix}delete`, { id });
|
||||
}
|
||||
1978
apps/web-antd/src/views/system/oa-scene/components/form-page.vue
Normal file
1978
apps/web-antd/src/views/system/oa-scene/components/form-page.vue
Normal file
File diff suppressed because it is too large
Load Diff
1107
apps/web-antd/src/views/system/oa-scene/components/modal.vue
Normal file
1107
apps/web-antd/src/views/system/oa-scene/components/modal.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 场景列表:点击平台标签预览该平台消息配置
|
||||
* 数据由列表页 modalApi.setData 注入:platformName / platformCode / messageType / payload
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Empty } from 'ant-design-vue';
|
||||
|
||||
import MsgPreview from '#/views/system/oa-scene/components/msg-preview.vue';
|
||||
|
||||
const platformName = ref('');
|
||||
const platformCode = ref('');
|
||||
const messageType = ref('');
|
||||
const payload = ref<Record<string, any>>({});
|
||||
const hasConfig = ref(false);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '消息预览',
|
||||
footer: false,
|
||||
draggable: true,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const data = modalApi.getData<any>() || {};
|
||||
platformName.value = String(data.platformName || data.platform_name || '');
|
||||
platformCode.value = String(data.platformCode || data.platform_code || '');
|
||||
messageType.value = String(data.messageType || data.message_type || '');
|
||||
payload.value =
|
||||
data.payload && typeof data.payload === 'object' ? data.payload : {};
|
||||
hasConfig.value = !!messageType.value;
|
||||
modalApi.setState({
|
||||
title: platformName.value
|
||||
? `消息预览 · ${platformName.value}`
|
||||
: '消息预览',
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[480px]">
|
||||
<div v-if="hasConfig" class="preview-wrap">
|
||||
<MsgPreview
|
||||
:platform-code="platformCode"
|
||||
:message-type="messageType"
|
||||
:payload="payload"
|
||||
/>
|
||||
</div>
|
||||
<Empty v-else description="该平台暂无消息配置" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-wrap {
|
||||
padding: 8px 4px 12px;
|
||||
min-height: 120px;
|
||||
}
|
||||
</style>
|
||||
1244
apps/web-antd/src/views/system/oa-scene/components/msg-preview.vue
Normal file
1244
apps/web-antd/src/views/system/oa-scene/components/msg-preview.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OA 场景 - 钉钉 actionCard 按钮列表(btns)
|
||||
*
|
||||
* 用于 dingtalk 平台 actionCard 消息类型的 btns 字段编辑。
|
||||
* 每条 btn 字段对齐钉钉官方结构(DingTalkChannel::buildPayload case 'actionCard'):
|
||||
* - title 按钮文案(必填)
|
||||
* - actionURL 跳转 URL(必填)
|
||||
*
|
||||
* 受 maxCount 限制(钉钉限制 6 个)。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Input } from 'ant-design-vue';
|
||||
|
||||
interface ActionButton {
|
||||
title?: string;
|
||||
actionURL?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 btns 数组 */
|
||||
modelValue?: ActionButton[];
|
||||
/** 最大条数 */
|
||||
maxCount?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
maxCount: 6,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ActionButton[]];
|
||||
}>();
|
||||
|
||||
const list = computed<ActionButton[]>({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const canAdd = computed(() => list.value.length < props.maxCount);
|
||||
|
||||
function updateItem(index: number, key: keyof ActionButton, value: string) {
|
||||
list.value = list.value.map((item, i) =>
|
||||
i === index ? { ...item, [key]: value } : item,
|
||||
);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (!canAdd.value) return;
|
||||
list.value = [...list.value, {}];
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
list.value = list.value.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="button-list">
|
||||
<div
|
||||
v-for="(item, idx) in list"
|
||||
:key="idx"
|
||||
class="btn-row"
|
||||
>
|
||||
<span class="btn-index">{{ idx + 1 }}</span>
|
||||
<Input
|
||||
:value="item.title"
|
||||
placeholder="按钮文案(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
class="btn-input"
|
||||
@update:value="(v: string) => updateItem(idx, 'title', v)"
|
||||
/>
|
||||
<Input
|
||||
:value="item.actionURL"
|
||||
placeholder="跳转 URL(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
class="btn-input btn-url"
|
||||
@update:value="(v: string) => updateItem(idx, 'actionURL', v)"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="btn-del"
|
||||
@click="removeItem(idx)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="canAdd"
|
||||
type="dashed"
|
||||
size="small"
|
||||
block
|
||||
@click="addItem"
|
||||
>
|
||||
+ 添加按钮({{ list.length }}/{{ maxCount }})
|
||||
</Button>
|
||||
<div v-else-if="list.length > 0" class="max-hint">
|
||||
已达上限 {{ maxCount }} 条
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.button-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed var(--ant-color-split, #f0f0f0);
|
||||
}
|
||||
|
||||
.btn-index {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.max-hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OA 场景 - 钉钉 feedCard 链接列表(links)
|
||||
*
|
||||
* 用于 dingtalk 平台 feedCard 消息类型的 links 字段编辑。
|
||||
* 每条 link 字段对齐钉钉官方结构(DingTalkChannel::buildPayload case 'feedCard'):
|
||||
* - title 标题(必填)
|
||||
* - messageURL 跳转链接(必填)
|
||||
* - picURL 封面图(可选,从素材库选图片 xk_file.type=0)
|
||||
*
|
||||
* 受 maxCount 限制(默认 10)。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Input } from 'ant-design-vue';
|
||||
|
||||
import UploadImage from '#/components/form/components/upload-image.vue';
|
||||
|
||||
interface FeedLink {
|
||||
title?: string;
|
||||
messageURL?: string;
|
||||
picURL?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 links 数组 */
|
||||
modelValue?: FeedLink[];
|
||||
/** 最大条数 */
|
||||
maxCount?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
maxCount: 10,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: FeedLink[]];
|
||||
}>();
|
||||
|
||||
const list = computed<FeedLink[]>({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const canAdd = computed(() => list.value.length < props.maxCount);
|
||||
|
||||
function updateItem(index: number, key: keyof FeedLink, value: string) {
|
||||
list.value = list.value.map((item, i) =>
|
||||
i === index ? { ...item, [key]: value } : item,
|
||||
);
|
||||
}
|
||||
|
||||
function updateImage(index: number, urls: string[]) {
|
||||
updateItem(index, 'picURL', urls[0] || '');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (!canAdd.value) return;
|
||||
list.value = [...list.value, {}];
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
list.value = list.value.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="link-list">
|
||||
<div
|
||||
v-for="(item, idx) in list"
|
||||
:key="idx"
|
||||
class="link-item"
|
||||
>
|
||||
<div class="link-head">
|
||||
<span class="link-index">链接 {{ idx + 1 }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="link-del"
|
||||
@click="removeItem(idx)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="link-form">
|
||||
<div class="form-row">
|
||||
<label class="row-label">
|
||||
标题<span class="req">*</span>
|
||||
</label>
|
||||
<Input
|
||||
:value="item.title"
|
||||
placeholder="链接标题(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="(v: string) => updateItem(idx, 'title', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="row-label">
|
||||
跳转链接<span class="req">*</span>
|
||||
</label>
|
||||
<Input
|
||||
:value="item.messageURL"
|
||||
placeholder="https://(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="(v: string) => updateItem(idx, 'messageURL', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="row-label">封面图(可选)</label>
|
||||
<UploadImage
|
||||
:model-value="item.picURL ? [item.picURL] : []"
|
||||
:multiple="false"
|
||||
:max-count="1"
|
||||
:accept-types="[0]"
|
||||
@update:model-value="(urls: string[]) => updateImage(idx, urls)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="canAdd"
|
||||
type="dashed"
|
||||
size="small"
|
||||
block
|
||||
@click="addItem"
|
||||
>
|
||||
+ 添加链接({{ list.length }}/{{ maxCount }})
|
||||
</Button>
|
||||
<div v-else-if="list.length > 0" class="max-hint">
|
||||
已达上限 {{ maxCount }} 条
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.link-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.link-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.link-index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.link-del {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.req {
|
||||
margin-left: 2px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
}
|
||||
|
||||
.max-hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OA 场景 - 企业微信图文消息列表(news)
|
||||
*
|
||||
* 用于 work_wechat / work_wechat_app 平台 news 消息类型的 articles 字段编辑。
|
||||
* 每条 article 字段对齐企微官方结构(WorkWechatAppChannel::buildPayload case 'news'):
|
||||
* - title 标题(必填)
|
||||
* - description 描述(必填)
|
||||
* - url 点击跳转链接(必填)
|
||||
* - picurl 封面图 URL(从素材库选;xk_file.type=0 即图片)
|
||||
*
|
||||
* 受 maxCount 限制(企微限制 1~8 条)。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Input, Textarea } from 'ant-design-vue';
|
||||
|
||||
import UploadImage from '#/components/form/components/upload-image.vue';
|
||||
|
||||
interface NewsArticle {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
picurl?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 articles 数组 */
|
||||
modelValue?: NewsArticle[];
|
||||
/** 最大条数(企微限制 8) */
|
||||
maxCount?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
maxCount: 8,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: NewsArticle[]];
|
||||
}>();
|
||||
|
||||
/** 内部可变副本(每次更新都重新生成新数组触发响应) */
|
||||
const list = computed<NewsArticle[]>({
|
||||
get: () => Array.isArray(props.modelValue) ? props.modelValue : [],
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
/** 是否允许新增 */
|
||||
const canAdd = computed(() => list.value.length < props.maxCount);
|
||||
|
||||
/** 更新指定下标 article 的字段 */
|
||||
function updateItem(index: number, key: keyof NewsArticle, value: string) {
|
||||
const next = list.value.map((item, i) =>
|
||||
i === index ? { ...item, [key]: value } : item,
|
||||
);
|
||||
list.value = next;
|
||||
}
|
||||
|
||||
/** 更新封面图 URL(UploadImage 返回 string[]) */
|
||||
function updateImage(index: number, urls: string[]) {
|
||||
updateItem(index, 'picurl', urls[0] || '');
|
||||
}
|
||||
|
||||
/** 新增一条空白 article */
|
||||
function addItem() {
|
||||
if (!canAdd.value) return;
|
||||
list.value = [...list.value, {}];
|
||||
}
|
||||
|
||||
/** 删除指定 article */
|
||||
function removeItem(index: number) {
|
||||
list.value = list.value.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="news-list">
|
||||
<div
|
||||
v-for="(item, idx) in list"
|
||||
:key="idx"
|
||||
class="news-item"
|
||||
>
|
||||
<div class="news-head">
|
||||
<span class="news-index">图文 {{ idx + 1 }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="news-del"
|
||||
@click="removeItem(idx)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="news-form">
|
||||
<div class="form-row">
|
||||
<label class="row-label">
|
||||
标题<span class="req">*</span>
|
||||
</label>
|
||||
<Input
|
||||
:value="item.title"
|
||||
placeholder="图文标题(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="(v: string) => updateItem(idx, 'title', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="row-label">
|
||||
描述<span class="req">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
:value="item.description"
|
||||
placeholder="图文描述(必填)"
|
||||
:rows="2"
|
||||
size="small"
|
||||
@update:value="(v: string) => updateItem(idx, 'description', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="row-label">
|
||||
跳转链接<span class="req">*</span>
|
||||
</label>
|
||||
<Input
|
||||
:value="item.url"
|
||||
placeholder="https://(必填)"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="(v: string) => updateItem(idx, 'url', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="row-label">封面图</label>
|
||||
<UploadImage
|
||||
:model-value="item.picurl ? [item.picurl] : []"
|
||||
:multiple="false"
|
||||
:max-count="1"
|
||||
:accept-types="[0]"
|
||||
@update:model-value="(urls: string[]) => updateImage(idx, urls)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="canAdd"
|
||||
type="dashed"
|
||||
size="small"
|
||||
block
|
||||
@click="addItem"
|
||||
>
|
||||
+ 添加图文({{ list.length }}/{{ maxCount }})
|
||||
</Button>
|
||||
<div v-else-if="list.length > 0" class="max-hint">
|
||||
已达上限 {{ maxCount }} 条
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.news-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.news-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.news-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.news-index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.news-del {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.news-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.req {
|
||||
margin-left: 2px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
}
|
||||
|
||||
.max-hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OA 场景 - 飞书 post 富文本编辑器(简化版)
|
||||
*
|
||||
* 用于 feishu 平台 post 消息类型编辑。
|
||||
* 后端期望结构(FeishuChannel::buildPayload case 'post'):
|
||||
* {
|
||||
* zh_cn: {
|
||||
* title: string,
|
||||
* content: [[ {tag:'text', text}, {tag:'a', href, text}, ... ]]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* 简化方案:每段一个 Textarea(一行节点),段内用 markdown 风格表达链接:
|
||||
* - `[文字](url)` → { tag:'a', href:url, text:文字 }
|
||||
* - 其余文字 → { tag:'text', text:文字 }
|
||||
*
|
||||
* 不支持复杂富文本(图片/at/表情等节点),用户选项 all-four 表示"要能用"即可。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Input, Textarea } from 'ant-design-vue';
|
||||
|
||||
interface PostNode {
|
||||
tag: 'text' | 'a';
|
||||
text: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface FeishuPost {
|
||||
zh_cn?: {
|
||||
title?: string;
|
||||
content?: PostNode[][];
|
||||
};
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 post 数据(含 zh_cn 包裹) */
|
||||
modelValue?: FeishuPost;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: FeishuPost];
|
||||
}>();
|
||||
|
||||
/** 标题(双向) */
|
||||
const title = computed<string>({
|
||||
get: () => props.modelValue?.zh_cn?.title || '',
|
||||
set: (val) => emitValue(val, paragraphs.value),
|
||||
});
|
||||
|
||||
/** 段落(双向;每段是原始字符串,提交时解析为 PostNode[]) */
|
||||
const paragraphs = computed<string[]>({
|
||||
get: () => props.modelValue?.zh_cn?.content?.map(stringifyNodes) ?? [],
|
||||
set: (val) => emitValue(title.value, val),
|
||||
});
|
||||
|
||||
function emitValue(newTitle: string, newParagraphs: string[]) {
|
||||
emit('update:modelValue', {
|
||||
zh_cn: {
|
||||
title: newTitle,
|
||||
content: newParagraphs.map(parseParagraph).filter((p) => p.length > 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一段文本解析为飞书 post 节点数组
|
||||
* 支持 markdown 风格链接:[text](url)
|
||||
*/
|
||||
function parseParagraph(text: string): PostNode[] {
|
||||
if (!text) return [];
|
||||
const nodes: PostNode[] = [];
|
||||
// 链接正则:[文字](url),url 必须以 http(s):// 开头
|
||||
const regex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push({ tag: 'text', text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
nodes.push({ tag: 'a', text: match[1]!, href: match[2]! });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push({ tag: 'text', text: text.slice(lastIndex) });
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 反向:把节点数组还原为可编辑的字符串([文字](url)) */
|
||||
function stringifyNodes(nodes: PostNode[] | undefined): string {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return '';
|
||||
return nodes
|
||||
.map((n) => {
|
||||
if (n.tag === 'a' && n.href) {
|
||||
return `[${n.text}](${n.href})`;
|
||||
}
|
||||
return n.text || '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function updateParagraph(index: number, value: string) {
|
||||
const next = [...paragraphs.value];
|
||||
next[index] = value;
|
||||
paragraphs.value = next;
|
||||
}
|
||||
|
||||
function addParagraph() {
|
||||
paragraphs.value = [...paragraphs.value, ''];
|
||||
}
|
||||
|
||||
function removeParagraph(index: number) {
|
||||
paragraphs.value = paragraphs.value.filter((_, i) => i !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="post-editor">
|
||||
<div class="form-row">
|
||||
<label class="row-label">标题</label>
|
||||
<Input
|
||||
:value="title"
|
||||
placeholder="富文本标题(可选)"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="(v: string) => (title = v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="paragraphs">
|
||||
<div
|
||||
v-for="(p, idx) in paragraphs"
|
||||
:key="idx"
|
||||
class="paragraph-row"
|
||||
>
|
||||
<div class="paragraph-head">
|
||||
<span class="paragraph-index">段落 {{ idx + 1 }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="paragraph-del"
|
||||
@click="removeParagraph(idx)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
:value="p"
|
||||
:rows="2"
|
||||
size="small"
|
||||
placeholder="支持 [文字](https://链接) 语法"
|
||||
@update:value="(v: string) => updateParagraph(idx, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="dashed" size="small" block @click="addParagraph">
|
||||
+ 添加段落
|
||||
</Button>
|
||||
|
||||
<div class="hint">
|
||||
提示:链接语法 <code>[文字](https://...)</code>,其他文字原样作为文本节点
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.post-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.paragraph-row {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.paragraph-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.paragraph-index {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.paragraph-del {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 按 payload_schema 动态渲染消息内容表单(场景与定时任务共用)
|
||||
* 支持 TemplateCard / NewsList / LinkList / GalleryPickLink(acceptTypes) 等
|
||||
* v-model 绑定整份 payload 对象
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import UploadImage from '#/components/form/components/upload-image.vue';
|
||||
import UploadOssFile from '#/components/form/components/upload-oss-file.vue';
|
||||
import ButtonList from '#/views/system/oa-scene/components/payload-lists/button-list.vue';
|
||||
import LinkList from '#/views/system/oa-scene/components/payload-lists/link-list.vue';
|
||||
import NewsList from '#/views/system/oa-scene/components/payload-lists/news-list.vue';
|
||||
import PostEditor from '#/views/system/oa-scene/components/payload-lists/post-editor.vue';
|
||||
import TemplateCardForm from '#/views/system/oa-scene/components/template-card-form.vue';
|
||||
|
||||
export interface PayloadFieldSchema {
|
||||
name: string;
|
||||
label: string;
|
||||
component: string;
|
||||
required?: boolean;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** payload_schema 解析后的字段列表 */
|
||||
schema: PayloadFieldSchema[];
|
||||
/** 当前消息 payload */
|
||||
modelValue: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: Record<string, any>): void;
|
||||
}>();
|
||||
|
||||
const payload = computed({
|
||||
get() {
|
||||
return props.modelValue && typeof props.modelValue === 'object'
|
||||
? props.modelValue
|
||||
: {};
|
||||
},
|
||||
set(v: Record<string, any>) {
|
||||
emit('update:modelValue', v || {});
|
||||
},
|
||||
});
|
||||
|
||||
function getValue(fieldName: string): any {
|
||||
return payload.value?.[fieldName];
|
||||
}
|
||||
|
||||
function setValue(fieldName: string, value: any) {
|
||||
payload.value = { ...payload.value, [fieldName]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析字段 acceptTypes
|
||||
* 兼容两套约定:场景种子常用 0=图片;xk_file_type 常用 1=图片
|
||||
*/
|
||||
function fieldAcceptTypes(field: PayloadFieldSchema): number[] {
|
||||
const raw = field.props?.acceptTypes;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw.map(Number).filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
function isImageMediaField(field: PayloadFieldSchema) {
|
||||
const types = fieldAcceptTypes(field);
|
||||
// 仅图片类型走图片上传器
|
||||
return types.every((t) => t === 0 || t === 1);
|
||||
}
|
||||
|
||||
function imageUrlsOf(fieldName: string): string[] {
|
||||
const url = getValue(fieldName);
|
||||
return url ? [String(url)] : [];
|
||||
}
|
||||
|
||||
function setImageUrls(fieldName: string, urls: string[]) {
|
||||
setValue(fieldName, urls[0] || '');
|
||||
}
|
||||
|
||||
function fileInputAccept(field: PayloadFieldSchema) {
|
||||
const types = fieldAcceptTypes(field);
|
||||
if (types.includes(3) || types.includes(2)) {
|
||||
return 'audio/*,.amr,.mp3,.wav';
|
||||
}
|
||||
return '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,*/*';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="payload-schema-form">
|
||||
<template v-for="field in schema" :key="field.name">
|
||||
<div
|
||||
v-if="field.component === 'TemplateCardEditor'"
|
||||
class="payload-row"
|
||||
>
|
||||
<label class="payload-label">{{ field.label }}</label>
|
||||
<TemplateCardForm
|
||||
:model-value="getValue('template_card') || {}"
|
||||
@update:model-value="(val) => setValue('template_card', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'NewsList'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<NewsList
|
||||
:model-value="getValue(field.name) || []"
|
||||
:max-count="field.props?.maxCount || 8"
|
||||
@update:model-value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'ButtonList'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<ButtonList
|
||||
:model-value="getValue(field.name) || []"
|
||||
:max-count="field.props?.maxCount || 6"
|
||||
@update:model-value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'LinkList'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<LinkList
|
||||
:model-value="getValue(field.name) || []"
|
||||
:max-count="field.props?.maxCount || 10"
|
||||
@update:model-value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'PostEditor'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<PostEditor
|
||||
:model-value="getValue(field.name) || {}"
|
||||
@update:model-value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="field.component === 'GalleryPickLink'"
|
||||
class="payload-row"
|
||||
>
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<UploadImage
|
||||
v-if="isImageMediaField(field)"
|
||||
:model-value="imageUrlsOf(field.name)"
|
||||
:multiple="false"
|
||||
:max-count="1"
|
||||
:accept-types="fieldAcceptTypes(field)"
|
||||
@update:model-value="(urls) => setImageUrls(field.name, urls)"
|
||||
/>
|
||||
<UploadOssFile
|
||||
v-else
|
||||
:model-value="getValue(field.name) || ''"
|
||||
:max-count="1"
|
||||
:accept-types="fieldAcceptTypes(field)"
|
||||
:accept="fileInputAccept(field)"
|
||||
@update:model-value="(url) => setValue(field.name, url || '')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'Textarea'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
:value="getValue(field.name)"
|
||||
:rows="field.props?.rows || 3"
|
||||
:placeholder="field.props?.placeholder"
|
||||
@update:value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'RadioGroup'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<RadioGroup
|
||||
:value="getValue(field.name)"
|
||||
:options="field.props?.options"
|
||||
@update:value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'Select'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<Select
|
||||
:value="getValue(field.name)"
|
||||
:options="field.props?.options"
|
||||
:placeholder="field.props?.placeholder"
|
||||
style="width: 100%"
|
||||
@update:value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.component === 'InputNumber'" class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
:value="getValue(field.name)"
|
||||
:placeholder="field.props?.placeholder"
|
||||
style="width: 100%"
|
||||
@update:value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="payload-row">
|
||||
<label class="payload-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<Input
|
||||
:value="getValue(field.name)"
|
||||
:placeholder="field.props?.placeholder"
|
||||
@update:value="(val) => setValue(field.name, val)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!schema?.length" class="empty-hint">该消息类型无需配置内容</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.payload-schema-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.payload-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.payload-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* OA 测试发送手机号输入 + 记忆选择器(任务 5)
|
||||
*
|
||||
* 用途:场景测试发送(Step 3)每个平台一行需要输入 @ 手机号列表,
|
||||
* 本组件把"输入 + 常用快速选择 + 后端记忆"三件事打包成一个独立组件。
|
||||
*
|
||||
* 特性:
|
||||
* - 顶部 Input tags 模式,回车 / 逗号 / 空格都能添加手机号
|
||||
* - 输入框下方展示常用手机号快速选择标签(最多 8 个)
|
||||
* - 前 3 个使用_count 最高的标为常用色(蓝),其余灰色
|
||||
* - 点击标签立即添加到选中列表(去重),并后台 record 一次(提升常用度)
|
||||
* - 选中手机号后自动调用 record API 记忆,下次常用排序更靠前
|
||||
* - 当 platform_code 切换时自动重新拉取记忆列表
|
||||
* - 删除单条记忆:标签上 hover 出现 ×(用户主动清除)
|
||||
*
|
||||
* Props:
|
||||
* - platformCode:必填,平台编码(按平台维度隔离记忆库)
|
||||
* - modelValue:string[],当前选中的手机号列表(双向绑定)
|
||||
*
|
||||
* 复用:
|
||||
* - 任务 3 form-page.vue Step 3 每个启用平台一行用本组件
|
||||
* - 后续机器人管理页测试发送也可复用
|
||||
*/
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { Input, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
deleteOaTestPhone,
|
||||
getOaTestPhones,
|
||||
recordOaTestPhone,
|
||||
type OaTestPhoneItem,
|
||||
} from '#/views/system/oa-scene/api';
|
||||
|
||||
interface Props {
|
||||
/** 平台编码(按平台维度隔离记忆库) */
|
||||
platformCode: string;
|
||||
/** 当前选中的手机号列表 */
|
||||
modelValue?: string[];
|
||||
/** 占位符 */
|
||||
placeholder?: string;
|
||||
/** 是否禁用(例如飞书 webhook 不支持手机号 @,外层可禁用) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
placeholder: '输入手机号回车添加,可多选',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void;
|
||||
(e: 'recorded', phone: string): void;
|
||||
}>();
|
||||
|
||||
/* ----------------- 内部状态 ----------------- */
|
||||
|
||||
/** 后端拉取的记忆库列表(按 use_count DESC 已排序) */
|
||||
const memoryList = ref<OaTestPhoneItem[]>([]);
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false);
|
||||
|
||||
/** Input tags 模式的当前值(受控) */
|
||||
const inputValue = computed<string[]>({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
/* ----------------- 数据加载 ----------------- */
|
||||
|
||||
async function loadMemory() {
|
||||
if (!props.platformCode) {
|
||||
memoryList.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getOaTestPhones(props.platformCode);
|
||||
const list = res?.data ?? res ?? [];
|
||||
memoryList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
console.error('phone-memory-picker loadMemory error', e);
|
||||
memoryList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMemory);
|
||||
watch(() => props.platformCode, loadMemory);
|
||||
|
||||
/* ----------------- 标签快捷选择 ----------------- */
|
||||
|
||||
/** 展示前 8 个常用手机号(去除当前已选) */
|
||||
const quickList = computed(() => {
|
||||
const selected = new Set(props.modelValue.map((p) => p.trim()));
|
||||
return memoryList.value
|
||||
.filter((item) => item.phone && !selected.has(item.phone))
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
/** 前 3 个标为常用色 */
|
||||
function isTopItem(index: number): boolean {
|
||||
return index < 3;
|
||||
}
|
||||
|
||||
/** 点击快捷标签 → 加入 modelValue,并后台 record */
|
||||
async function pickFromMemory(item: OaTestPhoneItem) {
|
||||
if (props.disabled) return;
|
||||
const phone = item.phone.trim();
|
||||
if (!phone) return;
|
||||
// 去重
|
||||
if (props.modelValue.includes(phone)) return;
|
||||
emit('update:modelValue', [...props.modelValue, phone]);
|
||||
// 后台 record,不阻塞 UI(失败也不影响主流程)
|
||||
try {
|
||||
await recordOaTestPhone({
|
||||
platform_code: props.platformCode,
|
||||
phone,
|
||||
name: item.name || '',
|
||||
});
|
||||
emit('recorded', phone);
|
||||
// 静默刷新(更新排序)
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
console.warn('recordOaTestPhone failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------- Input tags 添加手机号 ----------------- */
|
||||
|
||||
/** 输入新值时的处理(tags 模式 antd Input 不支持,用回车手动添加) */
|
||||
function handleInputConfirm(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const raw = target.value || '';
|
||||
// 支持一次粘贴多个:逗号 / 空格 / 换行分隔
|
||||
const phones = raw
|
||||
.split(/[\s,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
if (phones.length === 0) return;
|
||||
// 简单校验:11 位数字;非法的提示但不阻塞合法的
|
||||
const validPhones: string[] = [];
|
||||
for (const p of phones) {
|
||||
if (!/^1\d{10}$/.test(p)) {
|
||||
message.warning(`「${p}」不是有效手机号,已忽略`);
|
||||
continue;
|
||||
}
|
||||
if (!props.modelValue.includes(p)) {
|
||||
validPhones.push(p);
|
||||
}
|
||||
}
|
||||
if (validPhones.length > 0) {
|
||||
emit('update:modelValue', [...props.modelValue, ...validPhones]);
|
||||
// 后台批量记忆
|
||||
validPhones.forEach((phone) => {
|
||||
recordOaTestPhone({ platform_code: props.platformCode, phone }).catch(
|
||||
console.warn,
|
||||
);
|
||||
});
|
||||
setTimeout(loadMemory, 200);
|
||||
}
|
||||
// 清空输入框
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
/* ----------------- 移除选中的手机号 ----------------- */
|
||||
|
||||
function removePhone(phone: string) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((p) => p !== phone),
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------- 删除单条记忆(用户主动清除历史) ----------------- */
|
||||
|
||||
async function removeMemory(item: OaTestPhoneItem, e: Event) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteOaTestPhone(item.id);
|
||||
message.success('已删除');
|
||||
memoryList.value = memoryList.value.filter((m) => m.id !== item.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="phone-memory-picker">
|
||||
<!-- 已选手机号标签展示(带 × 关闭) -->
|
||||
<div v-if="modelValue.length" class="picked-tags">
|
||||
<Tag
|
||||
v-for="phone in modelValue"
|
||||
:key="phone"
|
||||
closable
|
||||
:disabled="disabled"
|
||||
@close="removePhone(phone)"
|
||||
>
|
||||
{{ phone }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 输入框(回车确认 / 粘贴多个) -->
|
||||
<Input
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
class="phone-input"
|
||||
@keydown.enter="handleInputConfirm"
|
||||
@blur="handleInputConfirm"
|
||||
/>
|
||||
|
||||
<!-- 常用快速选择标签 -->
|
||||
<Spin :spinning="loading" size="small">
|
||||
<div v-if="quickList.length" class="quick-tags">
|
||||
<span class="quick-title">常用:</span>
|
||||
<Tag
|
||||
v-for="(item, i) in quickList"
|
||||
:key="item.id"
|
||||
:color="isTopItem(i) ? 'blue' : 'default'"
|
||||
class="quick-tag"
|
||||
@click="pickFromMemory(item)"
|
||||
>
|
||||
<span>{{ item.name ? `${item.name}(${item.phone})` : item.phone }}</span>
|
||||
<span class="count">·{{ item.use_count }}</span>
|
||||
<CloseOutlined
|
||||
v-if="!disabled"
|
||||
class="del-icon"
|
||||
@click="removeMemory(item, $event)"
|
||||
/>
|
||||
</Tag>
|
||||
</div>
|
||||
<div v-else-if="!loading && !modelValue.length" class="empty-tip">
|
||||
输入手机号后会自动记忆,下次快速选择
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.phone-memory-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picked-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-title {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.quick-tag {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.del-icon {
|
||||
font-size: 10px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
margin-left: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--ant-color-error, #f5222d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 企微应用 API 场景投递目标面板
|
||||
* 个人目标:从已同步的企微组织架构(xk_oa_ww_user)选成员,存 userid
|
||||
* 群目标:多选群 + 客户群确认发送人 + 每群 @
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
CollapsePanel,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import UserTagSelect from '#/components/form/components/user-tag-select.vue';
|
||||
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
|
||||
import { getOaWwUserList } from '#/views/system/oa-chat/api/org';
|
||||
|
||||
export interface SceneTargetItem {
|
||||
target_type: 1 | 2;
|
||||
platform_code: string;
|
||||
/** 个人目标:企微 userid(优先) */
|
||||
userid?: string;
|
||||
/** 兼容旧数据 / 匹配到系统员工时回填 */
|
||||
admin_id?: number;
|
||||
chat_id?: number;
|
||||
at_admin_ids?: number[];
|
||||
at_userids?: string[];
|
||||
/** 客户群群发确认人 userid */
|
||||
sender_userid?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: SceneTargetItem[];
|
||||
/** 系统员工列表(群 @ 手机号用) */
|
||||
adminList: Array<{ id: number; name: string; avatar?: string }>;
|
||||
platformCode?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: SceneTargetItem[]): void;
|
||||
}>();
|
||||
|
||||
const platformCode = computed(() => props.platformCode || 'work_wechat_app');
|
||||
|
||||
/** 该平台下的群聊选项 */
|
||||
const chatOptions = ref<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
chat_kind: number;
|
||||
external_id: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
/** 企微组织架构成员:选项 + userid→matched_admin_id */
|
||||
const wwUserOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const wwUserAdminMap = ref<Record<string, number>>({});
|
||||
const wwUsersLoaded = ref(false);
|
||||
|
||||
/**
|
||||
* 个人目标选中的 userid 列表(与 modelValue 双向同步)
|
||||
*/
|
||||
const personUserids = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
.filter((t) => t.target_type === 1)
|
||||
.map((t) => String(t.userid || '').trim())
|
||||
.filter((u) => u !== '');
|
||||
},
|
||||
set(ids: string[]) {
|
||||
rebuild(ids, groupChatIds.value);
|
||||
},
|
||||
});
|
||||
|
||||
const groupChatIds = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
.filter((t) => t.target_type === 2 && (t.chat_id || 0) > 0)
|
||||
.map((t) => Number(t.chat_id));
|
||||
},
|
||||
set(ids: number[]) {
|
||||
rebuild(personUserids.value, ids);
|
||||
},
|
||||
});
|
||||
|
||||
/** 旧数据仅有 admin_id、无 userid 时的提示标记 */
|
||||
const orphanPersonAdminIds = computed(() =>
|
||||
props.modelValue
|
||||
.filter(
|
||||
(t) =>
|
||||
t.target_type === 1 &&
|
||||
!String(t.userid || '').trim() &&
|
||||
(t.admin_id || 0) > 0,
|
||||
)
|
||||
.map((t) => Number(t.admin_id)),
|
||||
);
|
||||
|
||||
/** 按 chat_id 取群目标行(含 @ / 确认人配置) */
|
||||
function groupTargetOf(chatId: number): SceneTargetItem {
|
||||
return (
|
||||
props.modelValue.find(
|
||||
(t) => t.target_type === 2 && Number(t.chat_id) === chatId,
|
||||
) || {
|
||||
target_type: 2,
|
||||
platform_code: platformCode.value,
|
||||
chat_id: chatId,
|
||||
at_admin_ids: [],
|
||||
at_userids: [],
|
||||
sender_userid: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function chatMeta(chatId: number) {
|
||||
return chatOptions.value.find((c) => c.id === chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建 targets:个人按 userid;保留已有群的 @ / sender
|
||||
*/
|
||||
function rebuild(userids: string[], chatIds: number[]) {
|
||||
const next: SceneTargetItem[] = [];
|
||||
const uniq = [...new Set(userids.map((u) => String(u).trim()).filter(Boolean))];
|
||||
for (const userid of uniq) {
|
||||
const matchedAdminId = wwUserAdminMap.value[userid] || 0;
|
||||
// 保留详情回显时已有的 admin_id(若组织列表尚未匹配到)
|
||||
const prev = props.modelValue.find(
|
||||
(t) => t.target_type === 1 && String(t.userid || '') === userid,
|
||||
);
|
||||
next.push({
|
||||
target_type: 1,
|
||||
platform_code: platformCode.value,
|
||||
userid,
|
||||
admin_id: matchedAdminId || prev?.admin_id || 0,
|
||||
});
|
||||
}
|
||||
for (const chatId of chatIds) {
|
||||
const prev = groupTargetOf(chatId);
|
||||
next.push({
|
||||
target_type: 2,
|
||||
platform_code: platformCode.value,
|
||||
chat_id: chatId,
|
||||
at_admin_ids: [...(prev.at_admin_ids || [])],
|
||||
at_userids: [...(prev.at_userids || [])],
|
||||
sender_userid: prev.sender_userid || '',
|
||||
});
|
||||
}
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
function updateGroupAt(
|
||||
chatId: number,
|
||||
patch: {
|
||||
at_admin_ids?: number[];
|
||||
at_userids?: string[];
|
||||
sender_userid?: string;
|
||||
},
|
||||
) {
|
||||
const next = props.modelValue.map((t) => {
|
||||
if (t.target_type !== 2 || Number(t.chat_id) !== chatId) return t;
|
||||
return {
|
||||
...t,
|
||||
at_admin_ids: patch.at_admin_ids ?? t.at_admin_ids ?? [],
|
||||
at_userids: patch.at_userids ?? t.at_userids ?? [],
|
||||
sender_userid:
|
||||
patch.sender_userid !== undefined
|
||||
? patch.sender_userid
|
||||
: t.sender_userid || '',
|
||||
};
|
||||
});
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存前校验:任一客户群缺确认发送员工则拦截
|
||||
* @returns 错误文案,通过返回空串
|
||||
*/
|
||||
function validateBeforeSubmit(): string {
|
||||
for (const chatId of groupChatIds.value) {
|
||||
const meta = chatMeta(chatId);
|
||||
if (Number(meta?.chat_kind) !== 2) continue;
|
||||
const sender = String(groupTargetOf(chatId).sender_userid || '').trim();
|
||||
if (!sender) {
|
||||
return `客户群「${meta?.name || chatId}」请选择确认发送员工`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
defineExpose({ validateBeforeSubmit });
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const res = await getOaChatListByPlatform();
|
||||
const grouped = res?.data ?? res ?? {};
|
||||
const list = grouped[platformCode.value] || [];
|
||||
chatOptions.value = (Array.isArray(list) ? list : []).map((c: any) => ({
|
||||
id: Number(c.id),
|
||||
name: String(c.name || ''),
|
||||
chat_kind: Number(c.chat_kind ?? 2),
|
||||
external_id: String(c.external_id || ''),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载群聊列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWwUsers() {
|
||||
try {
|
||||
const userRes = await getOaWwUserList({
|
||||
platform_code: platformCode.value,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
const userData = userRes?.data ?? userRes ?? {};
|
||||
const items = Array.isArray(userData.items) ? userData.items : [];
|
||||
const adminMap: Record<string, number> = {};
|
||||
wwUserOptions.value = items.map((u: any) => {
|
||||
const userid = String(u.userid || '');
|
||||
const matched = Number(u.matched_admin_id || 0);
|
||||
if (userid && matched > 0) {
|
||||
adminMap[userid] = matched;
|
||||
}
|
||||
return {
|
||||
label: u.name ? `${u.name}(${userid})` : userid,
|
||||
value: userid,
|
||||
};
|
||||
});
|
||||
wwUserAdminMap.value = adminMap;
|
||||
wwUsersLoaded.value = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
wwUserOptions.value = [];
|
||||
wwUserAdminMap.value = {};
|
||||
wwUsersLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadChats();
|
||||
loadWwUsers();
|
||||
});
|
||||
watch(platformCode, () => {
|
||||
loadChats();
|
||||
loadWwUsers();
|
||||
});
|
||||
|
||||
/** 系统员工 → UserTagSelect 选项(群 @ 手机号) */
|
||||
const adminTagOptions = computed(() =>
|
||||
props.adminList.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.name,
|
||||
avatar: a.avatar || '',
|
||||
})),
|
||||
);
|
||||
|
||||
/** 群聊 → UserTagSelect 选项 */
|
||||
const chatTagOptions = computed(() =>
|
||||
chatOptions.value.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name}${c.chat_kind === 2 ? '(客户群·需确认)' : '(应用群)'}`,
|
||||
avatar: '',
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scene-targets-panel">
|
||||
<div class="mb-3 text-xs text-gray-500">
|
||||
同一场景可同时选人、多群;客户群发送为群发任务,需指定确认员工并在企微客户端确认(非实时)
|
||||
</div>
|
||||
|
||||
<!-- 发送给个人:企微组织架构成员(userid) -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">发送给个人</div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
show-search
|
||||
allow-clear
|
||||
class="w-full"
|
||||
placeholder="从企微组织架构选择成员(可多选)"
|
||||
:value="personUserids"
|
||||
:options="wwUserOptions"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
:max-tag-count="6"
|
||||
@change="(v: string[]) => (personUserids = v || [])"
|
||||
/>
|
||||
<div
|
||||
v-if="wwUsersLoaded && wwUserOptions.length === 0"
|
||||
class="mt-1 text-xs text-orange-500"
|
||||
>
|
||||
请先在 OA 群聊 → 员工 Tab 同步组织架构
|
||||
</div>
|
||||
<div
|
||||
v-if="orphanPersonAdminIds.length > 0"
|
||||
class="mt-1 text-xs text-orange-500"
|
||||
>
|
||||
存在仅绑定系统员工、未选企微成员的旧目标(admin_id:
|
||||
{{ orphanPersonAdminIds.join('、') }}),请重新从组织架构选择成员
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送给群:UserTagSelect 多选 -->
|
||||
<div class="mb-2">
|
||||
<div class="section-title">发送给群</div>
|
||||
<UserTagSelect
|
||||
v-model="groupChatIds"
|
||||
:options="chatTagOptions"
|
||||
:max-count="99"
|
||||
placeholder="选择群聊(可多选)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse v-if="groupChatIds.length > 0" ghost>
|
||||
<CollapsePanel
|
||||
v-for="chatId in groupChatIds"
|
||||
:key="chatId"
|
||||
:header="chatMeta(chatId)?.name || `群#${chatId}`"
|
||||
>
|
||||
<template #extra>
|
||||
<Tag
|
||||
:color="chatMeta(chatId)?.chat_kind === 1 ? 'blue' : 'orange'"
|
||||
@click.stop
|
||||
>
|
||||
{{
|
||||
chatMeta(chatId)?.chat_kind === 1
|
||||
? '应用群·实时'
|
||||
: '客户群·需确认'
|
||||
}}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 仅客户群:确认发送员工必填 -->
|
||||
<div
|
||||
v-if="chatMeta(chatId)?.chat_kind === 2"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="mb-1 text-xs">
|
||||
确认发送员工 <span class="text-red-500">*</span>
|
||||
</div>
|
||||
<Select
|
||||
show-search
|
||||
allow-clear
|
||||
class="w-full"
|
||||
placeholder="选择在企微客户端确认群发的员工 userid"
|
||||
:value="groupTargetOf(chatId).sender_userid || undefined"
|
||||
:options="wwUserOptions"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
@change="
|
||||
(v: string) => updateGroupAt(chatId, { sender_userid: v || '' })
|
||||
"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
对应企微 add_msg_template.sender,该员工需在客户端点确认后消息才会发出
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-xs text-gray-500">本群 @ 配置</div>
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-xs">@ 系统员工(手机号)</div>
|
||||
<UserTagSelect
|
||||
:model-value="groupTargetOf(chatId).at_admin_ids || []"
|
||||
:options="adminTagOptions"
|
||||
placeholder="选择要 @ 的员工"
|
||||
@update:model-value="
|
||||
(v: (string | number)[]) =>
|
||||
updateGroupAt(chatId, {
|
||||
at_admin_ids: v.map((n) => Number(n)).filter((n) => n > 0),
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs">@ 群成员 userid</div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
show-search
|
||||
class="w-full"
|
||||
placeholder="从组织架构选择或输入 userid"
|
||||
:options="wwUserOptions"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
:value="groupTargetOf(chatId).at_userids || []"
|
||||
@change="
|
||||
(v: string[]) => updateGroupAt(chatId, { at_userids: v || [] })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
|
||||
<Button
|
||||
v-if="chatOptions.length === 0"
|
||||
type="link"
|
||||
size="small"
|
||||
class="px-0"
|
||||
@click="loadChats"
|
||||
>
|
||||
刷新群聊列表
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockSchema, FixedField } from '#/components/oa-template-card-editor/types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Radio, RadioGroup } from 'ant-design-vue';
|
||||
|
||||
import TemplateCardEditor from '#/components/oa-template-card-editor/index.vue';
|
||||
|
||||
/**
|
||||
* OA 场景 - 企业微信模板卡片专用编辑器
|
||||
*
|
||||
* 在通用拖拽编辑器之上做 OA 封装:
|
||||
* - 顶部用 RadioGroup 切换卡片子类型(text_notice / news_notice)
|
||||
* - 根据子类型动态切换 schema(fixedFields + blocks)
|
||||
* - 输出的 JSON 结构与企微官方文档对齐(template_card 子结构)
|
||||
*
|
||||
* 本期支持两种子类型:
|
||||
* - text_notice:文本通知卡片
|
||||
* - news_notice:图文展示卡片(在 text_notice 基础上加 card_image)
|
||||
* 其他子类型(button_interaction/vote_interaction/multiple_interaction)后续扩展
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** 双向绑定的 template_card 数据(企微 payload.template_card) */
|
||||
modelValue?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, any>];
|
||||
}>();
|
||||
|
||||
/** 当前选中的卡片子类型 */
|
||||
const cardType = ref<string>(props.modelValue?.card_type || 'text_notice');
|
||||
|
||||
/** 卡片子类型选项 */
|
||||
const cardTypeOptions = [
|
||||
{ label: '文本通知(text_notice)', value: 'text_notice' },
|
||||
{ label: '图文展示(news_notice)', value: 'news_notice' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 顶层固定字段定义(不可拖拽,固定在属性面板顶部)
|
||||
* 共通字段:card_type、source、main_title、emphasis_content、sub_title_text、card_action
|
||||
*/
|
||||
const commonFixedFields: FixedField[] = [
|
||||
{
|
||||
name: 'card_type',
|
||||
label: '卡片类型',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: cardTypeOptions,
|
||||
defaultValue: 'text_notice',
|
||||
},
|
||||
{
|
||||
name: 'main_title.title',
|
||||
label: '一级标题',
|
||||
type: 'string',
|
||||
placeholder: '建议不超过 26 个字',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'main_title.desc',
|
||||
label: '标题辅助信息',
|
||||
type: 'string',
|
||||
placeholder: '建议不超过 30 个字',
|
||||
},
|
||||
{
|
||||
name: 'emphasis_content.title',
|
||||
label: '关键数据内容',
|
||||
type: 'string',
|
||||
placeholder: '如「100」,建议不超过 10 个字',
|
||||
},
|
||||
{
|
||||
name: 'emphasis_content.desc',
|
||||
label: '关键数据描述',
|
||||
type: 'string',
|
||||
placeholder: '如「数据含义」,建议不超过 15 个字',
|
||||
},
|
||||
{
|
||||
name: 'sub_title_text',
|
||||
label: '二级普通文本',
|
||||
type: 'textarea',
|
||||
placeholder: '建议不超过 112 个字',
|
||||
},
|
||||
{
|
||||
name: 'card_action.type',
|
||||
label: '整体卡片点击事件',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: '跳转 URL', value: 1 },
|
||||
{ label: '跳转小程序', value: 2 },
|
||||
],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: 'card_action.url',
|
||||
label: '卡片点击跳转 URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://...',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* news_notice 特有的字段(图文展示)
|
||||
*/
|
||||
const newsOnlyFields: FixedField[] = [
|
||||
{
|
||||
name: 'card_image.url',
|
||||
label: '卡片图片 URL',
|
||||
type: 'image',
|
||||
acceptTypes: [0],
|
||||
placeholder: '图文展示卡片的主图',
|
||||
},
|
||||
{
|
||||
name: 'card_image.aspect_ratio',
|
||||
label: '图片宽高比',
|
||||
type: 'string',
|
||||
placeholder: '如 2.25(宽/高)',
|
||||
defaultValue: '2.25',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 完整的固定字段列表(根据 cardType 动态组合)
|
||||
*/
|
||||
const fixedFields = computed<FixedField[]>(() => {
|
||||
const list = [...commonFixedFields];
|
||||
if (cardType.value === 'news_notice') {
|
||||
// news_notice 在 main_title 之后插入 card_image 字段
|
||||
const insertIdx = list.findIndex((f) => f.name === 'main_title.desc') + 1;
|
||||
list.splice(insertIdx, 0, ...newsOnlyFields);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
/**
|
||||
* 可拖拽块定义
|
||||
* - horizontal_content_list:二级标题+文本列表(最多 6 项)
|
||||
* - jump_list:跳转链接列表(最多 3 项)
|
||||
*/
|
||||
const blockSchemas: BlockSchema[] = [
|
||||
{
|
||||
type: 'horizontal_content_item',
|
||||
label: '二级标题+文本',
|
||||
icon: 'mdi:format-list-bulleted',
|
||||
targetArrayPath: 'horizontal_content_list',
|
||||
maxCount: 6,
|
||||
defaultValue: { keyname: '', value: '' },
|
||||
fields: [
|
||||
{
|
||||
name: 'keyname',
|
||||
label: '二级标题',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: '建议不超过 5 个字',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
label: '二级文本',
|
||||
type: 'string',
|
||||
placeholder: '二级文本内容',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: '类型',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '普通文本(默认)', value: 0 },
|
||||
{ label: 'URL 链接', value: 1 },
|
||||
{ label: '文件附件', value: 2 },
|
||||
{ label: '成员详情', value: 3 },
|
||||
],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: '跳转 URL',
|
||||
type: 'url',
|
||||
placeholder: 'type=1 时必填',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'jump_item',
|
||||
label: '跳转链接',
|
||||
icon: 'mdi:link-variant',
|
||||
targetArrayPath: 'jump_list',
|
||||
maxCount: 3,
|
||||
defaultValue: { title: '', type: 1, url: '' },
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: '跳转文案',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: '建议不超过 13 个字',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: '跳转类型',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'URL', value: 1 },
|
||||
{ label: '小程序', value: 2 },
|
||||
],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: '跳转 URL',
|
||||
type: 'url',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 编辑器输出的 JSON(实时双向同步给父级) */
|
||||
const editorValue = ref<Record<string, any>>({ ...props.modelValue });
|
||||
|
||||
/**
|
||||
* 监听 props.modelValue 变化,同步到内部(编辑场景回显)
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
cardType.value = val.card_type || 'text_notice';
|
||||
editorValue.value = { ...val };
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换卡片子类型时,更新 editorValue.card_type 并清空 news_notice 特有字段
|
||||
*/
|
||||
function handleCardTypeChange(val: any) {
|
||||
cardType.value = val;
|
||||
editorValue.value = {
|
||||
...editorValue.value,
|
||||
card_type: val,
|
||||
};
|
||||
if (val !== 'news_notice') {
|
||||
// 切换回 text_notice 时清除 card_image 字段
|
||||
delete editorValue.value.card_image;
|
||||
}
|
||||
emit('update:modelValue', editorValue.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器值变化时同步给父级
|
||||
*/
|
||||
function handleEditorUpdate(val: Record<string, any>) {
|
||||
editorValue.value = val;
|
||||
emit('update:modelValue', val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-card-form">
|
||||
<div class="card-type-switcher">
|
||||
<label>卡片子类型:</label>
|
||||
<RadioGroup
|
||||
:value="cardType"
|
||||
size="small"
|
||||
@update:value="handleCardTypeChange"
|
||||
>
|
||||
<Radio
|
||||
v-for="opt in cardTypeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<TemplateCardEditor
|
||||
:model-value="editorValue"
|
||||
:schema="blockSchemas"
|
||||
:fixed-fields="fixedFields"
|
||||
@update:model-value="handleEditorUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template-card-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-type-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
font-size: 13px;
|
||||
|
||||
label {
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
apps/web-antd/src/views/system/oa-scene/config/constants.ts
Normal file
15
apps/web-antd/src/views/system/oa-scene/config/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* OA 消息类型选项
|
||||
*/
|
||||
export const OA_MESSAGE_TYPE_OPTIONS = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 场景状态选项
|
||||
*/
|
||||
export const OA_SCENE_STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
104
apps/web-antd/src/views/system/oa-scene/config/form.ts
Normal file
104
apps/web-antd/src/views/system/oa-scene/config/form.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/**
|
||||
* 场景基础信息表单(仅含基础字段,机器人多选和 @ 人在弹窗中用自定义组件实现)
|
||||
*/
|
||||
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: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '业务方代码中调用 dispatch 时传入的编码,如 order_paid',
|
||||
},
|
||||
fieldName: 'scene_code',
|
||||
label: '场景编码',
|
||||
rules: 'required',
|
||||
// 编辑场景下场景编码不可修改(前端禁用)
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => {
|
||||
return { disabled: !!values?.id };
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入场景名称(中文,便于管理)',
|
||||
},
|
||||
fieldName: 'scene_name',
|
||||
label: '场景名称',
|
||||
rules: 'required',
|
||||
},
|
||||
// 启用平台(任务 2):Checkbox.Group,options 由 form-page 异步注入(从后端平台列表)
|
||||
{
|
||||
component: 'CheckboxGroup',
|
||||
fieldName: 'scene_platforms',
|
||||
label: '启用平台',
|
||||
help: '本场景启用的平台(控制 Step 2 平台 Tab 展示范围);不勾选则默认全启用',
|
||||
componentProps: {
|
||||
options: [],
|
||||
},
|
||||
formItemClass: 'col-span-12',
|
||||
},
|
||||
// 字段绑定模式(任务 2):开启后 Step 2 每个非空 payload 字段下出现「业务字段名」输入框,
|
||||
// 业务方 dispatch 时按字段名覆盖对应 payload 路径
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'field_binding_mode',
|
||||
label: '字段绑定模式',
|
||||
defaultValue: 0,
|
||||
help: '0=固定内容(每次推送都一样);1=可替换内容(业务方 dispatch 时按字段名覆盖)',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '固定内容', value: 0 },
|
||||
{ label: '可替换内容', value: 1 },
|
||||
],
|
||||
},
|
||||
formItemClass: 'col-span-12',
|
||||
},
|
||||
// 消息类型不再放在基础表单中:每个平台支持的消息类型不同,放在弹窗下方的平台 Tab 内独立配置
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '场景状态',
|
||||
},
|
||||
{
|
||||
// 注意:项目内 textarea 的 schema 组件名是 'Textarea'(antd 原生组件直接注册)
|
||||
// 不是 'VbenTextarea'(不存在的组件名,会导致字段无法渲染)
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '可选,场景说明(什么业务事件触发,包含哪些信息)',
|
||||
rows: 3,
|
||||
},
|
||||
fieldName: 'description',
|
||||
label: '场景说明',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
47
apps/web-antd/src/views/system/oa-scene/config/search.ts
Normal file
47
apps/web-antd/src/views/system/oa-scene/config/search.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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: 'scene_name',
|
||||
label: '场景名称',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '选择状态',
|
||||
options: [
|
||||
{ label: '启用', value: '1' },
|
||||
{ label: '禁用', value: '0' },
|
||||
],
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
103
apps/web-antd/src/views/system/oa-scene/config/table.ts
Normal file
103
apps/web-antd/src/views/system/oa-scene/config/table.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getOaSceneList } from '#/views/system/oa-scene/api';
|
||||
|
||||
interface RowType {
|
||||
id: number;
|
||||
scene_code: string;
|
||||
scene_name: string;
|
||||
scene_platforms: string[];
|
||||
platform_summaries: Array<{
|
||||
platform_code: string;
|
||||
platform_name: string;
|
||||
icon: string;
|
||||
message_type: string;
|
||||
channel: string;
|
||||
}>;
|
||||
message_config: Record<string, { message_type?: string; payload?: any }>;
|
||||
description: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景列表表格配置
|
||||
* 启用平台 / 发送方式由 platform_summaries 驱动,点击平台 Tag 可预览消息
|
||||
*/
|
||||
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: 'scene_code', align: 'left', title: '场景编码', width: 160 },
|
||||
{ field: 'scene_name', align: 'left', title: '场景名称', minWidth: 140 },
|
||||
{
|
||||
field: 'platforms',
|
||||
align: 'left',
|
||||
title: '启用平台',
|
||||
minWidth: 200,
|
||||
slots: { default: 'platforms' },
|
||||
},
|
||||
{
|
||||
field: 'send_mode',
|
||||
align: 'left',
|
||||
title: '发送方式',
|
||||
minWidth: 220,
|
||||
slots: { default: 'send_mode' },
|
||||
},
|
||||
{ field: 'description', align: 'left', title: '说明', minWidth: 120 },
|
||||
{
|
||||
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 getOaSceneList({
|
||||
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,
|
||||
};
|
||||
257
apps/web-antd/src/views/system/oa-scene/index.vue
Normal file
257
apps/web-antd/src/views/system/oa-scene/index.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* OA 通知场景管理列表页
|
||||
* 新增/编辑走内页表单;列表展示启用平台与发送方式,点击平台 Tag 预览消息
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { onDeactivated, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deleteOaScene, getOaSceneInfo } from './api';
|
||||
import MsgPreviewModal from './components/msg-preview-modal.vue';
|
||||
import SceneFormPage from './components/form-page.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({ name: 'OaScene' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const formValues = ref<any>({});
|
||||
const formIsUpdate = ref(false);
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
/** 列表点击平台 Tag → 消息预览弹窗 */
|
||||
const [PreviewModalComponent, previewModalApi] = useVbenModal({
|
||||
connectedComponent: MsgPreviewModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开平台消息预览:取该行 message_config 中对应平台的类型与 payload
|
||||
*/
|
||||
function openPlatformPreview(row: any, summary: any) {
|
||||
const code = String(summary?.platform_code || '');
|
||||
const config = row?.message_config?.[code] || {};
|
||||
const messageType = String(
|
||||
config.message_type || summary?.message_type || '',
|
||||
);
|
||||
if (!messageType) {
|
||||
message.warning('该平台暂无消息配置');
|
||||
return;
|
||||
}
|
||||
previewModalApi.setData({
|
||||
platformName: summary?.platform_name || code,
|
||||
platformCode: code,
|
||||
messageType,
|
||||
payload: config.payload || {},
|
||||
});
|
||||
previewModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开新增/编辑内页:编辑必须拉详情
|
||||
*/
|
||||
async function openForm(row: any = {}, isUpdate = false) {
|
||||
let values: any = {};
|
||||
if (isUpdate && row?.id) {
|
||||
try {
|
||||
values = await getOaSceneInfo(row.id);
|
||||
} catch (e) {
|
||||
console.error('加载场景详情失败', e);
|
||||
message.error('加载场景详情失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
formValues.value = values;
|
||||
formIsUpdate.value = isUpdate;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function onFormBack() {
|
||||
showForm.value = false;
|
||||
}
|
||||
|
||||
function onFormSaved() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开路由或 KeepAlive 失活时回到列表态
|
||||
* 避免缓存卡在表单页,以及 Transition 卸载时 DOM 节点悬空
|
||||
*/
|
||||
function resetFormView() {
|
||||
showForm.value = false;
|
||||
}
|
||||
|
||||
onDeactivated(resetFormView);
|
||||
onBeforeRouteLeave(() => {
|
||||
resetFormView();
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除场景(支持单个和批量)
|
||||
*/
|
||||
const deleteApi = (row: any) => {
|
||||
let ids: any[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
|
||||
}
|
||||
deleteOaScene({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 单根节点:避免 KeepAlive + Transition 对多根组件报错导致切走空白 -->
|
||||
<template>
|
||||
<div class="oa-scene-page">
|
||||
<div v-show="!showForm">
|
||||
<Page auto-content-height title="OA 通知场景管理">
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: openForm.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 启用平台:点击名称标签可预览该平台消息 -->
|
||||
<template #platforms="{ row }">
|
||||
<div
|
||||
v-if="(row.platform_summaries || []).length"
|
||||
class="tag-wrap"
|
||||
>
|
||||
<Tag
|
||||
v-for="s in row.platform_summaries"
|
||||
:key="s.platform_code"
|
||||
color="processing"
|
||||
class="platform-tag"
|
||||
@click="openPlatformPreview(row, s)"
|
||||
>
|
||||
{{ s.platform_name || s.platform_code }}
|
||||
</Tag>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未启用</span>
|
||||
</template>
|
||||
|
||||
<!-- 发送方式:通道 · 消息类型 -->
|
||||
<template #send_mode="{ row }">
|
||||
<div
|
||||
v-if="(row.platform_summaries || []).length"
|
||||
class="tag-wrap"
|
||||
>
|
||||
<Tag
|
||||
v-for="s in row.platform_summaries"
|
||||
:key="`mode-${s.platform_code}`"
|
||||
:color="s.channel === '应用API' ? 'orange' : 'blue'"
|
||||
>
|
||||
{{ s.channel
|
||||
}}{{ s.message_type ? ` · ${s.message_type}` : '' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</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: openForm.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<PreviewModalComponent />
|
||||
</Page>
|
||||
</div>
|
||||
|
||||
<SceneFormPage
|
||||
v-if="showForm"
|
||||
:values="formValues"
|
||||
:is-update="formIsUpdate"
|
||||
@back="onFormBack"
|
||||
@saved="onFormSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.platform-tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.platform-tag:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
InputPassword,
|
||||
message,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getOaPlatformCredential,
|
||||
getOaPlatformList,
|
||||
updateOaPlatformCredential,
|
||||
updateOaPlatformEnabled,
|
||||
} from '#/views/system/oa-platform/api';
|
||||
import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api';
|
||||
|
||||
/**
|
||||
* 系统配置 - OA 通知 Tab
|
||||
* 总开关 + 平台子开关;work_wechat_app 可展开编辑平台级凭证
|
||||
*/
|
||||
|
||||
const oaEnabled = ref(false);
|
||||
const totalSaving = ref(false);
|
||||
const platformsLoading = ref(false);
|
||||
const platforms = ref<any[]>([]);
|
||||
/** 当前展开编辑凭证的平台 id */
|
||||
const editingCredId = ref<number | null>(null);
|
||||
const credSaving = ref(false);
|
||||
const credForm = ref({
|
||||
corp_id: '',
|
||||
agent_id: 0,
|
||||
app_secret: '',
|
||||
default_sender: '',
|
||||
});
|
||||
|
||||
function isWorkWechatApp(code: string) {
|
||||
return code === 'work_wechat_app';
|
||||
}
|
||||
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
|
||||
const oaRow = rows.find((r) => r.config_key === 'oa_notify_enabled');
|
||||
oaEnabled.value = oaRow
|
||||
? oaRow.config_value === '1' ||
|
||||
oaRow.config_value === 'true' ||
|
||||
oaRow.config_value === true
|
||||
: false;
|
||||
} catch (e) {
|
||||
console.error('加载系统配置失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlatforms() {
|
||||
platformsLoading.value = true;
|
||||
try {
|
||||
const list = await getOaPlatformList();
|
||||
platforms.value = Array.isArray(list) ? list : list?.data || [];
|
||||
} catch (e) {
|
||||
console.error('加载平台列表失败', e);
|
||||
} finally {
|
||||
platformsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotalSwitch(checked: boolean) {
|
||||
totalSaving.value = true;
|
||||
try {
|
||||
await saveSystemConfig([
|
||||
{ config_key: 'oa_notify_enabled', config_value: checked ? '1' : '0' },
|
||||
]);
|
||||
oaEnabled.value = checked;
|
||||
message.success(`OA 通知已${checked ? '启用' : '关闭'}`);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
oaEnabled.value = !checked;
|
||||
} finally {
|
||||
totalSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlatformSwitch(
|
||||
platform: any,
|
||||
checked: boolean,
|
||||
index: number,
|
||||
) {
|
||||
const oldValue = platform.enabled;
|
||||
platforms.value[index].enabled = checked ? 1 : 0;
|
||||
try {
|
||||
await updateOaPlatformEnabled(platform.id, checked ? 1 : 0);
|
||||
message.success(`${platform.name} 已${checked ? '启用' : '关闭'}`);
|
||||
} catch (e: any) {
|
||||
platforms.value[index].enabled = oldValue;
|
||||
message.error(e.message || `${platform.name} 切换失败`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开/收起凭证编辑 */
|
||||
async function toggleCredential(platform: any) {
|
||||
if (editingCredId.value === platform.id) {
|
||||
editingCredId.value = null;
|
||||
return;
|
||||
}
|
||||
editingCredId.value = platform.id;
|
||||
try {
|
||||
const data = await getOaPlatformCredential(platform.id);
|
||||
const row = data?.data ?? data ?? {};
|
||||
credForm.value = {
|
||||
corp_id: row.corp_id || '',
|
||||
agent_id: Number(row.agent_id || 0),
|
||||
app_secret: '',
|
||||
default_sender: row.default_sender || '',
|
||||
};
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载凭证失败');
|
||||
editingCredId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredential(platform: any) {
|
||||
credSaving.value = true;
|
||||
try {
|
||||
await updateOaPlatformCredential({
|
||||
id: platform.id,
|
||||
corp_id: credForm.value.corp_id,
|
||||
agent_id: Number(credForm.value.agent_id || 0),
|
||||
app_secret: credForm.value.app_secret || '',
|
||||
default_sender: credForm.value.default_sender || '',
|
||||
});
|
||||
message.success('平台凭证已保存');
|
||||
editingCredId.value = null;
|
||||
await loadPlatforms();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
credSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemConfig();
|
||||
loadPlatforms();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4">
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-1 text-base font-medium">OA 通知总开关</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
关闭后,所有场景的 OA 通知都将停止推送(包括所有平台)
|
||||
</div>
|
||||
</div>
|
||||
<Spin :spinning="totalSaving">
|
||||
<Switch
|
||||
:checked="oaEnabled"
|
||||
checked-children="开"
|
||||
un-checked-children="关"
|
||||
@change="handleTotalSwitch"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :class="{ 'oa-platform-disabled': !oaEnabled }">
|
||||
<div class="mb-3 text-base font-medium">平台子开关</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
每个平台独立开关;企业微信(应用API)可配置平台级凭证供机器人继承
|
||||
</div>
|
||||
<Spin :spinning="platformsLoading">
|
||||
<div
|
||||
v-if="platforms.length === 0 && !platformsLoading"
|
||||
class="py-4 text-center text-gray-400"
|
||||
>
|
||||
暂无平台数据,请联系开发初始化
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(platform, index) in platforms"
|
||||
:key="platform.platform_code"
|
||||
class="rounded border border-gray-100 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ platform.name }}</span>
|
||||
<Tag>{{ platform.platform_code }}</Tag>
|
||||
<Tag
|
||||
v-if="isWorkWechatApp(platform.platform_code)"
|
||||
:color="platform.has_credential ? 'success' : 'default'"
|
||||
>
|
||||
{{ platform.has_credential ? '凭证已配' : '凭证未配' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
排序:{{ platform.sort }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isWorkWechatApp(platform.platform_code)"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="toggleCredential(platform)"
|
||||
>
|
||||
{{ editingCredId === platform.id ? '收起凭证' : '配置凭证' }}
|
||||
</Button>
|
||||
<Switch
|
||||
:checked="platform.enabled === 1"
|
||||
:disabled="!oaEnabled"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="
|
||||
(checked: boolean) =>
|
||||
handlePlatformSwitch(platform, checked, index)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
isWorkWechatApp(platform.platform_code) &&
|
||||
editingCredId === platform.id
|
||||
"
|
||||
class="mt-3 grid grid-cols-1 gap-3 border-t border-dashed border-gray-200 pt-3 md:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">企业 ID(corpid)</div>
|
||||
<Input
|
||||
v-model:value="credForm.corp_id"
|
||||
placeholder="请输入 corpid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">AgentId</div>
|
||||
<InputNumber
|
||||
v-model:value="credForm.agent_id"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
placeholder="应用 agentid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">
|
||||
应用 Secret(留空不改)
|
||||
</div>
|
||||
<InputPassword
|
||||
v-model:value="credForm.app_secret"
|
||||
placeholder="自建应用 Secret"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">
|
||||
客户群群发默认 sender(userid)
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="credForm.default_sender"
|
||||
placeholder="可选,群发任务执行人"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="credSaving"
|
||||
@click="saveCredential(platform)"
|
||||
>
|
||||
保存凭证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oa-platform-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
|
||||
import FormAvatar from '#/components/form/components/avatar.vue';
|
||||
|
||||
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
|
||||
import { getSystemConfigList, saveSystemConfig } from './api';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
@@ -50,6 +51,7 @@ function readStoredTab() {
|
||||
stored === 'salesperson' ||
|
||||
stored === 'invoice_notice'
|
||||
) {
|
||||
if (stored === 'price_adjust' || stored === 'input_audit' || stored === 'miniprogram' || stored === 'oa_notify') {
|
||||
activeKey.value = stored;
|
||||
}
|
||||
}
|
||||
@@ -313,6 +315,10 @@ onMounted(() => {
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="oa_notify" tab="OA通知">
|
||||
<OaNotifyConfigPanel />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<div class="mt-4">
|
||||
|
||||
Reference in New Issue
Block a user