feat(oa): add permission-controlled shared finance remarks to done center

Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks.

Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
This commit is contained in:
zlwu committed 2026-10-30 20:42:05 +08:00
1 parent 86df360f35
commit 0e64b413ce
15 files changed
+1188 -78

No files matched your search

@@ -0,0 +1,299 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { parse, compileTemplate } from '@vue/compiler-sfc'
import ts from 'typescript'
import { ref, reactive, computed, proxyRefs } from 'vue'
const root = new URL('../../', import.meta.url)
const fields = [
'canManageFinanceRemark',
'financeRemarkVisible',
'financeRemarkLoading',
'financeRemarkSaving',
'financeRemarkText',
'openFinanceRemark',
'saveFinanceRemark',
'cancelFinanceRemark',
'clearFinanceAccess',
'load',
'rows'
]
function evaluate(source, deps, tail = '') {
const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '')
const js = ts.transpileModule(withoutImports + tail, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const exports = {}
return (
new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) ||
exports.default ||
exports
)
}
function harness(platform) {
const calls = []
const hooks = {}
const api = {
list: {
list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }],
total: 1,
canManageFinanceRemark: true
},
get: async () => ({ financeRemark: '最新备注' }),
put: async () => ({ financeRemark: '' })
}
const deps = {
ref,
reactive,
computed,
defineOptions() {},
watchDebounced() {},
onMounted() {},
onActivated() {},
onDeactivated(fn) {
hooks.hide = fn
},
useRouter: () => ({}),
useMenuPerm: () => ({ hasMenuPerm: () => false }),
useUserStore: () => ({}),
ElMessage: { success() {}, error() {}, warning() {} },
uni: { showToast() {}, navigateTo() {} },
VanEmpty: {},
getToken: () => 'token',
forceRelogin() {},
fetchOaTasks: async () => {
calls.push(['list'])
return api.list
},
fetchOaFinanceRemark: async (id) => {
calls.push(['get', id])
return api.get(id)
},
fetchUpdateOaFinanceRemark: async (id, text) => {
calls.push(['put', id, text])
return api.put(id, text)
}
}
const path =
platform === 'web'
? 'web-admin/src/views/oa-approval/done/index.vue'
: 'mobile-h5/pages/oa/done/index.vue'
const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8'))
let vm
if (platform === 'web') {
vm = proxyRefs(
evaluate(
descriptor.scriptSetup.content,
deps,
'\nreturn {' +
fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') +
'}'
)
)
} else {
const options = evaluate(descriptor.script.content, deps)
vm = options.data()
for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm)
hooks.hide = () => options.onHide?.call(vm)
}
const load = () => (platform === 'web' ? vm.load() : vm.loadList(true))
const rows = () => (platform === 'web' ? vm.rows : vm.list)
return { vm, calls, api, load, rows, hooks, descriptor }
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: capability gates access and opening gets latest instance value`, async () => {
const h = harness(platform)
await h.load()
assert.equal(h.vm.canManageFinanceRemark, true)
await h.vm.openFinanceRemark(h.rows()[0])
assert.deepEqual(h.calls.at(-1), ['get', 'instance-1'])
assert.equal(h.vm.financeRemarkText, '最新备注')
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: cancellation makes no write and discards late GET`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.vm.cancelFinanceRemark()
resolve({ financeRemark: '不得重新显示' })
await opening
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '我的未保存输入'
h.api.put = async () => {
throw new Error('failure')
}
await h.vm.saveFinanceRemark()
assert.equal(h.vm.financeRemarkText, '我的未保存输入')
assert.equal(h.vm.financeRemarkVisible, true)
h.api.put = async () => ({ financeRemark: '' })
h.vm.financeRemarkText = ''
await h.vm.saveFinanceRemark()
assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']])
assert.equal(h.vm.financeRemarkVisible, false)
})
test(`${platform}: missing capability strips cached notes and prevents GET`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 }
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
const count = h.calls.length
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.calls.length, count)
})
test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.hooks.hide()
resolve({ financeRemark: '不得恢复的旧备注' })
await opening
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: overlong input cannot send PUT`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '字'.repeat(513)
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => {
const h = harness(platform)
await h.load()
let resolveGet
h.api.get = () =>
new Promise((r) => {
resolveGet = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
resolveGet({ financeRemark: '备注' })
await opening
let resolvePut
h.api.put = () =>
new Promise((r) => {
resolvePut = r
})
const saving = h.vm.saveFinanceRemark()
await h.vm.saveFinanceRemark()
assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1)
resolvePut({ financeRemark: '备注' })
await saving
})
test(`${platform}: late list response cannot restore permission after hiding`, async () => {
const h = harness(platform)
await h.load()
let resolve
const previous = h.api.list
h.api.list = new Promise((r) => {
resolve = r
})
const loading = h.load()
h.hooks.hide()
resolve(previous)
await loading
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: failed latest-value request cannot save stale list text`, async () => {
const h = harness(platform)
await h.load()
h.api.get = async () => {
throw new Error('forbidden')
}
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: template compiles with plain text and gated edit controls`, () => {
const { descriptor } = harness(platform)
const result = compileTemplate({
source: descriptor.template.content,
filename: 'done.vue',
id: platform
})
assert.deepEqual(result.errors, [])
assert.match(result.code, /canManageFinanceRemark/)
assert.match(result.code, /toDisplayString\([^)]*financeRemark/)
assert.doesNotMatch(result.code, /innerHTML/)
if (platform === 'mobile')
assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/)
})
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => {
const calls = []
const request = (config) => {
calls.push(config)
return Promise.resolve({})
}
request.get = (config) => request({ ...config, method: 'GET' })
request.put = (config) => request({ ...config, method: 'PUT' })
const path =
platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js'
const api = evaluate(readFileSync(new URL(path, root), 'utf8'), {
request,
get: (url, header) => request({ url, header, method: 'GET' }),
getToken: () => 'token'
})
assert.equal(typeof api.fetchOaFinanceRemark, 'function')
await api.fetchOaFinanceRemark('instance-1')
await api.fetchUpdateOaFinanceRemark('instance-1', '')
assert.equal(calls[0].method, 'GET')
assert.equal(calls[1].method, 'PUT')
assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark')
assert.equal(calls[1].url, calls[0].url)
assert.deepEqual(calls[1].data, { financeRemark: '' })
if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token')
})
}
test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => {
const h = harness('web')
await h.load()
h.hooks.hide()
const count = h.calls.length
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.calls.length, count)
})