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

+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>