diff --git a/scripts/tests/wecomSceneMappings.test.mjs b/scripts/tests/wecomSceneMappings.test.mjs new file mode 100644 index 0000000..c51c287 --- /dev/null +++ b/scripts/tests/wecomSceneMappings.test.mjs @@ -0,0 +1,363 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import * as vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { compileScript, parse } from '@vue/compiler-sfc' +import ts from 'typescript' + +const { descriptor } = parse(readFileSync('src/views/settings/wecom/scenes/index.vue', 'utf8')) +const compiled = ts.transpileModule( + compileScript(descriptor, { id: 'wecom-mappings-test' }).content, + { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + } +).outputText +const field = (code, name = code, required_mapping = false) => ({ + code, + name, + required_mapping, + value_type: 'string', + description: `${name}说明` +}) +const refundFields = [ + ['refund_no', '退款单号'], + ['order_id', '订单 ID'], + ['order_no', '订单号'], + ['asset_identifier', '资产标识'], + ['actual_received_amount', '订单实收金额'], + ['requested_refund_amount', '申请退款金额'], + ['refund_voucher_key', '退款凭证'], + ['refund_reason', '退款原因'], + ['package_usage_id', '套餐使用记录 ID'], + ['refund_asset_type', '资产类型', true], + ['refund_device_type', '设备类型', true], + ['refund_device_model', '设备型号', true], + ['refund_package_used_mb', '当前退款套餐已用量(MB)', true], + ['refund_package_total_mb', '当前退款套餐总量(MB)', true], + ['refund_channel_trade_no', '原支付渠道交易流水号', true], + ['refund_applicant_remark', '申请人备注', true], + ['refund_method_code', '退款方式编码'], + ['refund_method', '退款方式', true], + ['customer_account_info', '客户收款信息'], + ['customer_voucher_key', '客户收款凭证'], + ['submitter_id', '提交人账号 ID'], + ['submitter_name', '提交人名称'] +].map((args) => field(...args)) +const control = (id, required = false) => ({ id, title: `控件 ${id}`, type: 'Text', required }) +const response = (data) => ({ code: 0, data }) +const scene = (mapping = []) => ({ + business_type: 'refund_approval', + application_id: 1, + template_id: 'template-1', + status: 1, + control_mapping: mapping +}) +const settle = () => new Promise(setImmediate) +function deferred() { + let resolve, reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function harness(overrides = {}) { + const calls = { fields: [], templates: [], saves: [], warnings: [], errors: [] } + const service = { + getBusinessFields: async (type) => { + calls.fields.push(type) + return response({ items: refundFields }) + }, + inspectTemplate: async (...args) => { + calls.templates.push(args) + return response({ name: '模板', controls: [control('text-1')] }) + }, + saveScene: async (...args) => { + calls.saves.push(args) + return response({}) + }, + getApplications: async () => response({ items: [] }), + getScenes: async () => response({ items: [] }), + ...overrides + } + const imports = { + vue: { ...vue, onMounted() {} }, + 'element-plus': { + ElMessage: { + warning: (message) => calls.warnings.push(message), + error: (message) => calls.errors.push(message), + success() {} + }, + ElTag: {} + }, + '@/composables/useCheckedColumns': { + useCheckedColumns: () => ({ columns: vue.ref([]), columnChecks: vue.ref([]) }) + }, + '@/composables/useAuth': { useAuth: () => ({ hasAuth: () => true }) }, + '@/api/modules': { WecomService: service }, + '@/config/constants': { JULY_PERMISSIONS: { wecom: {} } }, + '@/utils/business/format': { formatDateTime: (value) => value } + } + const module = { exports: {} } + new Function('require', 'module', 'exports', compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + const state = module.exports.default.setup({}, { expose() {} }) + return { state, service, calls } +} + +test('opening a new scene displays all 22 API fields before any template is synchronized', async () => { + const { state, calls } = harness() + state.createScene() + await settle() + assert.deepEqual(calls.fields, ['refund_approval']) + assert.equal(calls.templates.length, 0) + assert.equal(state.mappingRows.value.length, 22) + assert.deepEqual( + state.mappingRows.value.map((row) => row.business_field), + refundFields.map((f) => f.code) + ) + for (const [index, row] of state.mappingRows.value.entries()) { + assert.equal(row.field_name, refundFields[index].name) + assert.equal(row.required_mapping, refundFields[index].required_mapping) + assert.equal(row.control_id, '') + } +}) + +test('selecting a business type immediately replaces old rows with all fields for that type', async () => { + const { state, service } = harness() + state.createScene() + await settle() + service.getBusinessFields = async (type) => { + assert.equal(type, 'offline_recharge_approval') + return response({ items: [field('recharge_no', '充值单号', true), field('remark', '备注')] }) + } + state.sceneForm.business_type = 'offline_recharge_approval' + state.handleBusinessTypeChange() + assert.equal(state.mappingRows.value.length, 0) + await settle() + assert.deepEqual( + state.mappingRows.value.map((row) => row.business_field), + ['recharge_no', 'remark'] + ) +}) + +for (const templateFirst of [false, true]) { + test(`editing retains saved bindings and option mappings when ${templateFirst ? 'template' : 'fields'} responds first`, async () => { + const fields = deferred() + const template = deferred() + const { state } = harness({ + getBusinessFields: () => fields.promise, + inspectTemplate: () => template.promise + }) + state.editScene( + scene([ + { + business_field: 'refund_method', + control_id: 'select-1', + control_type: 'Selector', + option_mapping: { original: '原路退回' } + } + ]) + ) + const finishFields = () => fields.resolve(response({ items: refundFields })) + const finishTemplate = () => + template.resolve( + response({ + name: '退款模板', + controls: [{ ...control('select-1'), type: 'Selector' }] + }) + ) + ;(templateFirst ? finishTemplate : finishFields)() + await settle() + ;(templateFirst ? finishFields : finishTemplate)() + await settle() + assert.equal(state.mappingRows.value.length, 22) + const mapping = state.mappingRows.value.find((row) => row.business_field === 'refund_method') + assert.equal(mapping.control_id, 'select-1') + assert.equal(mapping.control_title, '控件 select-1') + assert.equal(mapping.control_type, 'Selector') + assert.deepEqual(JSON.parse(mapping.option_mapping), { original: '原路退回' }) + }) +} + +test('resyncing templates keeps all business rows but clears controls removed from the template', async () => { + const { state, service } = harness() + state.editScene( + scene([{ business_field: 'refund_no', control_id: 'text-1', control_type: 'Text' }]) + ) + await settle() + service.inspectTemplate = async () => response({ name: '新模板', controls: [control('text-2')] }) + await state.syncTemplate() + assert.equal(state.mappingRows.value.length, 22) + assert.equal(state.mappingRows.value[0].control_id, '') + assert.equal(state.mappingRows.value[0].control_type, '') +}) + +test('required_mapping blocks saving missing business bindings; optional blank fields are not submitted', async () => { + const { state, calls } = harness({ + getBusinessFields: async () => + response({ items: [field('required', '必填字段', true), field('optional')] }) + }) + state.editScene(scene()) + await settle() + await state.saveScene() + assert.equal(calls.saves.length, 0) + assert.match(calls.warnings.at(-1), /必填业务字段“必填字段”/) + const row = state.mappingRows.value[0] + row.control_id = 'text-1' + state.handleControlChange(row) + await state.saveScene() + assert.deepEqual(calls.saves[0], [ + 'refund_approval', + { + application_id: 1, + template_id: 'template-1', + status: 1, + control_mapping: [ + { + business_field: 'required', + control_id: 'text-1', + control_type: 'Text', + option_mapping: {} + } + ] + } + ]) +}) + +test('required template controls still require a business field independently of required_mapping', async () => { + const { state, calls } = harness({ + getBusinessFields: async () => response({ items: [field('optional')] }), + inspectTemplate: async () => response({ controls: [control('required-control', true)] }) + }) + state.editScene(scene()) + await settle() + assert.equal(state.parseMapping(), undefined) + assert.match(calls.warnings.at(-1), /必填控件/) +}) + +test('selecting and clearing controls updates control IDs and types without changing the business field', async () => { + const { state } = harness() + state.editScene(scene()) + await settle() + const row = state.mappingRows.value[0] + row.control_id = 'text-1' + state.handleControlChange(row) + assert.equal(row.control_type, 'Text') + assert.equal(row.control_title, '控件 text-1') + row.control_id = '' + state.handleControlChange(row) + assert.equal(row.control_type, '') + assert.equal(row.control_title, '') + assert.equal(row.business_field, 'refund_no') +}) + +test('out-of-order business field responses cannot overwrite a newer business type', async () => { + const older = deferred() + const newer = deferred() + const { state } = harness({ + getBusinessFields: (type) => (type === 'refund_approval' ? older.promise : newer.promise) + }) + state.createScene() + state.sceneForm.business_type = 'offline_recharge_approval' + state.handleBusinessTypeChange() + newer.resolve(response({ items: [field('new-type')] })) + await settle() + older.resolve(response({ items: refundFields })) + await settle() + assert.equal(state.mappingRows.value[0].business_field, 'new-type') + assert.equal(state.fieldsLoading.value, false) +}) + +test('resetting the editor rejects pending responses even when the business type is unchanged', async () => { + const pending = deferred() + const { state, service } = harness({ getBusinessFields: () => pending.promise }) + state.createScene() + service.getBusinessFields = async () => response({ items: [field('new-session')] }) + state.createScene() + await settle() + pending.resolve(response({ items: refundFields })) + await settle() + assert.equal(state.mappingRows.value[0].business_field, 'new-session') +}) + +test('changing template settings rejects pending template responses and clears old control bindings', async () => { + const pending = deferred() + const { state } = harness({ inspectTemplate: () => pending.promise }) + state.editScene(scene()) + await settle() + state.sceneForm.template_id = 'new-template' + state.handleTemplateChange() + pending.resolve(response({ name: '过期模板', controls: [control('old-control')] })) + await settle() + assert.equal(state.templateControls.value.length, 0) + assert.equal(state.templateLoaded.value, false) + assert.equal(state.mappingRows.value.length, 22) +}) + +test('loading or failed field discovery cannot save an empty incomplete mapping', async (t) => { + const pending = deferred() + const { state, calls } = harness({ getBusinessFields: () => pending.promise }) + t.mock.method(console, 'error', () => {}) + state.sceneForm.template_id = 'template' + const loading = state.loadBusinessFields('refund_approval') + await state.saveScene() + assert.equal(calls.saves.length, 0) + pending.reject(new Error('network unavailable')) + await loading + await state.saveScene() + assert.equal(calls.saves.length, 0) + assert.equal(state.businessFieldsLoaded.value, false) + assert.equal(calls.errors.length, 1) +}) + +function findMappingSection(node) { + if ( + node.type === 1 && + node.props.some( + (prop) => prop.name === 'class' && prop.value?.content.includes('mapping-section') + ) + ) + return node + return node.children?.map(findMappingSection).find(Boolean) +} + +test('rendered mapping section shows all 22 field names and exactly eight required stars before template sync', async () => { + const { state } = harness() + state.createScene() + await settle() + const section = findMappingSection(descriptor.template.ast) + const app = vue.createSSRApp({ + setup: () => ({ ...state }), + render: vue.compile(section.loc.source) + }) + for (const name of ['ElTag', 'ElSelect', 'ElOption', 'ElInput', 'ElEmpty']) { + app.component( + name, + vue.defineComponent({ + setup: + (_, { attrs, slots }) => + () => + vue.h('div', attrs, slots.default?.()) + }) + ) + } + app.directive('loading', { getSSRProps: () => ({}) }) + const html = await renderToString(app) + assert.equal((html.match(/class="mapping-row"/g) || []).length, 22) + assert.equal((html.match(/aria-label="必填"/g) || []).length, 8) + for (const field of refundFields) assert.ok(html.includes(field.name), field.name) + assert.match(html, /请先同步模板/) + assert.doesNotMatch(html, /暂无控件映射,请新增一条/) + assert.doesNotMatch(html, /控件 ID|选择模板控件后自动填充/) + assert.doesNotMatch(html, /mapping-field__description/) + for (const field of refundFields) assert.ok(!html.includes(field.description), field.description) +}) diff --git a/src/types/api/wecom.ts b/src/types/api/wecom.ts index 7150215..3f132f7 100644 --- a/src/types/api/wecom.ts +++ b/src/types/api/wecom.ts @@ -97,6 +97,7 @@ export interface WecomBusinessField { description: string name: string value_type: string + required_mapping: boolean } export interface WecomBusinessFieldList { diff --git a/src/views/settings/wecom/scenes/index.vue b/src/views/settings/wecom/scenes/index.vue index 55378df..4386d95 100644 --- a/src/views/settings/wecom/scenes/index.vue +++ b/src/views/settings/wecom/scenes/index.vue @@ -79,6 +79,7 @@ clearable filterable class="full-width" + @change="handleTemplateChange" > - + @@ -203,10 +230,11 @@ interface MappingEditor { key: number business_field: string + field_name: string + required_mapping: boolean control_title: string control_id: string control_type: string - required: boolean option_mapping: string } @@ -215,12 +243,16 @@ const loading = ref(false) const saving = ref(false) const fieldsLoading = ref(false) + const businessFieldsLoaded = ref(false) const inspectingTemplate = ref(false) const editorVisible = ref(false) const applications = ref([]) const businessFields = ref([]) const templateControls = ref([]) const templateName = ref('') + const templateLoaded = ref(false) + let fieldsRequestId = 0 + let templateRequestId = 0 const scenes = ref([]) const selectedBusinessType = ref() const nextMappingKey = ref(1) @@ -244,22 +276,29 @@ const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-') const loadBusinessFields = async (businessType: WecomBusinessType) => { + const requestId = ++fieldsRequestId fieldsLoading.value = true + businessFieldsLoaded.value = false try { const response = await WecomService.getBusinessFields(businessType) + if (requestId !== fieldsRequestId || businessType !== sceneForm.business_type) return if (response.code === 0) { businessFields.value = response.data.items || [] + businessFieldsLoaded.value = true + syncMappingRows() } + } catch (error) { + if (requestId !== fieldsRequestId) return + console.error('加载审批业务字段失败:', error) + ElMessage.error('加载业务字段失败,请重新选择业务类型或重新打开编辑') } finally { - fieldsLoading.value = false + if (requestId === fieldsRequestId) fieldsLoading.value = false } } const handleBusinessTypeChange = () => { businessFields.value = [] - mappingRows.value.forEach((mapping) => { - mapping.business_field = '' - }) + mappingRows.value = [] void loadBusinessFields(sceneForm.business_type) } @@ -295,8 +334,15 @@ ]) const fillEditor = (scene?: WecomScene) => { + fieldsRequestId += 1 + templateRequestId += 1 + businessFields.value = [] + businessFieldsLoaded.value = false + fieldsLoading.value = false + inspectingTemplate.value = false templateControls.value = [] templateName.value = '' + templateLoaded.value = false if (!scene) { selectedBusinessType.value = undefined Object.assign(sceneForm, { @@ -318,10 +364,11 @@ mappingRows.value = (scene.control_mapping || []).map((mapping) => ({ key: nextMappingKey.value++, business_field: mapping.business_field, + field_name: mapping.business_field, + required_mapping: false, control_title: '', control_id: mapping.control_id, control_type: mapping.control_type, - required: true, option_mapping: JSON.stringify(mapping.option_mapping || {}) })) } @@ -339,24 +386,48 @@ void syncTemplate(false) } - const syncMappingRows = (controls: WecomTemplateControl[]) => { + const syncMappingRows = () => { + if (!businessFieldsLoaded.value) return const existingMappings = new Map( - mappingRows.value.map((mapping) => [mapping.control_id, mapping]) + mappingRows.value.map((mapping) => [mapping.business_field, mapping]) ) - mappingRows.value = controls.map((control) => { - const existing = existingMappings.get(control.id) + mappingRows.value = businessFields.value.map((field) => { + const existing = existingMappings.get(field.code) + const control = templateControls.value.find((item) => item.id === existing?.control_id) + const keepExisting = !templateLoaded.value || Boolean(control) return { key: existing?.key || nextMappingKey.value++, - business_field: existing?.business_field || '', - control_title: control.title, - control_id: control.id, - control_type: control.type, - required: control.required, - option_mapping: existing?.option_mapping || '{}' + business_field: field.code, + field_name: field.name, + required_mapping: field.required_mapping === true, + control_title: control?.title || '', + control_id: control?.id || (keepExisting ? existing?.control_id || '' : ''), + control_type: control?.type || (keepExisting ? existing?.control_type || '' : ''), + option_mapping: keepExisting ? existing?.option_mapping || '{}' : '{}' } }) } + const handleControlChange = (mapping: MappingEditor) => { + const control = templateControls.value.find((item) => item.id === mapping.control_id) + mapping.control_id = control?.id || '' + mapping.control_title = control?.title || '' + mapping.control_type = control?.type || '' + mapping.option_mapping = '{}' + } + + const handleTemplateChange = () => { + templateRequestId += 1 + inspectingTemplate.value = false + templateControls.value = [] + templateName.value = '' + templateLoaded.value = false + mappingRows.value.forEach((mapping) => { + mapping.control_id = '' + handleControlChange(mapping) + }) + } + const syncTemplate = async (showMessage = true) => { if (!sceneForm.application_id) { ElMessage.warning('请先选择企微应用') @@ -367,21 +438,28 @@ return } + const requestId = ++templateRequestId inspectingTemplate.value = true try { const response = await WecomService.inspectTemplate(sceneForm.application_id, { template_id: sceneForm.template_id.trim() }) + if (requestId !== templateRequestId) return if (response.code === 0) { templateControls.value = response.data.controls || [] templateName.value = response.data.name || '' - syncMappingRows(templateControls.value) + templateLoaded.value = true + syncMappingRows() if (showMessage) { ElMessage.success(`模板控件已同步,共 ${templateControls.value.length} 项`) } } + } catch (error) { + if (requestId !== templateRequestId) return + console.error('同步审批模板失败:', error) + if (showMessage) ElMessage.error('同步模板失败,请重试') } finally { - inspectingTemplate.value = false + if (requestId === templateRequestId) inspectingTemplate.value = false } } @@ -425,16 +503,14 @@ const parseMapping = (): WecomSceneControlMapping[] | undefined => { const result: WecomSceneControlMapping[] = [] for (const mapping of mappingRows.value) { - if (!mapping.business_field) { - if (mapping.required) { - ElMessage.warning( - `请为必填控件“${mapping.control_title || mapping.control_id}”选择业务字段` - ) + if (!mapping.control_id) { + if (mapping.required_mapping) { + ElMessage.warning(`请为必填业务字段“${mapping.field_name}”选择模板控件`) return } continue } - if (!mapping.control_id || !mapping.control_type) { + if (!mapping.control_type) { ElMessage.warning(`控件“${mapping.control_title || mapping.control_id}”信息不完整`) return } @@ -455,10 +531,21 @@ option_mapping: optionMapping }) } + const missingControl = templateControls.value.find( + (control) => control.required && !result.some((mapping) => mapping.control_id === control.id) + ) + if (missingControl) { + ElMessage.warning(`请为必填控件“${missingControl.title || missingControl.id}”选择业务字段`) + return + } return result } const saveScene = async () => { + if (fieldsLoading.value || !businessFieldsLoaded.value || inspectingTemplate.value) { + ElMessage.warning('请等待业务字段和模板控件加载完成') + return + } if (!sceneForm.template_id.trim()) { ElMessage.warning('请输入模板 ID') return @@ -537,9 +624,13 @@ } .mapping-tip { - flex-shrink: 0; + flex-shrink: 1; + max-width: 320px; margin-top: 0; - white-space: nowrap; + font-size: 12px; + line-height: 1.5; + color: var(--el-text-color-secondary); + white-space: normal; } .template-name { @@ -568,6 +659,24 @@ overflow-y: auto; } + .mapping-content { + min-height: 80px; + } + + .mapping-required { + color: var(--el-color-danger); + } + + .mapping-field__name { + color: var(--el-text-color-primary); + } + + .mapping-field__code { + font-size: 12px; + color: var(--el-text-color-secondary); + overflow-wrap: anywhere; + } + .mapping-row { padding: 14px; margin-top: 6px; @@ -578,7 +687,7 @@ .mapping-row__grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }