Files
xk-admin/apps/web-antd/src/views/system/oa-scene/components/form-page.vue

1979 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
/**
* OA 场景新增/编辑内页表单(由列表页 v-if 挂载,替代原 modal.vue
*
* 三步 C 端化流程(任务 3 大改造):
* Step 1 基础信息scene_code/scene_name/启用平台/字段绑定模式
* Step 2 平台配置(合并原"平台消息 + 投递目标"
* - 顶部 sticky 平台 Tab仅展示 Step 1 选中的平台)
* - 纵向滚动:消息类型 + payload + 字段绑定 + 推送机器人 + 投递目标 + @人
* - 桌面右侧 / 移动底部实时消息预览
* Step 3 测试发送:按启用平台独立测试,每平台一行 + 手机号记忆
*
* 提交 at_users 统一为双结构(后端兼容旧纯数组):
* { work_wechat: { admin_ids: number[], userids: string[] }, work_wechat_app: { ... } }
* work_wechat_app 额外提交 targets人/多群/每群 @
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Page, useVbenModal, VbenIcon } from '@vben/common-ui';
import {
Alert,
Button,
Card,
Checkbox,
Empty,
Input,
message,
Radio,
RadioGroup,
Select,
Spin,
Step,
Steps,
Tabs,
Tag,
} from 'ant-design-vue';
import UserTagSelect from '#/components/form/components/user-tag-select.vue';
import { useVbenForm } from '#/adapter/form';
import { getAdminList } from '#/views/system/admin/api';
import { getOaPlatformList } from '#/views/system/oa-platform/api';
import {
getOaRobotChatMembers,
getOaRobotList,
} from '#/views/system/oa-robot/api';
import { isWorkWechatApp } from '#/views/system/oa-robot/config/form';
import TestResultModal from '#/views/system/oa-robot/components/test-result-modal.vue';
import {
createOaScene,
getOaMessageTypes,
testSendOaScene,
updateOaScene,
} from '#/views/system/oa-scene/api';
import { modalFormProps } from '#/views/system/oa-scene/config/form';
import MsgPreview from '#/views/system/oa-scene/components/msg-preview.vue';
import PhoneMemoryPicker from '#/views/system/oa-scene/components/phone-memory-picker.vue';
import PayloadSchemaForm from '#/views/system/oa-scene/components/payload-schema-form.vue';
import SceneTargetsPanel, {
type SceneTargetItem,
} from '#/views/system/oa-scene/components/scene-targets-panel.vue';
import { getOaWwUserList } from '#/views/system/oa-chat/api/org';
interface MessageTypeOption {
message_type: string;
name: string;
icon: string;
need_media: number;
payload_schema: string | null;
}
interface PayloadFieldSchema {
name: string;
label: string;
component: string;
required?: boolean;
props?: Record<string, any>;
}
/** TestResultModal 所需的结果项结构 */
interface TestResultItem {
message_type: string;
name: string;
success: boolean;
message: string;
request?: any;
response: any;
cost_ms: number;
}
const props = defineProps<{
/** 详情或空对象(新增) */
values: Record<string, any>;
/** 是否编辑模式 */
isUpdate: boolean;
}>();
const emit = defineEmits<{
back: [];
saved: [];
}>();
defineOptions({ name: 'OaSceneFormPage' });
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
/** 分步表单当前步0=基础信息, 1=平台配置(合并), 2=测试发送 */
const currentStep = ref(0);
const stepValidating = ref(false);
const STEP_TITLES = ['基础信息', '平台配置', '测试发送'];
/** 平台列表(仅展示启用的平台) */
const platforms = ref<any[]>([]);
/** 启用平台下的所有机器人 */
const allRobots = ref<any[]>([]);
/** 员工列表(用于 @ 人多选) */
const adminList = ref<any[]>([]);
/** 当前选中的 Tab 平台编码 */
const activePlatformTab = ref('');
/** 各平台支持的消息类型列表(来自 message-types 接口) */
const messageTypesMap = ref<Record<string, MessageTypeOption[]>>({});
const selectedMessageType = ref<Record<string, string>>({});
const payloadValues = ref<Record<string, Record<string, any>>>({});
/** 字段绑定(任务 2键 "<platform_code>.<payload_path>" → business_key */
const fieldBindings = ref<Record<string, string>>({});
/** 机器人勾选(按平台分组) */
const selectedRobots = ref<Record<string, number[]>>({});
/** @ 配置按平台admin_ids=系统员工userids=应用群成员 */
const selectedAtUsers = ref<
Record<string, { admin_ids: number[]; userids: string[] }>
>({});
/** 应用 API 群成员选项(按平台合并已选机器人的成员) */
const membersByPlatform = ref<
Record<string, { userid: string; name: string }[]>
>({});
/** 应用 API 投递目标(人 / 群 / 每群 @ */
const sceneTargets = ref<SceneTargetItem[]>([]);
const targetsPanelRef = ref<{ validateBeforeSubmit: () => string } | null>(
null,
);
/** 暂存待回显的 robot_ids */
let pendingRobotIds: number[] = [];
/** 暂存待回显的 targets */
let pendingTargets: SceneTargetItem[] = [];
/** 测试发送:每平台 phones + 可替换业务字段 vars */
const testParamsByPlatform = ref<
Record<string, { phones: string[]; vars: Record<string, string> }>
>({});
/** 单个平台测试中的 loading按平台独立 */
const testingPlatform = ref<Record<string, boolean>>({});
/** 41054 客户群未激活类的错误,前端给专门提示 */
const errCode41054Tip =
'客户群群发任务权限未激活errcode=41054请在企微管理端开通「客户联系 → 客户群发」功能';
/**
* Step3 临时测试成员(仅 work_wechat_app
* 选中后合并进本次 testSend 的 targets未选则仍用场景已配置 targets
*/
const testWwMemberUserids = ref<string[]>([]);
const wwTestUserOptions = ref<{ label: string; value: string }[]>([]);
/** 加载企微组织成员,供 Step3 临时测试选人 */
async function loadWwTestUsers() {
try {
const userRes = await getOaWwUserList({
platform_code: 'work_wechat_app',
page: 1,
pageSize: 500,
});
const userData = userRes?.data ?? userRes ?? {};
const items = Array.isArray(userData.items) ? userData.items : [];
wwTestUserOptions.value = items.map((u: any) => ({
label: u.name ? `${u.name}${u.userid}` : String(u.userid),
value: String(u.userid),
}));
} catch (e) {
console.error(e);
wwTestUserOptions.value = [];
}
}
const pageTitle = computed(() =>
props.isUpdate ? '编辑通知场景' : '新增通知场景',
);
const [Form, formApi] = useVbenForm(modalFormProps);
/** 复用机器人模块的测试结果弹窗 */
const [TestResultModalComponent, testResultModalApi] = useVbenModal({
connectedComponent: TestResultModal,
});
/* ============================================================ *
* 响应式断点:> 768px = 桌面双栏(左配置 + 右预览),否则单栏(底部预览)
* ============================================================ */
const isDesktop = ref(false);
function syncBreakpoint() {
isDesktop.value = window.innerWidth >= 1024;
}
onMounted(() => {
syncBreakpoint();
window.addEventListener('resize', syncBreakpoint);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncBreakpoint);
});
/* ============================================================ *
* Step 1 启用平台 + 字段绑定模式
* ============================================================ */
/**
* form 值镜像vben form 的 getValues 非响应式,无法被 computed 跟踪)
* 这里在 schema 的 componentProps.onChange 里把字段同步到本地 ref
*/
const scenePlatformsMirror = ref<string[]>([]);
const fieldBindingModeMirror = ref(0);
/** 当前选中启用的平台编码数组 */
const scenePlatforms = computed<string[]>(() => scenePlatformsMirror.value);
/** 字段绑定模式0=固定 1=可替换) */
const fieldBindingMode = computed(() => fieldBindingModeMirror.value);
/** form schema 字段变更同步到本地镜像(用于响应式触发 visiblePlatforms */
function handleFormScenePlatformsChange(v: any) {
scenePlatformsMirror.value = Array.isArray(v) ? v.map(String) : [];
}
function handleFormFieldBindingModeChange(v: any) {
fieldBindingModeMirror.value = Number(v) === 1 ? 1 : 0;
}
/** 实际在 Step 2 展示的平台Step 1 选中非空 → 取交集;为空 → 全部启用平台(兼容旧行为) */
const visiblePlatforms = computed(() => {
if (scenePlatforms.value.length === 0) return platforms.value;
const set = new Set(scenePlatforms.value);
return platforms.value.filter((p) => set.has(p.platform_code));
});
/* ============================================================ *
* 数据回显 / 初始化
* ============================================================ */
function normalizeAtUsers(
raw: Record<string, any> | undefined,
): Record<string, { admin_ids: number[]; userids: string[] }> {
const result: Record<string, { admin_ids: number[]; userids: string[] }> = {};
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 ensureAtSlot(platformCode: string) {
if (!selectedAtUsers.value[platformCode]) {
selectedAtUsers.value = {
...selectedAtUsers.value,
[platformCode]: { admin_ids: [], userids: [] },
};
}
}
function adminIdsOf(platformCode: string): number[] {
return selectedAtUsers.value[platformCode]?.admin_ids || [];
}
function useridsOf(platformCode: string): string[] {
return selectedAtUsers.value[platformCode]?.userids || [];
}
async function validateCurrentStep(): Promise<boolean> {
if (currentStep.value === 0) {
const e = await formApi.validate();
return !!e.valid;
}
if (currentStep.value === 1) {
// Step 1 平台配置:至少一个平台选了消息类型;必填 payload至少一个机器人
const configured = visiblePlatforms.value.some(
(p) => !!selectedMessageType.value[p.platform_code],
);
if (!configured) {
message.warning('请至少为一个平台选择消息类型');
return false;
}
if (!validatePayloadRequired()) return false;
const totalRobots = Object.values(selectedRobots.value).reduce(
(s, arr) => s + arr.length,
0,
);
if (totalRobots === 0) {
message.warning('请至少勾选一个推送机器人');
return false;
}
const targetErr = targetsPanelRef.value?.validateBeforeSubmit?.() || '';
if (targetErr) {
message.warning(targetErr);
return false;
}
return true;
}
return true;
}
function validatePayloadRequired(): boolean {
for (const platform of visiblePlatforms.value) {
const code = platform.platform_code;
const schema = currentPayloadSchema(code);
for (const field of schema) {
if (!field.required) continue;
const val = getPayloadValue(code, field.name);
const isEmpty =
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' &&
!Array.isArray(val) &&
Object.keys(val).length === 0);
if (isEmpty) {
message.warning(`${platform.name} 缺少必填字段:${field.label}`);
return false;
}
}
}
return true;
}
async function goNext() {
stepValidating.value = true;
try {
const ok = await validateCurrentStep();
if (ok) {
currentStep.value = Math.min(currentStep.value + 1, 2);
}
} finally {
stepValidating.value = false;
}
}
function goPrev() {
currentStep.value = Math.max(currentStep.value - 1, 0);
}
function initFromValues(values: Record<string, any>) {
selectedMessageType.value = {};
payloadValues.value = {};
selectedRobots.value = {};
selectedAtUsers.value = {};
sceneTargets.value = [];
fieldBindings.value = {};
testParamsByPlatform.value = {};
testingPlatform.value = {};
if (values && Object.keys(values).length > 0) {
formApi.setValues({
...values,
// 编辑回显时不带 message_config/field_bindings 进 form避免被 form schema 当字段处理)
message_config: undefined,
field_bindings: undefined,
// scene_platforms / field_binding_mode 直接 formApi.setValuesschema 中已有同名 fieldName
});
// 同步镜像formApi.setValues 不会触发 onChange所以手动同步
handleFormScenePlatformsChange(values.scene_platforms || []);
handleFormFieldBindingModeChange(values.field_binding_mode || 0);
const config = values.message_config || {};
for (const code of Object.keys(config)) {
selectedMessageType.value[code] = config[code].message_type || 'text';
payloadValues.value[code] = config[code].payload || {};
}
// 回显字段绑定(后端返回 { "<platform>.<field>": "<key>" }
const fb = values.field_bindings || {};
if (fb && typeof fb === 'object') {
fieldBindings.value = { ...fb };
}
pendingRobotIds = values.robot_ids || [];
selectedAtUsers.value = normalizeAtUsers(values.at_users);
pendingTargets = Array.isArray(values.targets) ? values.targets : [];
sceneTargets.value = pendingTargets.map((t: any) => ({
target_type: Number(t.target_type) === 1 ? 1 : 2,
platform_code: t.platform_code || 'work_wechat_app',
userid: String(t.userid || '').trim() || undefined,
admin_id: Number(t.admin_id || 0) || undefined,
chat_id: Number(t.chat_id || 0) || undefined,
at_admin_ids: (t.at_admin_ids || [])
.map(Number)
.filter((n: number) => n > 0),
at_userids: (t.at_userids || [])
.map(String)
.filter((s: string) => s !== ''),
sender_userid: String(t.sender_userid || ''),
}));
} else {
formApi.resetForm();
pendingRobotIds = [];
pendingTargets = [];
}
currentStep.value = 0;
loadData();
}
watch(
() => props.values,
(values) => {
initFromValues(values || {});
},
{ immediate: true },
);
async function loadData() {
loading.value = true;
try {
const [platformList, typesRes, adminRes] = await Promise.all([
getOaPlatformList(),
getOaMessageTypes(),
getAdminList({ page: 1, pageSize: 500 }),
]);
const platformRows: any[] = Array.isArray(platformList)
? platformList
: platformList?.data || [];
platforms.value = platformRows
.filter((p: any) => p.enabled === 1)
.sort((a: any, b: any) => (b.sort || 0) - (a.sort || 0));
activePlatformTab.value = platforms.value[0]?.platform_code || '';
// 注入 scene_platforms Checkbox.Group 选项;仅「新增」默认全选,「编辑」严格用详情已选
try {
formApi.updateSchema([
{
fieldName: 'scene_platforms',
componentProps: {
options: platforms.value.map((p) => ({
label: p.name,
value: p.platform_code,
})),
onChange: (e: any) => {
const val = Array.isArray(e) ? e : e?.target?.value;
handleFormScenePlatformsChange(val);
},
},
},
{
fieldName: 'field_binding_mode',
componentProps: {
onChange: (e: any) => {
const val = e?.target?.value ?? e;
handleFormFieldBindingModeChange(val);
},
},
},
]);
const platformsAll = platforms.value.map((p) => p.platform_code);
if (props.isUpdate) {
// 编辑只用详情回显的已选平台禁止兜底全选getValues 是异步,不要直接点属性)
const fromDetail = Array.isArray(props.values?.scene_platforms)
? props.values.scene_platforms.map(String)
: [];
const formVals = await formApi.getValues();
const fromForm = Array.isArray(formVals?.scene_platforms)
? formVals.scene_platforms.map(String)
: [];
const selected =
fromDetail.length > 0 ? fromDetail : fromForm;
handleFormScenePlatformsChange(selected);
if (selected.length > 0) {
await formApi.setValues({
...formVals,
scene_platforms: selected,
});
}
} else {
// 新增:默认全选当前启用平台
const formVals = await formApi.getValues();
await formApi.setValues({
...formVals,
scene_platforms: platformsAll,
});
handleFormScenePlatformsChange(platformsAll);
}
// 字段绑定模式:编辑用详情值,新增保持默认;禁止用 getValues 异步结果覆盖(会竞态打回 0
if (props.isUpdate) {
handleFormFieldBindingModeChange(props.values?.field_binding_mode ?? 0);
}
} catch (e) {
console.warn('scene_platforms schema 注入失败', e);
}
const typesData = (typesRes as any)?.data ?? typesRes ?? {};
messageTypesMap.value = typesData;
const robotRes = await getOaRobotList({
page: 1,
pageSize: 500,
});
const robotRows: any[] = robotRes?.items || robotRes?.data || [];
const boundIdSet = new Set(pendingRobotIds.map((id) => Number(id)));
allRobots.value = robotRows.filter((r: any) => {
const onPlatform = platforms.value.some(
(p: any) => p.platform_code === r.platform_code,
);
if (!onPlatform) return false;
return Number(r.status) === 1 || boundIdSet.has(Number(r.id));
});
const adminRows: any[] = adminRes?.items || adminRes?.data || [];
adminList.value = adminRows.map((a: any) => ({
id: a.id,
name: a.nick_name || a.name || a.username || `员工${a.id}`,
avatar: a.avatar || '',
}));
if (pendingRobotIds.length > 0) {
selectedRobots.value = groupRobotsByPlatform(pendingRobotIds);
}
await refreshAppMembers();
await loadWwTestUsers();
for (const platform of platforms.value) {
const code = platform.platform_code;
ensureAtSlot(code);
if (!selectedMessageType.value[code]) {
const types = messageTypesMap.value[code] || [];
const defaultType = types.find((t) => t.message_type === 'text');
selectedMessageType.value[code] =
defaultType?.message_type || types[0]?.message_type || '';
payloadValues.value[code] = {};
}
}
} catch (e) {
console.error('加载场景数据失败', e);
message.error('加载场景数据失败,请刷新重试');
} finally {
loading.value = false;
}
}
function groupRobotsByPlatform(robotIds: number[]): Record<string, number[]> {
const result: Record<string, number[]> = {};
for (const id of robotIds) {
const robot = allRobots.value.find((r) => r.id === id);
if (robot) {
if (!result[robot.platform_code]) {
result[robot.platform_code] = [];
}
result[robot.platform_code].push(id);
}
}
return result;
}
/* ============================================================ *
* 字段绑定(任务 2自动生成默认 business_key
* text 类 → name1/name2image 类 → img1/img2
* video 类 → video1voice 类 → voice1file 类 → file1
* ============================================================ */
/** 字段类型分类(按 acceptTypes / component 推断) */
function fieldCategory(field: PayloadFieldSchema): 'text' | 'image' | 'video' | 'voice' | 'file' {
const types = fieldAcceptTypes(field);
// xk_file.type0=图片 1=视频 2=音频 3=excel 4=压缩包
if (types.includes(0)) return 'image';
if (types.includes(1)) return 'video';
if (types.includes(2)) return 'voice';
if (types.length > 0 && types.every((t) => t >= 3)) return 'file';
// Textarea/Input/InputNumber/RadioGroup/Select 等文本类
return 'text';
}
const FIELD_PREFIX: Record<string, string> = {
text: 'name',
image: 'img',
video: 'video',
voice: 'voice',
file: 'file',
};
/** 计算某平台下某字段的默认 business_key按字段类型在同类型中递增 */
function defaultBindingKey(platformCode: string, fieldName: string): string {
const schema = currentPayloadSchema(platformCode);
const counts: Record<string, number> = {
text: 0,
image: 0,
video: 0,
voice: 0,
file: 0,
};
for (const f of schema) {
const cat = fieldCategory(f);
counts[cat] += 1;
if (f.name === fieldName) {
return `${FIELD_PREFIX[cat]}${counts[cat]}`;
}
}
// 兜底:用 text+1
return 'name1';
}
/** 字段绑定 path统一 "<platform>.payload.<fieldPath>"(渠道只读 payload */
function bindingPathKey(platformCode: string, fieldPath: string): string {
const fp = String(fieldPath || '').replace(/^\.?payload\./, '');
return `${platformCode}.payload.${fp}`;
}
/**
* 展开某平台可绑定字段(普通 schema + NewsList/LinkList 按条展开子字段)
* fieldPath 相对 payload如 content / articles.0.title / links.1.title
*/
function expandBindingFields(
platformCode: string,
): Array<{ fieldPath: string; label: string }> {
const schema = currentPayloadSchema(platformCode);
const payload = payloadValues.value[platformCode] || {};
const list: Array<{ fieldPath: string; label: string }> = [];
for (const field of schema) {
const comp = String(field.component || '');
if (comp === 'NewsList') {
const articles = Array.isArray(payload[field.name])
? payload[field.name]
: [];
const count = Math.max(articles.length, 1);
const subKeys: Array<{ key: string; label: string }> = [
{ key: 'title', label: '标题' },
{ key: 'description', label: '描述' },
{ key: 'url', label: '链接' },
{ key: 'picurl', label: '封面' },
];
for (let i = 0; i < count; i++) {
for (const sub of subKeys) {
list.push({
fieldPath: `${field.name}.${i}.${sub.key}`,
label: `${field.label || '图文'}#${i + 1}.${sub.label}`,
});
}
}
continue;
}
if (comp === 'LinkList') {
const links = Array.isArray(payload[field.name]) ? payload[field.name] : [];
const count = Math.max(links.length, 1);
const subKeys: Array<{ key: string; label: string }> = [
{ key: 'title', label: '标题' },
{ key: 'messageURL', label: '链接' },
{ key: 'picURL', label: '封面' },
];
for (let i = 0; i < count; i++) {
for (const sub of subKeys) {
list.push({
fieldPath: `${field.name}.${i}.${sub.key}`,
label: `${field.label || '链接'}#${i + 1}.${sub.label}`,
});
}
}
continue;
}
// 列表整块不单独绑;普通输入/图片等一对一
if (['ButtonList', 'PostEditor', 'TemplateCardEditor'].includes(comp)) {
continue;
}
list.push({
fieldPath: field.name,
label: String(field.label || field.name),
});
}
return list;
}
/** 计算展开字段的默认 business_key按展开顺序 name1/name2… */
function defaultBindingKeyForPath(
platformCode: string,
fieldPath: string,
): string {
const fields = expandBindingFields(platformCode);
let idx = 0;
for (const f of fields) {
idx += 1;
if (f.fieldPath === fieldPath) {
return `name${idx}`;
}
}
return 'name1';
}
/** 读取某字段的 business_key兼容旧路径 platform.field */
function getBindingKey(platformCode: string, fieldPath: string): string {
const path = bindingPathKey(platformCode, fieldPath);
if (fieldBindings.value[path] !== undefined) {
return fieldBindings.value[path];
}
// 兼容旧数据work_wechat.content无 payload. 段)
const legacy = `${platformCode}.${fieldPath}`;
if (fieldBindings.value[legacy] !== undefined) {
return fieldBindings.value[legacy];
}
return defaultBindingKeyForPath(platformCode, fieldPath);
}
function setBindingKey(platformCode: string, fieldPath: string, val: string) {
const path = bindingPathKey(platformCode, fieldPath);
const next = { ...fieldBindings.value, [path]: val };
// 清掉同字段旧路径,避免双写歧义
const legacy = `${platformCode}.${fieldPath}`;
if (legacy in next) {
delete next[legacy];
}
fieldBindings.value = next;
}
/**
* Step3 可替换业务字段列表(与 expandBindingFields 对齐)
*/
function replaceableFieldsOf(
platformCode: string,
): Array<{ businessKey: string; payloadPath: string; label: string }> {
if (fieldBindingMode.value !== 1) return [];
const seen = new Set<string>();
const list: Array<{
businessKey: string;
payloadPath: string;
label: string;
}> = [];
for (const f of expandBindingFields(platformCode)) {
const path = bindingPathKey(platformCode, f.fieldPath);
const businessKey = String(getBindingKey(platformCode, f.fieldPath) || '').trim();
if (!businessKey || seen.has(businessKey)) continue;
seen.add(businessKey);
list.push({
businessKey,
payloadPath: path,
label: f.label,
});
}
return list;
}
/**
* 打包入库/测试用的 field_bindings补齐展开字段默认 key
*/
function resolveFieldBindingsMap(): Record<string, string> {
if (fieldBindingMode.value !== 1) return {};
const out: Record<string, string> = { ...fieldBindings.value };
for (const p of visiblePlatforms.value) {
const code = p.platform_code;
for (const f of expandBindingFields(code)) {
const path = bindingPathKey(code, f.fieldPath);
if (out[path] === undefined) {
// 迁移旧路径
const legacy = `${code}.${f.fieldPath}`;
out[path] =
out[legacy] !== undefined
? out[legacy]
: defaultBindingKeyForPath(code, f.fieldPath);
if (legacy in out) {
delete out[legacy];
}
}
}
}
const cleaned: Record<string, string> = {};
for (const [k, v] of Object.entries(out)) {
const key = String(v || '').trim();
if (key !== '') cleaned[k] = key;
}
return cleaned;
}
/** 读取/写入某平台测试 vars */
function getTestVar(platformCode: string, businessKey: string): string {
return testParamsByPlatform.value[platformCode]?.vars?.[businessKey] || '';
}
function setTestVar(platformCode: string, businessKey: string, val: string) {
const prev = testParamsByPlatform.value[platformCode] || {
phones: [],
vars: {},
};
testParamsByPlatform.value = {
...testParamsByPlatform.value,
[platformCode]: {
phones: prev.phones || [],
vars: { ...(prev.vars || {}), [businessKey]: val },
},
};
}
/* ============================================================ *
* 草稿组装(保存 + 测试发送共用)
* ============================================================ */
async function assembleSceneDraft(platformCodes?: string[]) {
const baseValues = await formApi.getValues();
const message_config: Record<string, any> = {};
// 仅组装传入 platformCodes 列表(测试用),未传则全部
const targetPlatforms =
platformCodes && platformCodes.length > 0
? visiblePlatforms.value.filter((p) =>
platformCodes.includes(p.platform_code),
)
: visiblePlatforms.value;
for (const platform of targetPlatforms) {
const code = platform.platform_code;
const messageType = selectedMessageType.value[code];
if (!messageType) continue;
message_config[code] = {
message_type: messageType,
payload: payloadValues.value[code] || {},
};
}
const robot_ids: number[] = [];
const filterSet = platformCodes ? new Set(platformCodes) : null;
for (const platformCode of Object.keys(selectedRobots.value)) {
if (filterSet && !filterSet.has(platformCode)) continue;
robot_ids.push(...(selectedRobots.value[platformCode] || []));
}
const at_users: Record<
string,
{ admin_ids: number[]; userids: string[] }
> = {};
for (const platformCode of Object.keys(selectedAtUsers.value)) {
if (filterSet && !filterSet.has(platformCode)) continue;
if (isWorkWechatApp(platformCode)) continue;
const slot = selectedAtUsers.value[platformCode] || {
admin_ids: [],
userids: [],
};
if (slot.admin_ids.length > 0 || slot.userids.length > 0) {
at_users[platformCode] = {
admin_ids: slot.admin_ids,
userids: slot.userids,
};
}
}
// 字段绑定:仅当 mode=1 时打包(补齐默认 business_key
const fb =
fieldBindingMode.value === 1 ? resolveFieldBindingsMap() : undefined;
return {
...baseValues,
message_config,
robot_ids,
at_users,
targets: sceneTargets.value,
field_binding_mode: fieldBindingMode.value,
field_bindings: fb,
};
}
function mapSceneResultsToModalItems(rows: any[]): TestResultItem[] {
return rows.map((r) => ({
message_type: String(r.message_type || r.platform_code || 'text'),
name: String(
r.name ||
`${r.robot_name || ''}${r.target_label ? `${r.target_label}` : ''}`,
),
success: !!r.success,
message: String(r.message || ''),
request: r.request,
response: r.response ?? {},
cost_ms: Number(r.cost_ms || 0),
}));
}
async function handleSave() {
const e = await formApi.validate();
if (!e.valid) return;
const targetErr = targetsPanelRef.value?.validateBeforeSubmit?.() || '';
if (targetErr) {
message.warning(targetErr);
return;
}
saving.value = true;
try {
const payload = await assembleSceneDraft();
const submitApi = props.isUpdate ? updateOaScene : createOaScene;
await submitApi(payload);
message.success('保存成功');
emit('saved');
emit('back');
} finally {
saving.value = false;
}
}
/**
* 分平台独立测试发送(任务 6
* @param platformCode 单平台测试;不传则全部启用平台
*/
async function handleTestSend(platformCode?: string) {
const codes = platformCode ? [platformCode] : [];
const draft = await assembleSceneDraft(codes);
if (!draft.robot_ids || draft.robot_ids.length === 0) {
message.warning('请先勾选至少一个推送机器人');
return;
}
if (!draft.message_config || Object.keys(draft.message_config).length === 0) {
message.warning('请先配置该平台的消息类型');
return;
}
const targetErr = targetsPanelRef.value?.validateBeforeSubmit?.() || '';
if (targetErr) {
message.warning(targetErr);
return;
}
// 取该平台测试参数:可替换业务字段 vars + phones
// 一键测全部时合并各平台 varsbusiness_key 场景内通常唯一)
let mergedVars: Record<string, string> = {};
if (platformCode) {
const tp = testParamsByPlatform.value[platformCode] || {
phones: [],
vars: {},
};
mergedVars = { ...(tp.vars || {}) };
} else {
for (const p of visiblePlatforms.value) {
const slot = testParamsByPlatform.value[p.platform_code];
if (slot?.vars) {
mergedVars = { ...mergedVars, ...slot.vars };
}
}
}
const tp = testParamsByPlatform.value[platformCode || ''] || {
phones: [],
vars: {},
};
const test_params: any = {
vars: mergedVars,
};
// @ 手机号(企微/钉钉用):把 phones 合并到 atMobiles
const phones = Array.isArray(tp.phones) ? tp.phones : [];
if (phones.length > 0) {
const atUsers = { ...(draft.at_users || {}) };
if (platformCode) {
const exist = atUsers[platformCode] || { admin_ids: [], userids: [] };
atUsers[platformCode] = {
admin_ids: exist.admin_ids,
userids: exist.userids,
atMobiles: phones,
} as any;
}
draft.at_users = atUsers;
}
// work_wechat_app临时测试成员合并进 targets追加个人目标不覆盖场景已配群/人)
let testTargets = Array.isArray(draft.targets) ? [...draft.targets] : [];
if (
(!platformCode || isWorkWechatApp(platformCode)) &&
testWwMemberUserids.value.length > 0
) {
const existUserids = new Set(
testTargets
.filter((t: any) => Number(t.target_type) === 1)
.map((t: any) => String(t.userid || '').trim())
.filter(Boolean),
);
for (const uid of testWwMemberUserids.value) {
const userid = String(uid || '').trim();
if (!userid || existUserids.has(userid)) continue;
testTargets.push({
target_type: 1,
platform_code: 'work_wechat_app',
userid,
admin_id: 0,
});
existUserids.add(userid);
}
}
// loading 标记
if (platformCode) {
testingPlatform.value = {
...testingPlatform.value,
[platformCode]: true,
};
} else {
testing.value = true;
}
try {
const result = await testSendOaScene({
id: Number(draft.id || 0) || undefined,
scene_code: String(draft.scene_code || ''),
scene_name: String(draft.scene_name || ''),
message_config: draft.message_config,
robot_ids: draft.robot_ids,
at_users: draft.at_users,
targets: testTargets,
platform_codes: codes,
field_binding_mode: draft.field_binding_mode,
field_bindings: draft.field_bindings,
test_params,
});
const rows = Array.isArray(result?.results) ? result.results : [];
const total = result?.total ?? rows.length;
const succ =
result?.success_count ?? rows.filter((r: any) => r.success).length;
const fail = total - succ;
const summary = `${total} 条,成功 ${succ},失败 ${fail}`;
if (fail === 0) {
message.success(summary);
} else if (succ === 0) {
message.error(summary);
} else {
message.warning(summary);
}
// 41054 客户群未激活专门提示
const has41054 = rows.some(
(r: any) =>
String(r.message || '').includes('41054') ||
JSON.stringify(r.response || {}).includes('41054'),
);
if (has41054) {
message.error(errCode41054Tip, 8);
}
testResultModalApi.setData({
results: mapSceneResultsToModalItems(rows),
summary,
robotName: String(draft.scene_name || ''),
platformCode: platformCode || 'scene',
testContent:
Object.values(test_params.vars || {})
.filter((v: any) => String(v || '').trim() !== '')
.join(' / ') || '(使用 Step2 原配置)',
sentAt: Math.floor(Date.now() / 1000),
});
testResultModalApi.open();
} catch (e: any) {
message.error(e?.message || '测试发送失败');
} finally {
if (platformCode) {
testingPlatform.value = {
...testingPlatform.value,
[platformCode]: false,
};
} else {
testing.value = false;
}
}
}
function robotsOfPlatform(platformCode: string): any[] {
return allRobots.value.filter((r: any) => r.platform_code === platformCode);
}
function checkedRobotsOfPlatform(platformCode: string): number[] {
return selectedRobots.value[platformCode] || [];
}
function toggleAllRobots(platformCode: string, e: any) {
const checked = (e?.target as HTMLInputElement)?.checked;
const robotIds = robotsOfPlatform(platformCode).map((r) => r.id);
selectedRobots.value = {
...selectedRobots.value,
[platformCode]: checked ? robotIds : [],
};
if (isWorkWechatApp(platformCode)) {
refreshAppMembers();
}
}
function toggleAllAtUsers(platformCode: string, e: any) {
const checked = (e?.target as HTMLInputElement)?.checked;
const adminIds = adminList.value.map((a) => a.id);
ensureAtSlot(platformCode);
selectedAtUsers.value = {
...selectedAtUsers.value,
[platformCode]: {
admin_ids: checked ? adminIds : [],
userids: useridsOf(platformCode),
},
};
}
/**
* 按已选应用 API 机器人合并拉取群成员userid 去重)
*/
async function refreshAppMembers() {
const appCodes = platforms.value
.map((p) => p.platform_code)
.filter((code) => isWorkWechatApp(code));
for (const code of appCodes) {
const robotIds = selectedRobots.value[code] || [];
if (robotIds.length === 0) {
membersByPlatform.value = { ...membersByPlatform.value, [code]: [] };
continue;
}
try {
const list = await getOaRobotChatMembers({ robot_ids: robotIds });
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
membersByPlatform.value = {
...membersByPlatform.value,
[code]: rows.map((m) => ({
userid: String(m.userid),
name: String(m.name || ''),
})),
};
} catch (e) {
console.error('加载应用群成员失败', e);
membersByPlatform.value = { ...membersByPlatform.value, [code]: [] };
}
}
}
/**
* 解析字段 acceptTypes兼容缺省图片字段默认 [0]
* xk_file.type 实际值0=图片 1=视频 2=音频 3=excel 4=压缩包
*/
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];
}
function isImageMediaField(field: PayloadFieldSchema) {
const types = fieldAcceptTypes(field);
// 仅图片类型xk_file.type=0走图片上传器视频/语音/文件走通用文件上传器
return types.length === 1 && types[0] === 0;
}
function imageUrlsOf(platformCode: string, fieldName: string): string[] {
const url = getPayloadValue(platformCode, fieldName);
return url ? [String(url)] : [];
}
function setImageUrls(
platformCode: string,
fieldName: string,
urls: string[],
) {
setPayloadValue(platformCode, fieldName, urls[0] || '');
}
function fileInputAccept(field: PayloadFieldSchema) {
const types = fieldAcceptTypes(field);
if (types.includes(3)) {
return 'audio/*,.amr,.mp3,.wav';
}
return '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,*/*';
}
function currentPayloadSchema(platformCode: string): PayloadFieldSchema[] {
const messageType = selectedMessageType.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 {
return JSON.parse(typeInfo.payload_schema);
} catch {
return [];
}
}
function getPayloadValue(platformCode: string, fieldName: string): any {
return payloadValues.value[platformCode]?.[fieldName];
}
function setPayloadValue(platformCode: string, fieldName: string, value: any) {
const current = payloadValues.value[platformCode] || {};
payloadValues.value = {
...payloadValues.value,
[platformCode]: { ...current, [fieldName]: value },
};
}
function handleMessageTypeChange(platformCode: string, messageType: string) {
selectedMessageType.value = {
...selectedMessageType.value,
[platformCode]: messageType,
};
payloadValues.value = {
...payloadValues.value,
[platformCode]: {},
};
}
const isEmpty = computed(() => platforms.value.length === 0);
/* ============================================================ *
* 消息预览:把当前 payload 拼成 MsgPreview 需要的格式
* ============================================================ */
function previewPayloadOf(platformCode: string): Record<string, any> {
const messageType = selectedMessageType.value[platformCode];
const payload = { ...(payloadValues.value[platformCode] || {}) };
// template_card 类型:把 payload.template_card 暴露给 MsgPreview
if (messageType === 'template_card' && payload.template_card) {
return payload;
}
// 可替换模式:用 Step3 vars 按绑定路径覆盖预览(仅顶层字段名能直接映射时)
const vars = testParamsByPlatform.value[platformCode]?.vars || {};
const merged = { ...payload };
if (fieldBindingMode.value === 1) {
for (const item of replaceableFieldsOf(platformCode)) {
const val = vars[item.businessKey];
if (val === undefined || val === '') continue;
// path = "platform.fieldName" → 取 fieldName 写入 payload 预览
const parts = item.payloadPath.split('.');
const fieldName = parts.length >= 2 ? parts[parts.length - 1] : '';
if (fieldName) {
merged[fieldName] = val;
}
}
}
return merged;
}
/** 当前预览的平台(默认跟随 activePlatformTab */
const previewPlatformCode = computed(() => activePlatformTab.value);
const previewMessageType = computed(
() => selectedMessageType.value[previewPlatformCode.value] || 'text',
);
/* ============================================================ *
* 机器人 / @ 人 / Tab 切换清理
* ============================================================ */
watch(
() => visiblePlatforms.value,
(list) => {
const validCodes = new Set(list.map((p) => p.platform_code));
const newRobots: Record<string, number[]> = {};
const newAtUsers: Record<
string,
{ admin_ids: number[]; userids: string[] }
> = {};
for (const code of validCodes) {
newRobots[code] = selectedRobots.value[code] || [];
newAtUsers[code] = selectedAtUsers.value[code] || {
admin_ids: [],
userids: [],
};
}
selectedRobots.value = newRobots;
selectedAtUsers.value = newAtUsers;
// 如果当前 Tab 平台不在 visiblePlatforms 中,切换到第一个
if (!validCodes.has(activePlatformTab.value)) {
activePlatformTab.value = list[0]?.platform_code || '';
}
},
);
</script>
<template>
<Page :title="pageTitle" auto-content-height>
<template #extra>
<div class="flex items-center gap-2">
<Button @click="emit('back')">返回</Button>
<Button v-if="currentStep > 0" @click="goPrev">上一步</Button>
<Button
v-if="currentStep < 2"
:loading="stepValidating"
type="primary"
@click="goNext"
>
下一步
</Button>
<Button
v-if="currentStep === 2"
type="primary"
:loading="saving"
@click="handleSave"
>
保存
</Button>
</div>
</template>
<Spin :spinning="loading">
<!-- 分步导航3 -->
<Steps :current="currentStep" class="mb-4" size="small">
<Step
v-for="title in STEP_TITLES"
:key="title"
:title="title"
/>
</Steps>
<!-- ============== Step 1基础信息 ============== -->
<div v-show="currentStep === 0">
<Card :bordered="false" class="scene-card">
<Form />
</Card>
</div>
<!-- ============== Step 2平台配置合并原 2+3 ============== -->
<div v-show="currentStep === 1">
<Alert
v-if="scenePlatforms.length === 0 && platforms.length > 0"
type="info"
show-icon
message="未指定启用平台,默认展示所有已启用平台"
description="可在「基础信息」步骤中精确选择本场景要启用的平台"
class="mb-3"
/>
<div v-if="isEmpty" class="empty-hint py-4 text-center">
暂无启用的平台请先在系统配置 OA 通知中启用平台
</div>
<!-- 桌面左右双栏左配置 + 右预览 sticky移动单栏底部预览 -->
<div :class="['step2-layout', { 'step2-layout--mobile': !isDesktop }]">
<!-- 左侧配置区含顶部 sticky 平台 Tab -->
<div class="step2-config">
<div class="platform-tabs-wrapper">
<Tabs v-model:active-key="activePlatformTab" size="small">
<Tabs.TabPane
v-for="platform in visiblePlatforms"
:key="platform.platform_code"
>
<template #tab>
<span class="platform-tab-label">
<VbenIcon
v-if="platform.icon"
:icon="platform.icon"
class="platform-icon"
/>
{{ platform.name }}
</span>
</template>
<div class="oa-scene-tab">
<!-- 消息类型 -->
<Card :bordered="false" size="small" class="sub-card">
<div class="section-title">消息类型</div>
<RadioGroup
:value="selectedMessageType[platform.platform_code]"
@update:value="
(val) =>
handleMessageTypeChange(
platform.platform_code,
val,
)
"
>
<Radio
v-for="opt in messageTypesMap[
platform.platform_code
] || []"
:key="opt.message_type"
:value="opt.message_type"
>
{{ opt.name }}
</Radio>
</RadioGroup>
</Card>
<!-- 消息内容payload 表单,复用 PayloadSchemaForm -->
<Card :bordered="false" size="small" class="sub-card">
<div class="section-title">消息内容</div>
<div class="payload-form">
<PayloadSchemaForm
:schema="currentPayloadSchema(platform.platform_code)"
:model-value="
payloadValues[platform.platform_code] || {}
"
@update:model-value="
(v) => {
payloadValues = {
...payloadValues,
[platform.platform_code]: v || {},
};
}
"
/>
<div
v-if="
fieldBindingMode === 1 &&
expandBindingFields(platform.platform_code).length >
0
"
class="binding-panel"
>
<div class="section-title">业务字段绑定</div>
<div class="mb-2 text-xs text-gray-500">
dispatch 时用业务 key 覆盖对应 payload 路径;图文按每条卡片的标题/描述/链接/封面分别绑定。投递对象与机器人请在下方「推送机器人 / 投递目标」配置。
</div>
<div
v-for="f in expandBindingFields(
platform.platform_code,
)"
:key="f.fieldPath"
class="binding-row"
>
<label class="binding-label">
{{ f.label }}
<span class="binding-hint">
{{ f.fieldPath }}
</span>
</label>
<Input
:value="
getBindingKey(
platform.platform_code,
f.fieldPath,
)
"
placeholder="自动生成,可手改"
size="small"
allow-clear
@update:value="
(val) =>
setBindingKey(
platform.platform_code,
f.fieldPath,
val,
)
"
/>
</div>
</div>
</div>
</Card>
<!-- 推送机器人(合并自原 Step 3 -->
<Card :bordered="false" size="small" class="sub-card">
<div class="section-title">
推送机器人
<Checkbox
class="float-right"
:checked="
robotsOfPlatform(platform.platform_code).length > 0 &&
checkedRobotsOfPlatform(platform.platform_code)
.length ===
robotsOfPlatform(platform.platform_code).length
"
@change="
toggleAllRobots(platform.platform_code, $event)
"
>
全选
</Checkbox>
</div>
<div
v-if="robotsOfPlatform(platform.platform_code).length === 0"
class="empty-hint"
>
该平台暂无启用的机器人
</div>
<UserTagSelect
v-else
:model-value="
checkedRobotsOfPlatform(platform.platform_code)
"
:options="
robotsOfPlatform(platform.platform_code).map((r) => ({
value: r.id,
label: `${r.name}${Number(r.status) === 0 ? '(已禁用)' : ''}`,
}))
"
placeholder="选择该平台下的推送机器人(可多选)"
@update:model-value="
(v: (string | number)[]) => {
selectedRobots = {
...selectedRobots,
[platform.platform_code]: v.map((n) => Number(n)),
};
if (isWorkWechatApp(platform.platform_code)) {
refreshAppMembers();
}
}
"
/>
</Card>
<!-- 投递目标 / @员工(合并自原 Step 3 -->
<Card
v-if="isWorkWechatApp(platform.platform_code)"
:bordered="false"
size="small"
class="sub-card"
>
<div class="section-title">投递目标(企微应用 API</div>
<SceneTargetsPanel
ref="targetsPanelRef"
v-model="sceneTargets"
:admin-list="adminList"
:platform-code="platform.platform_code"
/>
</Card>
<Card
v-else
:bordered="false"
size="small"
class="sub-card"
>
<div class="section-title">
@ 系统员工(按手机号)
<Checkbox
class="float-right"
:checked="
adminList.length > 0 &&
adminIdsOf(platform.platform_code).length ===
adminList.length
"
@change="
toggleAllAtUsers(platform.platform_code, $event)
"
>
全选
</Checkbox>
</div>
<div v-if="adminList.length === 0" class="empty-hint">
暂无员工可选
</div>
<UserTagSelect
v-else
:model-value="adminIdsOf(platform.platform_code)"
:options="
adminList.map((a) => ({
value: a.id,
label: a.name,
avatar: a.avatar || '',
}))
"
placeholder="选择要 @ 的员工(可多选)"
@update:model-value="
(v: (string | number)[]) => {
selectedAtUsers = {
...selectedAtUsers,
[platform.platform_code]: {
admin_ids: v.map((n) => Number(n)),
userids: useridsOf(platform.platform_code),
},
};
}
"
/>
</Card>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</div>
<!-- 右侧预览区(桌面 sticky / 移动底部) -->
<div class="step2-preview">
<div class="preview-inner">
<div class="preview-title">实时消息预览</div>
<MsgPreview
v-if="previewPlatformCode"
:platform-code="previewPlatformCode"
:message-type="previewMessageType"
:payload="previewPayloadOf(previewPlatformCode)"
/>
<Empty
v-else
description="请先在 Step 1 启用至少一个平台"
:image-style="{ height: '40px' }"
/>
</div>
</div>
</div>
</div>
<!-- ============== Step 3测试发送按平台独立测试 ============== -->
<div v-show="currentStep === 2">
<Alert
type="info"
show-icon
class="mb-3"
message="分平台独立测试"
description="每个平台一行,可单独测试。可替换模式下填写业务字段测试值;固定模式直接用 Step2 配置。@ 手机号会自动记忆"
/>
<Alert
v-if="false"
type="warning"
show-icon
class="mb-3"
:message="errCode41054Tip"
/>
<div v-if="visiblePlatforms.length === 0" class="empty-hint">
请先在 Step 1 启用至少一个平台
</div>
<Card
v-for="platform in visiblePlatforms"
:key="platform.platform_code"
:bordered="false"
size="small"
class="test-row-card"
>
<div class="test-row">
<div class="test-row-head">
<span class="platform-name">
<VbenIcon
v-if="platform.icon"
:icon="platform.icon"
class="platform-icon"
/>
{{ platform.name }}
</span>
<Tag v-if="isWorkWechatApp(platform.platform_code)" color="orange">
企微应用 API
</Tag>
<Tag v-else color="blue">群机器人</Tag>
</div>
<!-- 企微应用:临时测试成员(不改场景配置,仅本次发送追加个人目标) -->
<div
v-if="isWorkWechatApp(platform.platform_code)"
class="test-row-phones"
>
<div class="mb-1 text-xs">临时测试成员</div>
<Select
mode="multiple"
show-search
allow-clear
class="w-full"
placeholder="可选:从组织架构选成员单独测个人消息"
:value="testWwMemberUserids"
:options="wwTestUserOptions"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
:max-tag-count="4"
@change="(v: string[]) => (testWwMemberUserids = v || [])"
/>
<div class="mt-1 text-xs text-gray-400">
选中后追加到本次测试的个人目标;未选则仍用 Step2 已配置的投递目标
</div>
<div
v-if="wwTestUserOptions.length === 0"
class="mt-1 text-xs text-orange-500"
>
请先在 OA 群聊 → 员工 Tab 同步组织架构
</div>
</div>
<!-- 飞书 webhook 不支持手机号 @,隐藏 phones 字段 -->
<div
v-if="!isWorkWechatApp(platform.platform_code)"
class="test-row-phones"
>
<div class="mb-1 text-xs">@ 手机号</div>
<PhoneMemoryPicker
v-if="platform.platform_code !== 'feishu'"
:platform-code="platform.platform_code"
:model-value="
(testParamsByPlatform[platform.platform_code] || {}).phones ||
[]
"
placeholder="输入手机号回车添加(@ 用)"
@update:model-value="
(v: string[]) => {
const prev = testParamsByPlatform[platform.platform_code] || {
phones: [],
vars: {},
};
testParamsByPlatform = {
...testParamsByPlatform,
[platform.platform_code]: {
phones: v,
vars: { ...(prev.vars || {}) },
},
};
}
"
/>
<div v-else class="text-xs text-gray-400">
飞书 webhook 不支持手机号 @,仅 @all 生效
</div>
</div>
<!-- 可替换模式:按 field_bindings 动态业务字段;固定模式用 Step2 配置 -->
<div class="test-row-params">
<template v-if="fieldBindingMode === 1">
<div class="mb-1 text-xs text-gray-500">
可替换业务字段(对应 Step2 绑定;留空则用 Step2 payload 原值)
</div>
<div
v-if="replaceableFieldsOf(platform.platform_code).length === 0"
class="text-xs text-orange-500"
>
该平台未配置可替换字段,请先在 Step2 为消息字段填写业务字段名
</div>
<div v-else class="param-grid">
<div
v-for="f in replaceableFieldsOf(platform.platform_code)"
:key="f.businessKey"
class="param-cell"
>
<label>
{{ f.label }}
<span class="text-gray-400">{{ f.businessKey }}</span>
</label>
<Input
:value="getTestVar(platform.platform_code, f.businessKey)"
:placeholder="`测试值,对应 ${f.businessKey}`"
size="small"
allow-clear
@update:value="
(v) =>
setTestVar(
platform.platform_code,
f.businessKey,
v || '',
)
"
/>
</div>
</div>
</template>
<div v-else class="text-xs text-gray-500">
当前为固定内容模式,测试将直接使用 Step2 已配置的消息内容
</div>
</div>
<div class="test-row-footer">
<Button
type="primary"
size="small"
:loading="!!testingPlatform[platform.platform_code]"
:disabled="
!selectedMessageType[platform.platform_code] ||
(checkedRobotsOfPlatform(platform.platform_code).length === 0 &&
!isWorkWechatApp(platform.platform_code))
"
@click="handleTestSend(platform.platform_code)"
>
测试本平台
</Button>
<span class="text-xs text-gray-400">
{{
isWorkWechatApp(platform.platform_code)
? testWwMemberUserids.length > 0
? `将追加 ${testWwMemberUserids.length} 个临时成员 + 场景投递目标`
: '仅发送给已配置的投递目标(可上方选临时成员)'
: `将发送给 ${checkedRobotsOfPlatform(platform.platform_code).length} 个机器人`
}}
</span>
</div>
</div>
</Card>
<div class="mt-4 text-center">
<Button :loading="testing" @click="handleTestSend()">
一键测试全部平台
</Button>
</div>
</div>
</Spin>
<TestResultModalComponent />
</Page>
</template>
<style scoped>
/* ============ Step 2 双栏布局 ============ */
.step2-layout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
align-items: start;
}
.step2-layout--mobile {
grid-template-columns: 1fr;
}
.step2-config {
min-width: 0;
}
.platform-tabs-wrapper {
position: sticky;
top: 0;
z-index: 10;
}
.step2-preview {
position: sticky;
top: 16px;
}
.step2-layout--mobile .step2-preview {
position: static;
margin-top: 16px;
}
.preview-inner {
border-radius: 8px;
padding: 12px;
border: 1px solid var(--ant-color-border-secondary, #f0f0f0);
}
.preview-title {
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text, #1d2129);
margin-bottom: 8px;
text-align: center;
}
/* ============ Tab 内容容器 ============ */
.oa-scene-tab {
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.sub-card {
border-radius: 8px;
}
.sub-card :deep(.ant-card-body) {
padding: 12px;
}
.section-title {
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text, #1d2129);
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title .float-right {
float: right;
font-weight: 400;
}
.empty-hint {
color: var(--ant-color-text-tertiary, #86909c);
}
/* ============ Payload 表单 ============ */
.payload-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.payload-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.payload-label {
font-size: 12px;
color: var(--ant-color-text-secondary, #4e5969);
}
.payload-label .required {
color: var(--ant-color-error, #f53f3f);
margin-left: 2px;
}
.binding-row {
margin-top: -4px;
margin-left: 12px;
padding-left: 8px;
border-left: 2px solid var(--ant-color-border-secondary, #e8e8e8);
display: flex;
flex-direction: column;
gap: 4px;
}
.binding-panel {
margin-top: 12px;
padding-top: 8px;
border-top: 1px dashed var(--ant-color-border-secondary, #f0f0f0);
}
.binding-row {
margin-bottom: 8px;
}
.binding-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
font-weight: 500;
color: var(--ant-color-text-secondary, #666);
}
.binding-hint {
margin-left: 4px;
color: var(--ant-color-text-tertiary, #bbb);
font-weight: 400;
font-size: 11px;
}
/* ============ Step 3 测试发送 ============ */
.test-row-card {
margin-bottom: 8px;
}
.test-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.test-row-head {
display: flex;
align-items: center;
gap: 8px;
}
.platform-name {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.platform-icon {
width: 16px;
height: 16px;
}
.platform-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.test-row-params {
margin-top: 4px;
}
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.param-cell label {
display: block;
font-size: 11px;
color: var(--ant-color-text-tertiary, #86909c);
margin-bottom: 2px;
}
.test-row-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.scene-card {
max-width: 900px;
}
</style>