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)
})
+16 -1
View File
@@ -408,7 +408,7 @@ export function fetchOaTasks(
const applyTimeFrom = (params?.applyTimeFrom || '').trim()
const applyTimeTo = (params?.applyTimeTo || '').trim()
const instanceStatus = (params?.instanceStatus || '').trim()
return request.get<{ list: any[]; total: number }>({
return request.get<{ list: any[]; total: number; canManageFinanceRemark?: boolean }>({
url: `${BASE}/tasks/${kind}`,
params: {
page: params?.page ?? 1,
@@ -664,3 +664,18 @@ export function fetchReplaceOaBankReceipts(
data: { items }
})
}
export type OaFinanceRemark = { instanceId: string; financeRemark: string }
export function fetchOaFinanceRemark(instanceId: string) {
return request.get<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`
})
}
export function fetchUpdateOaFinanceRemark(instanceId: string, financeRemark: string) {
return request.put<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`,
data: { financeRemark }
})
}
+176 -38
View File
@@ -6,18 +6,10 @@
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="流程名称">
<el-input
v-model="searchForm.appName"
placeholder="请输入流程名称"
clearable
/>
<el-input v-model="searchForm.appName" placeholder="请输入流程名称" clearable />
</el-form-item>
<el-form-item label="申请人">
<el-input
v-model="searchForm.initiatorName"
placeholder="请输入申请人"
clearable
/>
<el-input v-model="searchForm.initiatorName" placeholder="请输入申请人" clearable />
</el-form-item>
<el-form-item label="申请时间">
<el-date-picker
@@ -76,28 +68,13 @@
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
label="申请人"
min-width="120"
align="center"
>
<el-table-column prop="initiatorUserName" label="申请人" min-width="120" align="center">
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
</el-table-column>
<el-table-column
prop="applyTime"
label="申请时间"
min-width="170"
align="center"
>
<el-table-column prop="applyTime" label="申请时间" min-width="170" align="center">
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="currentNodeName"
label="当前节点"
min-width="140"
align="center"
>
<el-table-column prop="currentNodeName" label="当前节点" min-width="140" align="center">
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
</el-table-column>
<el-table-column
@@ -116,6 +93,15 @@
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="canManageFinanceRemark"
prop="financeRemark"
label="财务备注"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="400" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
@@ -130,6 +116,14 @@
<el-icon><Printer /></el-icon>
打印
</el-button>
<el-button
v-if="canManageFinanceRemark"
class="btn-op-item"
@click="openFinanceRemark(row)"
>
<el-icon><EditPen /></el-icon>
财务备注
</el-button>
<el-button
v-if="canUploadBankReceipt"
class="btn-op-item"
@@ -157,6 +151,49 @@
</div>
</section>
<el-dialog
:model-value="financeRemarkVisible"
title="财务备注"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
:close-on-press-escape="!financeRemarkSaving"
:show-close="!financeRemarkSaving"
@update:model-value="cancelFinanceRemark"
>
<el-form v-loading="financeRemarkLoading" label-position="top" @submit.prevent>
<el-form-item label="财务备注">
<el-input
v-model="financeRemarkText"
type="textarea"
:rows="5"
:maxlength="512"
show-word-limit
:disabled="financeRemarkLoading || financeRemarkSaving"
placeholder="请输入财务备注,留空可清空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" :disabled="financeRemarkSaving" @click="cancelFinanceRemark">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="financeRemarkSaving"
:disabled="financeRemarkLoading"
@click="saveFinanceRemark"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<OaBankReceiptDialog
v-model="bankReceiptVisible"
:instance-id="bankReceiptInstanceId"
@@ -166,11 +203,22 @@
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Printer, RefreshLeft, Search, Upload, View } from '@element-plus/icons-vue'
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
import {
Check,
Close,
EditPen,
Printer,
RefreshLeft,
Search,
Upload,
View
} from '@element-plus/icons-vue'
import {
fetchOaInstanceDetail,
fetchOaTasks,
fetchOaFinanceRemark,
fetchUpdateOaFinanceRemark
} from '@/api/oaApproval'
import OaBankReceiptDialog from '@/components/oa-approval/OaBankReceiptDialog.vue'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import { useUserStore } from '@/store/modules/user'
@@ -187,6 +235,82 @@
const loading = ref(false)
const printingId = ref('')
const rows = ref<any[]>([])
const canManageFinanceRemark = ref(false)
const financeRemarkVisible = ref(false)
const financeRemarkLoading = ref(false)
const financeRemarkSaving = ref(false)
const financeRemarkText = ref('')
let financeRemarkInstanceId = ''
let financeRemarkVersion = 0
let listVersion = 0
let pageActive = true
function cancelFinanceRemark() {
financeRemarkVersion++
financeRemarkVisible.value = false
financeRemarkText.value = ''
financeRemarkInstanceId = ''
financeRemarkLoading.value = false
financeRemarkSaving.value = false
}
function clearFinanceAccess() {
canManageFinanceRemark.value = false
cancelFinanceRemark()
rows.value = rows.value.map((row) => {
const sanitized = { ...row }
delete sanitized.financeRemark
return sanitized
})
}
async function openFinanceRemark(row: any) {
if (!canManageFinanceRemark.value || financeRemarkSaving.value) return
const id = String(row.instanceId || '').trim()
if (!id) return
cancelFinanceRemark()
const version = financeRemarkVersion
financeRemarkInstanceId = id
financeRemarkVisible.value = true
financeRemarkLoading.value = true
try {
const result = await fetchOaFinanceRemark(id)
if (version !== financeRemarkVersion) return
financeRemarkText.value = result.financeRemark || ''
} catch {
if (version === financeRemarkVersion) cancelFinanceRemark()
} finally {
if (version === financeRemarkVersion) financeRemarkLoading.value = false
}
}
async function saveFinanceRemark() {
if (
!canManageFinanceRemark.value ||
!financeRemarkInstanceId ||
financeRemarkLoading.value ||
financeRemarkSaving.value
)
return
if (financeRemarkText.value.length > 512) {
ElMessage.warning('财务备注最多 512 个字符')
return
}
const version = financeRemarkVersion
financeRemarkSaving.value = true
try {
await fetchUpdateOaFinanceRemark(financeRemarkInstanceId, financeRemarkText.value)
if (version !== financeRemarkVersion) return
cancelFinanceRemark()
ElMessage.success('财务备注已保存')
await load()
} catch {
// 请求层统一提示错误,保留输入以便重试。
} finally {
if (version === financeRemarkVersion) financeRemarkSaving.value = false
}
}
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -210,9 +334,7 @@
function currentPrinterName() {
const info: any = userStore.info || userStore.getUserInfo || {}
return String(
info.realName || info.nickName || info.userName || info.username || ''
).trim()
return String(info.realName || info.nickName || info.userName || info.username || '').trim()
}
function statusType(s: string) {
@@ -306,16 +428,24 @@
}
async function load() {
if (!pageActive) return
const version = ++listVersion
clearFinanceAccess()
loading.value = true
try {
const res = await fetchOaTasks('done', buildFilterParams())
if (version !== listVersion) return
canManageFinanceRemark.value = res?.canManageFinanceRemark === true
rows.value = res?.list || []
if (!canManageFinanceRemark.value) clearFinanceAccess()
total.value = res?.total || 0
} catch {
if (version !== listVersion) return
clearFinanceAccess()
rows.value = []
total.value = 0
} finally {
loading.value = false
if (version === listVersion) loading.value = false
}
}
@@ -328,6 +458,14 @@
{ debounce: 300, deep: true }
)
onDeactivated(() => {
pageActive = false
listVersion++
clearFinanceAccess()
})
onMounted(load)
onActivated(load)
onActivated(() => {
pageActive = true
void load()
})
</script>