feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互
This commit is contained in:
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>
|
||||
@@ -40,8 +40,8 @@ defineProps<{
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
border-right: 1px solid #e5e6eb;
|
||||
background: #fafbfc;
|
||||
border-right: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -49,16 +49,16 @@ defineProps<{
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -75,19 +75,19 @@ defineProps<{
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px dashed #c9cdd4;
|
||||
border: 1px dashed var(--ant-color-border, #c9cdd4);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
font-size: 13px;
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #165dff;
|
||||
color: #165dff;
|
||||
background: #f2f7ff;
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
color: var(--ant-color-primary, #165dff);
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -102,34 +102,7 @@ defineProps<{
|
||||
.empty-hint {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: #c9cdd4;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.block-library {
|
||||
background: #1f1f1f;
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
border-bottom-color: #374151;
|
||||
|
||||
.title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.block-item {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover {
|
||||
border-color: #60a5fa;
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,7 +159,7 @@ function handleRemove(instanceId: string) {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -167,16 +167,16 @@ function handleRemove(instanceId: string) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -199,22 +199,22 @@ function handleRemove(instanceId: string) {
|
||||
font-size: 12px;
|
||||
|
||||
.group-label {
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
font-weight: 500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
}
|
||||
|
||||
.group-dropzone {
|
||||
min-height: 60px;
|
||||
padding: 8px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border: 2px dashed var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.empty-dropzone {
|
||||
@@ -222,7 +222,7 @@ function handleRemove(instanceId: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
color: #c9cdd4;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -232,9 +232,9 @@ function handleRemove(instanceId: string) {
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
@@ -243,12 +243,12 @@ function handleRemove(instanceId: string) {
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #165dff;
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #165dff;
|
||||
background: #f2f7ff;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -268,13 +268,13 @@ function handleRemove(instanceId: string) {
|
||||
|
||||
.instance-label {
|
||||
font-size: 13px;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instance-summary {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -286,52 +286,15 @@ function handleRemove(instanceId: string) {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f53f3f;
|
||||
background: var(--ant-color-error, #f53f3f);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.canvas {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
border-bottom-color: #374151;
|
||||
|
||||
.title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.group-dropzone {
|
||||
background: #1f1f1f;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.instance-item {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
|
||||
&:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.instance-label {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,8 @@ async function handleCopy() {
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid #e5e6eb;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
/* JSON 代码面板保留 VS Code Dark+ 风格暗底(无论浅色/暗色主题) */
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ watch(
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [1]"
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@ watch(
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [1]"
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
@@ -278,8 +278,8 @@ watch(
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid #e5e6eb;
|
||||
background: #fff;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -287,16 +287,16 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
@@ -318,10 +318,10 @@ watch(
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -332,11 +332,11 @@ watch(
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
|
||||
.required {
|
||||
margin-left: 2px;
|
||||
color: #f53f3f;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,28 +345,4 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.property-panel {
|
||||
background: #1f1f1f;
|
||||
border-left-color: #374151;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
border-bottom-color: #374151;
|
||||
|
||||
.title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
border-bottom-color: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -493,10 +493,10 @@ defineExpose({
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@@ -504,13 +504,13 @@ defineExpose({
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
background: #fafbfc;
|
||||
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: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,20 +519,4 @@ defineExpose({
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.template-editor {
|
||||
background: #1f1f1f;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
background: #1f1f1f;
|
||||
border-bottom-color: #374151;
|
||||
|
||||
.title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
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>
|
||||
@@ -54,6 +54,7 @@ export async function getOaMessageTypes() {
|
||||
/**
|
||||
* 场景测试发送(同步)
|
||||
* 可传 id(已保存)或草稿 message_config + robot_ids;test_params 覆盖正文类字段
|
||||
* platform_codes(任务 6):可选指定只测试某些平台,未在列表中的机器人直接跳过
|
||||
*/
|
||||
export async function testSendOaScene(data: {
|
||||
id?: number;
|
||||
@@ -63,6 +64,7 @@ export async function testSendOaScene(data: {
|
||||
robot_ids?: number[];
|
||||
at_users?: Record<string, any>;
|
||||
targets?: any[];
|
||||
platform_codes?: string[];
|
||||
test_params?: {
|
||||
content?: string;
|
||||
title?: string;
|
||||
@@ -72,3 +74,58 @@ export async function testSendOaScene(data: {
|
||||
}) {
|
||||
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 });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -738,7 +738,7 @@ watch(
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.props?.acceptTypes || [1]"
|
||||
:accept-types="field.props?.acceptTypes || [0]"
|
||||
@select="
|
||||
(urls) => setPayloadValue(platform.platform_code, field.name, urls[0] || '')
|
||||
"
|
||||
@@ -1023,7 +1023,7 @@ watch(
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed #e5e6eb;
|
||||
border-bottom: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
@@ -1034,7 +1034,7 @@ watch(
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.payload-form {
|
||||
@@ -1051,11 +1051,11 @@ watch(
|
||||
|
||||
.payload-label {
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.payload-label .required {
|
||||
color: #f53f3f;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@@ -1072,19 +1072,19 @@ watch(
|
||||
.param-hint {
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e5e6eb;
|
||||
border-top: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
|
||||
}
|
||||
|
||||
.result-name {
|
||||
@@ -1096,12 +1096,12 @@ watch(
|
||||
.result-target,
|
||||
.result-cost {
|
||||
margin-left: 6px;
|
||||
color: #86909c;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
|
||||
.result-error {
|
||||
margin-top: 4px;
|
||||
color: #f53f3f;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 企微应用 API 场景投递目标面板
|
||||
* 支持:选人(多选)+ 选群(多选)+ 每群独立 @员工 / @userid
|
||||
* 客户群必须配置「确认发送员工」sender_userid(add_msg_template.sender)
|
||||
* 个人目标:从已同步的企微组织架构(xk_oa_ww_user)选成员,存 userid
|
||||
* 群目标:多选群 + 客户群确认发送人 + 每群 @
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
CollapsePanel,
|
||||
Select,
|
||||
@@ -16,12 +15,16 @@ import {
|
||||
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[];
|
||||
@@ -32,7 +35,8 @@ export interface SceneTargetItem {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: SceneTargetItem[];
|
||||
adminList: Array<{ id: number; name: string }>;
|
||||
/** 系统员工列表(群 @ 手机号用) */
|
||||
adminList: Array<{ id: number; name: string; avatar?: string }>;
|
||||
platformCode?: string;
|
||||
}>();
|
||||
|
||||
@@ -52,16 +56,22 @@ const chatOptions = ref<
|
||||
}>
|
||||
>([]);
|
||||
|
||||
/** 企微组织架构员工(确认发送人 / 可作参考) */
|
||||
/** 企微组织架构成员:选项 + userid→matched_admin_id */
|
||||
const wwUserOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const wwUserAdminMap = ref<Record<string, number>>({});
|
||||
const wwUsersLoaded = ref(false);
|
||||
|
||||
const personAdminIds = computed({
|
||||
/**
|
||||
* 个人目标选中的 userid 列表(与 modelValue 双向同步)
|
||||
*/
|
||||
const personUserids = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
.filter((t) => t.target_type === 1 && (t.admin_id || 0) > 0)
|
||||
.map((t) => Number(t.admin_id));
|
||||
.filter((t) => t.target_type === 1)
|
||||
.map((t) => String(t.userid || '').trim())
|
||||
.filter((u) => u !== '');
|
||||
},
|
||||
set(ids: number[]) {
|
||||
set(ids: string[]) {
|
||||
rebuild(ids, groupChatIds.value);
|
||||
},
|
||||
});
|
||||
@@ -73,10 +83,22 @@ const groupChatIds = computed({
|
||||
.map((t) => Number(t.chat_id));
|
||||
},
|
||||
set(ids: number[]) {
|
||||
rebuild(personAdminIds.value, ids);
|
||||
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 (
|
||||
@@ -98,15 +120,22 @@ function chatMeta(chatId: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建 targets:保留已有群的 @ / sender 配置,新增群默认空
|
||||
* 重建 targets:个人按 userid;保留已有群的 @ / sender
|
||||
*/
|
||||
function rebuild(adminIds: number[], chatIds: number[]) {
|
||||
function rebuild(userids: string[], chatIds: number[]) {
|
||||
const next: SceneTargetItem[] = [];
|
||||
for (const id of adminIds) {
|
||||
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,
|
||||
admin_id: id,
|
||||
userid,
|
||||
admin_id: matchedAdminId || prev?.admin_id || 0,
|
||||
});
|
||||
}
|
||||
for (const chatId of chatIds) {
|
||||
@@ -186,17 +215,29 @@ async function loadWwUsers() {
|
||||
const userRes = await getOaWwUserList({
|
||||
platform_code: platformCode.value,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
pageSize: 500,
|
||||
});
|
||||
const userData = userRes?.data ?? userRes ?? {};
|
||||
const items = Array.isArray(userData.items) ? userData.items : [];
|
||||
wwUserOptions.value = items.map((u: any) => ({
|
||||
label: u.name ? `${u.name}(${u.userid})` : String(u.userid),
|
||||
value: String(u.userid),
|
||||
}));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +249,24 @@ 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>
|
||||
@@ -216,39 +275,49 @@ watch(platformCode, () => {
|
||||
同一场景可同时选人、多群;客户群发送为群发任务,需指定确认员工并在企微客户端确认(非实时)
|
||||
</div>
|
||||
|
||||
<!-- 发送给个人 -->
|
||||
<!-- 发送给个人:企微组织架构成员(userid) -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">发送给个人</div>
|
||||
<Checkbox.Group
|
||||
v-model:value="personAdminIds"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
v-for="admin in adminList"
|
||||
:key="admin.id"
|
||||
:value="admin.id"
|
||||
>
|
||||
{{ admin.name }}
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
<div v-if="adminList.length === 0" class="text-gray-400">暂无员工可选</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送给群 -->
|
||||
<div class="mb-2">
|
||||
<div class="section-title">发送给群</div>
|
||||
<Select
|
||||
v-model:value="groupChatIds"
|
||||
mode="multiple"
|
||||
show-search
|
||||
allow-clear
|
||||
class="w-full"
|
||||
placeholder="选择群聊(可多选)"
|
||||
:options="
|
||||
chatOptions.map((c) => ({
|
||||
label: `${c.name}${c.chat_kind === 2 ? '(客户群·需确认)' : '(应用群)'}`,
|
||||
value: c.id,
|
||||
}))
|
||||
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>
|
||||
|
||||
@@ -304,29 +373,35 @@ watch(platformCode, () => {
|
||||
<div class="mb-2 text-xs text-gray-500">本群 @ 配置</div>
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-xs">@ 系统员工(手机号)</div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
class="w-full"
|
||||
<UserTagSelect
|
||||
:model-value="groupTargetOf(chatId).at_admin_ids || []"
|
||||
:options="adminTagOptions"
|
||||
placeholder="选择要 @ 的员工"
|
||||
:value="groupTargetOf(chatId).at_admin_ids || []"
|
||||
:options="
|
||||
adminList.map((a) => ({ label: a.name, value: a.id }))
|
||||
"
|
||||
@change="
|
||||
(v: number[]) => updateGroupAt(chatId, { at_admin_ids: v })
|
||||
@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="tags"
|
||||
mode="multiple"
|
||||
show-search
|
||||
class="w-full"
|
||||
placeholder="输入 userid 回车添加"
|
||||
:token-separators="[',', ' ']"
|
||||
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 })
|
||||
(v: string[]) => updateGroupAt(chatId, { at_userids: v || [] })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -350,6 +425,6 @@ watch(platformCode, () => {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -113,7 +113,7 @@ const newsOnlyFields: FixedField[] = [
|
||||
name: 'card_image.url',
|
||||
label: '卡片图片 URL',
|
||||
type: 'image',
|
||||
acceptTypes: [1],
|
||||
acceptTypes: [0],
|
||||
placeholder: '图文展示卡片的主图',
|
||||
},
|
||||
{
|
||||
@@ -302,11 +302,11 @@ function handleEditorUpdate(val: Record<string, any>) {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: #f2f7ff;
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
font-size: 13px;
|
||||
|
||||
label {
|
||||
color: #4e5969;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,33 @@ export const modalFormProps: VbenFormProps = {
|
||||
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',
|
||||
|
||||
@@ -29,8 +29,8 @@ export const formOptions: VbenFormProps = {
|
||||
componentProps: {
|
||||
placeholder: '选择状态',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
{ label: '启用', value: '1' },
|
||||
{ label: '禁用', value: '0' },
|
||||
],
|
||||
},
|
||||
defaultValue: '',
|
||||
|
||||
@@ -6,7 +6,15 @@ interface RowType {
|
||||
id: number;
|
||||
scene_code: string;
|
||||
scene_name: string;
|
||||
message_type: 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;
|
||||
@@ -14,6 +22,7 @@ interface RowType {
|
||||
|
||||
/**
|
||||
* 场景列表表格配置
|
||||
* 启用平台 / 发送方式由 platform_summaries 驱动,点击平台 Tag 可预览消息
|
||||
*/
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
@@ -29,13 +38,38 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'scene_code', align: 'left', title: '场景编码', width: 180 },
|
||||
{ field: 'scene_name', align: 'left', title: '场景名称' },
|
||||
{ field: 'message_type', align: 'left', title: '消息类型', width: 120, slots: { default: 'message_type' } },
|
||||
{ field: 'description', align: 'left', title: '说明' },
|
||||
{ field: 'status', align: 'left', title: '状态', width: 100, slots: { default: 'status' } },
|
||||
{ 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' },
|
||||
{
|
||||
type: 'html',
|
||||
title: '操作',
|
||||
slots: { default: 'action' },
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* OA 通知场景管理列表页
|
||||
* 新增/编辑走内页表单;列表展示启用平台与发送方式,点击平台 Tag 预览消息
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { onDeactivated, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
|
||||
@@ -11,14 +16,11 @@ 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';
|
||||
|
||||
/**
|
||||
* OA 通知场景管理列表页
|
||||
* 新增/编辑走内页表单(v-if 挂载 form-page),列表用 v-show 保持 Grid 状态
|
||||
*/
|
||||
defineOptions({ name: 'OaScene' });
|
||||
|
||||
const showForm = ref(false);
|
||||
@@ -43,8 +45,35 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
/** 列表点击平台 Tag → 消息预览弹窗 */
|
||||
const [PreviewModalComponent, previewModalApi] = useVbenModal({
|
||||
connectedComponent: MsgPreviewModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增/编辑内页:编辑必须拉详情(列表无 message_config 等)
|
||||
* 打开平台消息预览:取该行 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 = {};
|
||||
@@ -70,6 +99,19 @@ function onFormSaved() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开路由或 KeepAlive 失活时回到列表态
|
||||
* 避免缓存卡在表单页,以及 Transition 卸载时 DOM 节点悬空
|
||||
*/
|
||||
function resetFormView() {
|
||||
showForm.value = false;
|
||||
}
|
||||
|
||||
onDeactivated(resetFormView);
|
||||
onBeforeRouteLeave(() => {
|
||||
resetFormView();
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除场景(支持单个和批量)
|
||||
*/
|
||||
@@ -87,78 +129,129 @@ const deleteApi = (row: any) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 单根节点:避免 KeepAlive + Transition 对多根组件报错导致切走空白 -->
|
||||
<template>
|
||||
<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),
|
||||
<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),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #message_type="{ row }">
|
||||
<Tag
|
||||
:color="row.message_type === 'markdown' ? 'warning' : 'processing'"
|
||||
>
|
||||
{{ row.message_type === 'markdown' ? 'Markdown' : '文本' }}
|
||||
</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: openForm.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</div>
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SceneFormPage
|
||||
v-if="showForm"
|
||||
:values="formValues"
|
||||
:is-update="formIsUpdate"
|
||||
@back="onFormBack"
|
||||
@saved="onFormSaved"
|
||||
/>
|
||||
<!-- 启用平台:点击名称标签可预览该平台消息 -->
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user