From 40e6880f774cae53cbd35878527e8966eb8118e9 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 12 Nov 2026 14:03:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E6=96=B9=E4=BE=BFios=E4=B8=8A=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/app/setting/index.ts | 130 ++ src/api/mall/trade/brokerage/user/index.ts | 54 + src/views/app/setting/index.vue | 1924 +++++++++++++++++ .../brokerage/user/BrokerageBindLogDrawer.vue | 90 + .../user/BrokerageUserUpdateForm.vue | 350 +-- src/views/mall/trade/brokerage/user/index.vue | 22 +- .../brokerage/user/useBrokerageBindForm.ts | 188 ++ .../member/user/detail/UserBrokerageTree.vue | 36 + src/views/member/user/detail/index.vue | 16 +- tests/app-setting.test.cjs | 164 ++ tests/brokerage-bind-form.test.cjs | 154 ++ tests/member-user-brokerage.test.cjs | 1 + 12 files changed, 2988 insertions(+), 141 deletions(-) create mode 100644 src/api/app/setting/index.ts create mode 100644 src/views/app/setting/index.vue create mode 100644 src/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue create mode 100644 src/views/mall/trade/brokerage/user/useBrokerageBindForm.ts create mode 100644 tests/app-setting.test.cjs create mode 100644 tests/brokerage-bind-form.test.cjs diff --git a/src/api/app/setting/index.ts b/src/api/app/setting/index.ts new file mode 100644 index 00000000..43559c96 --- /dev/null +++ b/src/api/app/setting/index.ts @@ -0,0 +1,130 @@ +import request from '@/config/axios' + +export type AppMode = 'chat' | 'tea' +export type BookingStatus = + 'pending' | 'confirmed' | 'completed' | 'cancelled' | 'rejected' | 'expired' +export interface TeaSlot { + startTime: string + endTime: string +} +export interface AppSetting { + id?: number + iosMode: AppMode + androidMode: AppMode + h5Mode: AppMode + storeName: string + storeCover: string + storeDescription: string + storeAddress: string + storePhone: string + openTime: string + closeTime: string + bookingNotice: string + slots: TeaSlot[] +} +export interface TeaRoom { + id?: number + name: string + cover: string + capacity: number + facilities: string[] + description: string + price: number + sort: number + enabled: boolean +} +export interface TeaBooking { + id: number + bookingNo: string + roomId: number + roomName: string + roomCover: string + storeName: string + storeAddress: string + storePhone: string + date: string + startTime: string + endTime: string + persons: number + contactName: string + contactPhone: string + remark: string + price: number + status: BookingStatus + reason?: string + createTime?: string | number + canCancel: boolean + canConfirm: boolean + canReject: boolean + canComplete: boolean + canAdminCancel: boolean +} +export interface BookingQuery { + pageNo: number + pageSize: number + status?: BookingStatus + roomId?: number + date?: string + keyword?: string +} +export type ClosureStatus = 'pending' | 'processing' | 'resolved' | 'rejected' +export interface AccountClosure { + id: number + memberId: number + nickname: string + contactPhone: string + reason: string + status: ClosureStatus + processRemark: string + createTime: string | number + updateTime: string | number +} +export const getClosurePage = (params: { + pageNo: number + pageSize: number + status?: ClosureStatus + keyword?: string +}) => + request.get<{ list: AccountClosure[]; total: number }>({ + url: '/member/account-closure/page', + params + }) +export const updateClosureStatus = (data: { + id: number + status: Exclude + processRemark: string +}) => request.put({ url: '/member/account-closure/update-status', data }) + +export const getSetting = () => request.get({ url: '/member/app-setting/get' }) +export const saveSetting = (data: AppSetting) => + request.put({ url: '/member/app-setting/save', data }) +export const getRooms = () => request.get({ url: '/member/tea-room/list' }) +export const getRoom = (id: number) => + request.get({ url: '/member/tea-room/get', params: { id } }) +export const createRoom = (data: TeaRoom) => + request.post({ url: '/member/tea-room/create', data }) +export const updateRoom = (data: TeaRoom) => + request.put({ url: '/member/tea-room/update', data }) +export const deleteRoom = (id: number) => + request.delete({ url: '/member/tea-room/delete', params: { id } }) +export const getBookingPage = (params: BookingQuery) => + request.get<{ list: TeaBooking[]; total: number }>({ url: '/member/tea-booking/page', params }) +export const getBooking = (id: number) => + request.get({ url: '/member/tea-booking/get', params: { id } }) +export const updateBookingStatus = (data: { id: number; status: BookingStatus; reason?: string }) => + request.put({ url: '/member/tea-booking/update-status', data }) + +/** Convert user-entered yuan without rounding binary floating-point values. */ +export const yuanToFen = (value: string): number | null => { + const match = /^(\d{1,8})(?:\.(\d{1,2}))?$/.exec(value.trim()) + if (!match) return null + const amount = Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0')) + return Number.isSafeInteger(amount) && amount <= 2147483647 ? amount : null +} + +export const fenToYuan = (value: number | string): string => { + const fen = String(value) + if (!/^\d+$/.test(fen)) return '—' + const digits = fen.replace(/^0+(?=\d)/, '').padStart(3, '0') + return `${digits.slice(0, -2)}.${digits.slice(-2)}` +} diff --git a/src/api/mall/trade/brokerage/user/index.ts b/src/api/mall/trade/brokerage/user/index.ts index 8ed69771..1f0af7b1 100644 --- a/src/api/mall/trade/brokerage/user/index.ts +++ b/src/api/mall/trade/brokerage/user/index.ts @@ -42,3 +42,57 @@ export const clearBindUser = async (data: any) => { export const updateBrokerageEnabled = async (data: any) => { return await request.put({ url: `/trade/brokerage-user/update-brokerage-enable`, data }) } + +export interface BindMember { + id: number + userCode?: string | null + nickname?: string | null + avatar?: string | null + status?: number +} + +export interface BindContext { + user: BindMember + bindUser?: BindMember | null + bindUserId?: number | null + bindUserTime?: string | number | null + version: number | string + bindMode?: number | null +} + +export interface BindChangeRequest { + id: number + bindUserId?: number + expectedBindUserId: number | null + expectedVersion: number | string + reason: string + requestId: string +} + +export interface BindLog { + oldMember?: BindMember | null + newMember?: BindMember | null + id: number + userId: number + oldBindUserId?: number | null + newBindUserId?: number | null + oldVersion: number | string + newVersion: number | string + operatorId?: number | null + source: string + reason: string + createTime: string | number +} + +export const getBindContext = (id: number): Promise => + request.get({ url: '/trade/brokerage-user/bind-context', params: { id } }) + +export const getBindLogPage = (params: { id: number; pageNo: number; pageSize: number }) => + request.get({ url: '/trade/brokerage-user/bind-log-page', params }) + +export const getBindCandidatePage = (params: { + searchField: 'userCode' | 'nickname' | 'mobile' + keyword: string + pageNo: number + pageSize: number +}) => request.get({ url: '/trade/brokerage-user/bind-candidate-page', params }) diff --git a/src/views/app/setting/index.vue b/src/views/app/setting/index.vue new file mode 100644 index 00000000..d3bd5c3d --- /dev/null +++ b/src/views/app/setting/index.vue @@ -0,0 +1,1924 @@ + + + + + diff --git a/src/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue b/src/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue new file mode 100644 index 00000000..4b72da64 --- /dev/null +++ b/src/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue @@ -0,0 +1,90 @@ + + diff --git a/src/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue b/src/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue index 542091d9..1f7e9604 100644 --- a/src/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue +++ b/src/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue @@ -1,127 +1,223 @@ - - + + + + + diff --git a/src/views/mall/trade/brokerage/user/index.vue b/src/views/mall/trade/brokerage/user/index.vue index f0e210a1..4c928948 100644 --- a/src/views/mall/trade/brokerage/user/index.vue +++ b/src/views/mall/trade/brokerage/user/index.vue @@ -194,7 +194,7 @@ /> - + @@ -242,6 +242,14 @@ const getList = async () => { } } +const refreshAfterBinding = async () => { + try { + await getList() + } catch { + message.warning('关系已保存,列表刷新失败,请重新查询') + } +} + /** 搜索按钮操作 */ const handleQuery = () => { queryParams.pageNo = 1 @@ -296,16 +304,8 @@ const openCreateUserForm = () => { } /** 清除上级推广人 */ -const handleClearBindUser = async (row: BrokerageUserApi.BrokerageUserVO) => { - try { - // 二次确认 - await message.confirm(`确认要清除"${row.nickname}"的上级推广人吗?`) - // 发起修改 - await BrokerageUserApi.clearBindUser({ id: row.id }) - message.success('清除成功') - // 刷新列表 - await getList() - } catch {} +const handleClearBindUser = (row: BrokerageUserApi.BrokerageUserVO) => { + updateFormRef.value.open(row, 'clear') } /** 推广资格:开通/关闭 */ diff --git a/src/views/mall/trade/brokerage/user/useBrokerageBindForm.ts b/src/views/mall/trade/brokerage/user/useBrokerageBindForm.ts new file mode 100644 index 00000000..643aa716 --- /dev/null +++ b/src/views/mall/trade/brokerage/user/useBrokerageBindForm.ts @@ -0,0 +1,188 @@ +import { ref, watch } from 'vue' +import { generateUUID } from '@/utils' +import * as BrokerageApi from '@/api/mall/trade/brokerage/user' + +export function useBrokerageBindForm(onSaved: () => void) { + const visible = ref(false) + const loading = ref(false) + const searching = ref(false) + const submitting = ref(false) + const reviewing = ref(false) + const mode = ref<'update' | 'clear'>('update') + const context = ref() + const searchField = ref<'userCode' | 'nickname' | 'mobile'>('userCode') + const keyword = ref('') + const candidates = ref([]) + const selected = ref() + const pageNo = ref(1) + const total = ref(0) + const reason = ref('') + const error = ref('') + let generation = 0 + let searchVersion = 0 + let payload: BrokerageApi.BindChangeRequest | undefined + + watch( + [searchField, keyword], + () => { + searchVersion += 1 + searching.value = false + candidates.value = [] + selected.value = undefined + total.value = 0 + pageNo.value = 1 + reviewing.value = false + payload = undefined + }, + { flush: 'sync' } + ) + + async function open(id: number, action: 'update' | 'clear' = 'update') { + if (submitting.value) return + const current = ++generation + searchVersion += 1 + visible.value = true + loading.value = true + searching.value = false + reviewing.value = false + context.value = undefined + mode.value = action + keyword.value = '' + candidates.value = [] + selected.value = undefined + total.value = 0 + pageNo.value = 1 + reason.value = '' + error.value = '' + payload = undefined + try { + const result = await BrokerageApi.getBindContext(id) + if (current === generation && visible.value) context.value = result + } catch (failure) { + if (current === generation) error.value = errorText(failure, '读取当前关系失败,请重新打开') + } finally { + if (current === generation) loading.value = false + } + } + + async function search(page = 1) { + if (!context.value || !keyword.value.trim() || submitting.value) return + const current = generation + const version = ++searchVersion + searching.value = true + selected.value = undefined + candidates.value = [] + total.value = 0 + pageNo.value = page + error.value = '' + try { + const result = await BrokerageApi.getBindCandidatePage({ + pageNo: page, + pageSize: 10, + searchField: searchField.value, + keyword: keyword.value.trim() + }) + if (current !== generation || version !== searchVersion || !visible.value) return + candidates.value = result.list + total.value = result.total + } catch (failure) { + if (current === generation && version === searchVersion) { + error.value = errorText(failure, '查询失败,请重试') + } + } finally { + if (current === generation && version === searchVersion) searching.value = false + } + } + + function choose(member: BrokerageApi.BindMember | undefined) { + if (submitting.value || reviewing.value) return + if ( + !member || + member.id === context.value?.user.id || + member.id === context.value?.bindUserId || + member.status !== 0 + ) { + selected.value = undefined + return + } + selected.value = member + } + + function review() { + if (!context.value || submitting.value) return + if (mode.value === 'update' && !selected.value) { + error.value = '请查询并选择新上级' + return + } + if (mode.value === 'clear' && !context.value.bindUserId) { + error.value = '该会员当前没有上级,请刷新核对' + return + } + if (!reason.value.trim() || reason.value.trim().length > 500) { + error.value = '请填写500字以内的操作原因' + return + } + error.value = '' + payload = { + id: context.value.user.id, + bindUserId: mode.value === 'update' ? selected.value!.id : undefined, + expectedBindUserId: context.value.bindUserId ?? null, + expectedVersion: context.value.version, + reason: reason.value.trim(), + requestId: generateUUID() + } + reviewing.value = true + } + + async function submit() { + if (!payload || !reviewing.value || submitting.value) return + submitting.value = true + error.value = '' + try { + if (mode.value === 'clear') await BrokerageApi.clearBindUser(payload) + else await BrokerageApi.updateBindUser(payload) + visible.value = false + generation += 1 + onSaved() + } catch (failure) { + error.value = errorText(failure, '保存失败,请核对提示后重试') + } finally { + submitting.value = false + } + } + + function close() { + if (submitting.value) return + visible.value = false + generation += 1 + searchVersion += 1 + } + + return { + visible, + loading, + searching, + submitting, + reviewing, + mode, + context, + searchField, + keyword, + candidates, + selected, + pageNo, + total, + reason, + error, + open, + search, + choose, + review, + submit, + close + } +} + +function errorText(failure: unknown, fallback: string) { + return failure instanceof Error ? failure.message : fallback +} diff --git a/src/views/member/user/detail/UserBrokerageTree.vue b/src/views/member/user/detail/UserBrokerageTree.vue index 0a5fb058..a7596b33 100644 --- a/src/views/member/user/detail/UserBrokerageTree.vue +++ b/src/views/member/user/detail/UserBrokerageTree.vue @@ -6,6 +6,35 @@

收益按已结算累计,贡献以当前会员为收益人

+ + {{ root.inviter ? '更换分销上级' : '设置分销上级' }} + + 解除上级关系 + 变更记录 当前会员

+ + @@ -253,11 +284,16 @@ import { formatDate } from '@/utils/formatTime' import { brokeragePriceToYuan } from '@/utils/formatter' import { useBrokerageTree, type BrokerageTreeNode } from './useBrokerageTree' import UserBrokerageRecordDrawer from './UserBrokerageRecordDrawer.vue' +import BrokerageUserUpdateForm from '@/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue' +import BrokerageBindLogDrawer from '@/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue' import '@vue-flow/core/dist/style.css' import '@vue-flow/core/dist/theme-default.css' import '@vue-flow/controls/dist/style.css' const props = defineProps<{ user: UserVO | null; active: boolean }>() +const emit = defineEmits<{ relationSaved: [] }>() +const bindFormRef = ref>() +const bindLogRef = ref>() const { root, graph, diff --git a/src/views/member/user/detail/index.vue b/src/views/member/user/detail/index.vue index d0ab23dd..5f6189fa 100644 --- a/src/views/member/user/detail/index.vue +++ b/src/views/member/user/detail/index.vue @@ -74,10 +74,10 @@ - + - + @@ -119,6 +119,7 @@ defineOptions({ name: 'MemberDetail' }) const UserBrokerageTree = defineAsyncComponent(() => import('./UserBrokerageTree.vue')) const activeDetailTab = ref('0') +const relationshipVersion = ref(0) const loading = ref(true) // 加载中 const user = ref({} as UserApi.UserVO) @@ -138,11 +139,20 @@ const getUserData = async (id: number) => { } } +const handleRelationSaved = async () => { + relationshipVersion.value += 1 + try { + await getUserData(id) + } catch { + ElMessage.warning('关系已保存,页面刷新失败,请重新进入详情核对') + } +} + /** 初始化 */ const { currentRoute } = useRouter() // 路由 const { delView } = useTagsViewStore() // 视图操作 const route = useRoute() -const id = route.params.id +const id = Number(route.params.id) const syncDetailTab = () => { if ( diff --git a/tests/app-setting.test.cjs b/tests/app-setting.test.cjs new file mode 100644 index 00000000..d3729760 --- /dev/null +++ b/tests/app-setting.test.cjs @@ -0,0 +1,164 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const vue = require('vue') +const { parse, compileScript, compileTemplate } = require('vue/compiler-sfc') + +const filename = path.join(__dirname, '../src/views/app/setting/index.vue') +const { descriptor } = parse(fs.readFileSync(filename, 'utf8')) +const transpile = (source) => ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText +function evaluate(source, imports, globals = {}) { + const module = { exports: {} } + vm.runInNewContext(transpile(source), { + exports: module.exports, + require: (name) => { + if (!(name in imports)) throw new Error(`Unexpected import: ${name}`) + return imports[name] + }, + ...globals + }) + return module.exports +} +const api = evaluate(fs.readFileSync(path.join(__dirname, '../src/api/app/setting/index.ts'), 'utf8'), { + '@/config/axios': { default: {} } +}) +function harness(overrides = {}, editable = true) { + const calls = [] + const backend = { + ...api, + getSetting: async () => null, + getRooms: async () => [], + saveSetting: async (data) => { calls.push(['setting', data]); return true }, + getBookingPage: async () => ({ list: [], total: 0 }), + getClosurePage: async () => ({ list: [], total: 0 }), + updateBookingStatus: async (data) => { calls.push(['booking', data]); return true }, + updateClosureStatus: async (data) => { calls.push(['closure', data]); return true }, + ...overrides + } + const component = evaluate(compileScript(descriptor, { id: 'app-setting' }).content, { + vue: { ...vue, onMounted: () => {} }, + 'vue-router': { onBeforeRouteLeave: () => {} }, + 'element-plus': { ElMessageBox: { prompt: async () => ({ value: '客人要求取消' }) } }, + '@/components/UploadFile': { UploadImg: {} }, + '@/utils/permission': { checkPermi: () => editable }, + '@/utils/formatTime': { formatDate: (value) => value }, + '@/api/app/setting': backend + }, { + useMessage: () => ({ success: () => {}, warning: () => {}, confirm: async () => true }) + }).default + return { page: component.setup({}, { expose: () => {} }), calls, backend } +} +const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done }); return { promise, resolve } } + +test('settings template compiles with all five management sections', () => { + assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'app-setting' }).errors, []) + for (const section of ['运行模式', '门店信息', '包间管理', '预约管理', '注销申请']) assert.ok(descriptor.template.content.includes(section)) +}) + +test('price inputs convert decimal currency exactly and reject unsupported values', () => { + assert.equal(api.yuanToFen('0.29'), 29) + assert.equal(api.yuanToFen('1.01'), 101) + assert.equal(api.yuanToFen('21474836.47'), 2147483647) + assert.equal(api.yuanToFen('21474836.48'), null) + for (const invalid of ['-1', '1.001', '1e4', '', '1,000', 'Infinity']) assert.equal(api.yuanToFen(invalid), null) + assert.equal(api.fenToYuan(1), '0.01') + assert.equal(api.fenToYuan('900719925474099301'), '9007199254740993.01') +}) + +test('tea activation requires configured store, valid non-overlapping slots and an enabled room', async () => { + const { page, calls } = harness() + await page.loadSettings(); await page.loadRooms() + page.settings.iosMode = 'tea' + await page.submitSettings() + assert.equal(calls.length, 0) + assert.ok(page.settingErrors.value.activation) + Object.assign(page.settings, { + storeName: '茶舍', storeCover: '/cover.jpg', storeAddress: '门店地址', storePhone: '12345678', + openTime: '09:00', closeTime: '18:00', slots: [{ startTime: '10:00', endTime: '12:00' }, { startTime: '11:30', endTime: '14:00' }] + }) + page.rooms.value = [{ id: 1, enabled: true }] + await page.submitSettings() + assert.equal(calls.length, 0) + assert.match(page.settingErrors.value.slots, /重叠/) + page.settings.slots[1].startTime = '12:00' + await page.submitSettings() + assert.equal(calls.length, 1) + assert.equal(calls[0][1].iosMode, 'tea') + assert.equal(calls[0][1].androidMode, 'chat') + assert.equal(page.settingsDirty.value, false) +}) + +test('save failure retains user draft and unsaved state', async () => { + const { page } = harness({ saveSetting: async () => { throw new Error('network') } }) + await page.loadSettings() + page.settings.storeName = '尚未保存的茶舍' + await page.submitSettings() + assert.equal(page.settings.storeName, '尚未保存的茶舍') + assert.equal(page.settingsDirty.value, true) + assert.equal(page.settingsSaving.value, false) +}) + +test('chat mode settings retain valid default hours and cannot save blank hours', async () => { + const { page, calls } = harness() + await page.loadSettings() + assert.equal(page.settings.openTime, '10:00') + assert.equal(page.settings.closeTime, '22:00') + page.settings.openTime = '' + page.settings.closeTime = '' + await page.submitSettings() + assert.equal(calls.length, 0) + assert.ok(page.settingErrors.value.businessHours) +}) + +test('late booking responses cannot overwrite the current filter result', async () => { + const first = deferred(); const second = deferred(); let count = 0 + const { page } = harness({ getBookingPage: () => (++count === 1 ? first.promise : second.promise) }) + const a = page.loadBookings(); const b = page.loadBookings() + second.resolve({ list: [{ id: 2 }], total: 1 }); await b + first.resolve({ list: [{ id: 1 }], total: 3 }); await a + assert.equal(page.bookings.value[0].id, 2) + assert.equal(page.bookingTotal.value, 1) +}) + +test('booking actions use server capabilities and do not submit unavailable actions', async () => { + const { page, calls } = harness() + const booking = { id: 1, canConfirm: false, canReject: true, canComplete: false, canAdminCancel: false } + await page.changeBooking(booking, 'confirmed') + assert.equal(calls.length, 0) + await page.changeBooking(booking, 'rejected') + assert.equal(calls.length, 1) + assert.equal(calls[0][1].status, 'rejected') + assert.equal(calls[0][1].reason, '客人要求取消') +}) + +test('account closure requires processing remarks and only records an outcome', async () => { + const { page, calls } = harness() + page.openClosure({ id: 7, status: 'pending' }) + await page.submitClosure() + assert.equal(calls.length, 0) + assert.ok(page.closureRemarkError.value) + page.closureRemark.value = '已核实申请并完成必要的人工处理' + page.closureNextStatus.value = 'resolved' + await page.submitClosure() + assert.equal(calls.length, 1) + assert.equal(calls[0][0], 'closure') + assert.equal(calls[0][1].status, 'resolved') + assert.equal(page.closureDialog.value, false) +}) + +test('read-only operators cannot save settings or process requests', async () => { + const { page, calls } = harness({}, false) + await page.loadSettings() + page.settings.storeName = '无写权限' + await page.submitSettings() + page.openClosure({ id: 7, status: 'pending' }) + page.closureRemark.value = '测试' + await page.submitClosure() + await page.changeBooking({ id: 1, canConfirm: true }, 'confirmed') + assert.equal(calls.length, 0) +}) diff --git a/tests/brokerage-bind-form.test.cjs b/tests/brokerage-bind-form.test.cjs new file mode 100644 index 00000000..bfe57ae3 --- /dev/null +++ b/tests/brokerage-bind-form.test.cjs @@ -0,0 +1,154 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const vue = require('vue') +const { parse, compileTemplate } = require('vue/compiler-sfc') +const directory = path.join(__dirname, '../src/views/mall/trade/brokerage/user') +const source = fs.readFileSync(path.join(directory, 'useBrokerageBindForm.ts'), 'utf8') +const member = (id, userCode = `APP${id}`) => ({ id, userCode, nickname: `会员${id}`, status: 0 }) +const snapshot = (id = 300) => ({ user: member(id, '999827'), bindUser: member(10), bindUserId: 10, version: '8', bindMode: 1 }) +function deferred() { let resolve, reject; const promise = new Promise((done, fail) => { resolve = done; reject = fail }); return { promise, resolve, reject } } +function harness(overrides = {}) { + const writes = [] + let saved = 0 + let serial = 0 + const api = { + getBindContext: async (id) => snapshot(id), + getBindCandidatePage: async () => ({ list: [member(20)], total: 1 }), + updateBindUser: async (data) => { writes.push({ ...data }) }, + clearBindUser: async (data) => { writes.push({ ...data }) }, + ...overrides + } + const module = { exports: {} } + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, { + exports: module.exports, + require: (name) => { + if (name === 'vue') return vue + if (name === '@/api/mall/trade/brokerage/user') return api + if (name === '@/utils') return { generateUUID: () => `00000000-0000-4000-8000-${String(++serial).padStart(12, '0')}` } + throw new Error(`Unexpected import ${name}`) + } + }) + const scope = vue.effectScope() + const form = scope.run(() => module.exports.useBrokerageBindForm(() => { saved++ })) + return { form, writes, api, stop: () => scope.stop(), saved: () => saved } +} +async function select(form) { + form.keyword.value = 'APP20' + await form.search() + form.choose(form.candidates.value[0]) +} + +test('form and history templates compile and both pages use the shared form', () => { + for (const file of ['BrokerageUserUpdateForm.vue', 'BrokerageBindLogDrawer.vue']) { + const full = path.join(directory, file) + const { descriptor } = parse(fs.readFileSync(full, 'utf8')) + assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: full, id: 'bind' }).errors, []) + } + const detail = fs.readFileSync(path.join(__dirname, '../src/views/member/user/detail/UserBrokerageTree.vue'), 'utf8') + assert.match(detail, /bindFormRef\?\.open\(root.id/) + assert.match(detail, /trade:brokerage-user:update-bind-user/) + assert.match(detail, /trade:brokerage-user:clear-bind-user/) + const list = fs.readFileSync(path.join(directory, 'index.vue'), 'utf8') + assert.match(list, /updateFormRef.value.open\(row, 'clear'\)/) + assert.doesNotMatch(list, /clearBindUser\(\{ id: row.id \}\)/) +}) + +test('changing search after confirming A clears A and cannot submit B without selecting B', async () => { + const h = harness(); const f = h.form + await f.open(300) + await select(f) + assert.equal(f.selected.value.id, 20) + f.keyword.value = 'APP30' + assert.equal(f.selected.value, undefined) + f.reason.value = '核实关系' + f.review(); await f.submit() + assert.equal(h.writes.length, 0) + h.stop() +}) + +test('late candidate results and late member context never overwrite the active search/member', async () => { + const old = deferred(); const current = deferred(); let calls = 0 + const h = harness({ getBindCandidatePage: () => ++calls === 1 ? old.promise : current.promise }) + const f = h.form + await f.open(300) + f.keyword.value = 'A'; const first = f.search() + f.keyword.value = 'B'; const second = f.search() + current.resolve({ list: [member(30)], total: 1 }); await second + old.resolve({ list: [member(20)], total: 1 }); await first + assert.equal(f.candidates.value[0].id, 30) + assert.equal(f.searching.value, false) + const stale = deferred() + h.api.getBindContext = (id) => id === 300 ? stale.promise : Promise.resolve(snapshot(id)) + const previous = f.open(300); await f.open(400) + stale.resolve(snapshot(300)); await previous + assert.equal(f.context.value.user.id, 400) + h.stop() +}) + +test('requires reason, selected member and review, then sends internal IDs and version once', async () => { + const pending = deferred() + const sent = [] + const h = harness({ updateBindUser: (data) => { sent.push({ ...data }); return pending.promise } }) + const f = h.form + await f.open(300) + await select(f) + f.review(); await f.submit() + assert.equal(sent.length, 0) + f.reason.value = ' 经核实调整 ' + f.review() + const first = f.submit(); const duplicate = f.submit() + f.close() + assert.equal(f.visible.value, true) + assert.equal(sent.length, 1) + assert.equal(sent[0].id, 300) + assert.equal(sent[0].bindUserId, 20) + assert.equal(sent[0].expectedBindUserId, 10) + assert.equal(sent[0].expectedVersion, '8') + assert.equal(sent[0].reason, '经核实调整') + pending.resolve(); await Promise.all([first, duplicate]) + assert.equal(f.visible.value, false) + assert.equal(h.saved(), 1) + h.stop() +}) + +test('clear uses the same reviewed snapshot and cancel never writes', async () => { + const h = harness(); const f = h.form + await f.open(300, 'clear') + f.reason.value = '解除原关系'; f.review(); f.close() + assert.equal(h.writes.length, 0) + await f.open(300, 'clear') + f.reason.value = '解除原关系'; f.review(); await f.submit() + assert.equal(h.writes.length, 1) + assert.equal(h.writes[0].bindUserId, undefined) + assert.equal(h.writes[0].expectedBindUserId, 10) + h.stop() +}) + +test('retry after timeout preserves the request ID and input until saved', async () => { + const sent = []; let attempts = 0 + const h = harness({ updateBindUser: async (data) => { sent.push({ ...data }); if (++attempts === 1) throw new Error('关系已变更') } }) + const f = h.form + await f.open(300); await select(f) + f.reason.value = '核实'; f.review(); await f.submit() + assert.equal(f.visible.value, true) + assert.equal(f.reason.value, '核实') + assert.equal(f.selected.value.id, 20) + await f.submit() + assert.equal(sent[0].requestId, sent[1].requestId) + assert.equal(h.saved(), 1) + h.stop() +}) + +test('current member, current parent and disabled members cannot be selected', async () => { + const h = harness(); const f = h.form + await f.open(300) + for (const candidate of [member(300), member(10), { ...member(20), status: 1 }]) { + f.choose(candidate) + assert.equal(f.selected.value, undefined) + } + h.stop() +}) diff --git a/tests/member-user-brokerage.test.cjs b/tests/member-user-brokerage.test.cjs index d9fed7c9..4d08b454 100644 --- a/tests/member-user-brokerage.test.cjs +++ b/tests/member-user-brokerage.test.cjs @@ -433,6 +433,7 @@ test('Dagre布局无节点重叠,筛选移除节点后重新适应画布,详 const fitCalls = [] const context = { exports: {}, + defineEmits: () => () => {}, defineProps: () => props, useRouter: () => ({ push: (route) => routes.push(route) }), require: (name) => {