1979 lines
64 KiB
Vue
1979 lines
64 KiB
Vue
<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.setValues,schema 中已有同名 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/name2,image 类 → img1/img2,
|
||
* video 类 → video1,voice 类 → voice1,file 类 → file1
|
||
* ============================================================ */
|
||
|
||
/** 字段类型分类(按 acceptTypes / component 推断) */
|
||
function fieldCategory(field: PayloadFieldSchema): 'text' | 'image' | 'video' | 'voice' | 'file' {
|
||
const types = fieldAcceptTypes(field);
|
||
// xk_file.type:0=图片 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
|
||
// 一键测全部时合并各平台 vars(business_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>
|