Files
zkxw/web-admin/src/components/oa-approval/OaReceiveAccountSelect.vue
T
zlwuandCursor 0506fe7e8a fix: 发起审批时说明当前用户还没有收款账户
两端选择器都只展示 options 接口返回的本人账户,空列表不再像是系统没有账户。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:13:30 +08:00

633 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- FormCreate 自定义控件:收款账户选择(Fresh Mint · PC 下拉 + 窄屏抽屉;预览画布全宽) -->
<template>
<div class="oa-receive-account-select">
<template v-if="disabled">
<div v-if="normalized.length" class="oa-receive-account-select__detail">
<div
v-for="item in normalized"
:key="item.id"
class="oa-receive-account-select__line"
>
{{ formatReceiveAccountLine(item) }}
</div>
</div>
<span v-else class="oa-receive-account-select__readonly">—</span>
</template>
<template v-else>
<template v-if="isNarrow">
<button type="button" class="oa-receive-account-select__trigger" @click="openDrawer">
<span v-if="selectSummary" class="oa-receive-account-select__trigger-text">{{
selectSummary
}}</span>
<span v-else class="oa-receive-account-select__placeholder">请选择收款账户</span>
<span class="oa-receive-account-select__chev">▾</span>
</button>
<el-drawer
v-model="drawerVisible"
title="选择收款账户"
direction="btt"
size="75%"
append-to-body
:close-on-click-modal="false"
class="oa-receive-account-drawer"
modal-class="protocol-dialog-mask"
>
<el-input
v-model="keyword"
clearable
placeholder="搜索账户名称或账号"
class="oa-receive-account-select__search"
/>
<div class="oa-receive-account-select__list">
<label
v-for="a in filteredOptions"
:key="a.id"
class="oa-receive-account-select__row"
>
<el-checkbox
:model-value="pendingIds.includes(a.id)"
@change="(v: boolean | string | number) => onNarrowCheck(a.id, !!v)"
/>
<span class="oa-receive-account-select__row-label">{{ a.label }}</span>
</label>
<div v-if="!filteredOptions.length" class="oa-receive-account-select__empty">
暂无本人的收款账户
</div>
<button
type="button"
class="oa-receive-account-select__add-row"
@click="openCreateDialog"
>
<el-icon><Plus /></el-icon>
新增收款账户
</button>
</div>
<template #footer>
<el-button @click="drawerVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" @click="confirmDrawer">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-drawer>
</template>
<el-select
v-else
ref="selectRef"
:model-value="selectModel"
class="oa-receive-account-select__select"
filterable
clearable
multiple
placeholder="请选择收款账户"
@update:model-value="onSelectChange"
>
<el-option
v-for="a in accountOptions"
:key="a.id"
:label="a.label"
:value="a.id"
/>
<template #empty>
<div class="oa-receive-account-select__empty">暂无本人的收款账户</div>
</template>
<template #footer>
<div class="oa-receive-account-select__add-footer" @mousedown.prevent>
<el-button class="oa-receive-account-select__add-btn" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
新增收款账户
</el-button>
</div>
</template>
</el-select>
<div v-if="normalized.length" class="oa-receive-account-select__detail">
<div
v-for="item in normalized"
:key="item.id"
class="oa-receive-account-select__line"
>
{{ formatReceiveAccountLine(item) }}
</div>
</div>
</template>
<el-dialog
v-model="createVisible"
title="新增收款账户"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="账户类型" prop="accountType" class="form-col-span-2">
<el-select
v-model="createForm.accountType"
placeholder="请选择"
@change="onCreateTypeChange"
>
<el-option
v-for="(label, value) in ACCOUNT_TYPE_LABEL"
:key="value"
:label="label"
:value="value"
/>
</el-select>
</el-form-item>
<el-form-item label="户名" prop="accountName">
<el-input v-model="createForm.accountName" placeholder="请输入户名" maxlength="128" />
</el-form-item>
<el-form-item label="账号" prop="accountNo">
<el-input v-model="createForm.accountNo" placeholder="请输入账号" maxlength="128" />
</el-form-item>
<template v-if="createForm.accountType !== 'alipay'">
<el-form-item label="银行" prop="bankName">
<el-input
v-model="createForm.bankName"
placeholder="如:中国工商银行"
maxlength="128"
/>
</el-form-item>
<el-form-item label="银行所在省" prop="bankProvince">
<el-input
v-model="createForm.bankProvince"
placeholder="如:安徽省"
maxlength="64"
clearable
/>
</el-form-item>
<el-form-item label="银行所在市" prop="bankCity">
<el-input
v-model="createForm.bankCity"
placeholder="如:合肥市"
maxlength="64"
clearable
/>
</el-form-item>
<el-form-item label="银行支行" prop="bankBranch" class="form-col-span-2">
<el-input
v-model="createForm.bankBranch"
placeholder="请输入开户支行全称"
maxlength="256"
/>
</el-form-item>
</template>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="createForm.remark"
type="textarea"
:rows="2"
placeholder="选填"
maxlength="512"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="createVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="createLoading"
@click="submitCreate"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { Check, Close, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchCreateOaReceiveAccount,
fetchOaReceiveAccountOptions
} from '@/api/oaApproval'
import {
ACCOUNT_TYPE_LABEL,
formatReceiveAccountLine,
normalizeReceiveAccountModelValue,
optionLabelFromRow,
snapshotFromRow,
type ReceiveAccountSnapshot,
type ReceiveAccountType
} from '@/utils/oa-receive-account-value'
defineOptions({ name: 'receiveAccount' })
const props = defineProps<{
modelValue?: unknown
disabled?: boolean
formCreateInject?: Record<string, unknown>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ReceiveAccountSnapshot[]): void
(e: 'change', v: ReceiveAccountSnapshot[]): void
}>()
type AccountOpt = { id: string; label: string }
const snapshotMap = ref(new Map<string, ReceiveAccountSnapshot>())
const accountOptions = ref<AccountOpt[]>([])
const isNarrow = ref(false)
const drawerVisible = ref(false)
const keyword = ref('')
const pendingIds = ref<string[]>([])
const selectRef = ref<{ blur?: () => void } | null>(null)
let mql: MediaQueryList | null = null
const createVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref()
const createForm = reactive({
accountType: 'alipay' as ReceiveAccountType,
accountName: '',
accountNo: '',
bankName: '',
bankProvince: '',
bankCity: '',
bankBranch: '',
remark: ''
})
const requireWhenBank = (_rule: any, value: string, callback: (e?: Error) => void) => {
if (createForm.accountType === 'alipay') {
callback()
return
}
if (!String(value || '').trim()) {
callback(new Error('请填写'))
return
}
callback()
}
const createRules = computed(() => ({
accountType: [{ required: true, message: '请选择账户类型', trigger: 'change' }],
accountName: [{ required: true, message: '请输入户名', trigger: 'blur' }],
accountNo: [{ required: true, message: '请输入账号', trigger: 'blur' }],
bankName: [{ validator: requireWhenBank, trigger: 'blur' }],
bankProvince: [{ validator: requireWhenBank, trigger: 'blur' }],
bankCity: [{ validator: requireWhenBank, trigger: 'blur' }],
bankBranch: [{ validator: requireWhenBank, trigger: 'blur' }]
}))
const normalized = computed(() => normalizeReceiveAccountModelValue(props.modelValue))
const selectModel = computed(() => normalized.value.map((x) => x.id))
const selectSummary = computed(() => {
const n = normalized.value.length
if (!n) return ''
if (n === 1) {
const x = normalized.value[0]
return optionLabelFromRow(x)
}
return `已选 ${n} 个账户`
})
const filteredOptions = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return accountOptions.value
return accountOptions.value.filter(
(a) => a.label.toLowerCase().includes(q) || a.id.toLowerCase().includes(q)
)
})
function emitFromIds(ids: string[]) {
const items: ReceiveAccountSnapshot[] = []
const seen = new Set<string>()
const prevMap = new Map(normalized.value.map((x) => [x.id, x]))
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
const snap = snapshotMap.value.get(tid) || prevMap.get(tid)
if (snap) items.push(snap)
}
emit('update:modelValue', items)
emit('change', items)
}
function onSelectChange(val: string | string[] | null | undefined) {
emitFromIds(Array.isArray(val) ? val.map(String) : [])
}
function openDrawer() {
pendingIds.value = normalized.value.map((x) => x.id)
keyword.value = ''
drawerVisible.value = true
}
function onNarrowCheck(id: string, checked: boolean) {
const set = new Set(pendingIds.value)
if (checked) set.add(id)
else set.delete(id)
pendingIds.value = [...set]
}
function confirmDrawer() {
emitFromIds(pendingIds.value)
drawerVisible.value = false
}
function clearCreateBankFields() {
createForm.bankName = ''
createForm.bankProvince = ''
createForm.bankCity = ''
createForm.bankBranch = ''
}
function resetCreateForm() {
createForm.accountType = 'alipay'
createForm.accountName = ''
createForm.accountNo = ''
clearCreateBankFields()
createForm.remark = ''
}
function onCreateTypeChange() {
if (createForm.accountType === 'alipay') clearCreateBankFields()
createFormRef.value?.clearValidate?.()
}
function openCreateDialog() {
selectRef.value?.blur?.()
resetCreateForm()
createVisible.value = true
nextTick(() => createFormRef.value?.clearValidate?.())
}
async function submitCreate() {
try {
await createFormRef.value?.validate?.()
} catch {
ElMessageBox.alert('请完善必填项后再提交', '提示', { type: 'warning' })
return
}
createLoading.value = true
try {
const payload: Record<string, unknown> = {
accountType: createForm.accountType,
accountName: createForm.accountName.trim(),
accountNo: createForm.accountNo.trim(),
remark: createForm.remark?.trim() || undefined
}
if (createForm.accountType === 'alipay') {
payload.bankName = ''
payload.bankProvince = ''
payload.bankCity = ''
payload.bankBranch = ''
} else {
payload.bankName = createForm.bankName.trim()
payload.bankProvince = createForm.bankProvince.trim()
payload.bankCity = createForm.bankCity.trim()
payload.bankBranch = createForm.bankBranch.trim()
}
const created = await fetchCreateOaReceiveAccount(payload)
const snap = snapshotFromRow(created)
if (!snap.id) {
ElMessage.error('创建成功但未返回账户 ID')
return
}
await loadOptions()
// 确保新账户在 map 中(options 可能短暂未刷新)
if (!snapshotMap.value.has(snap.id)) {
const map = new Map(snapshotMap.value)
map.set(snap.id, snap)
snapshotMap.value = map
if (!accountOptions.value.some((x) => x.id === snap.id)) {
accountOptions.value = [
...accountOptions.value,
{ id: snap.id, label: optionLabelFromRow(snap) }
]
}
}
const nextIds = [...selectModel.value]
if (!nextIds.includes(snap.id)) nextIds.push(snap.id)
emitFromIds(nextIds)
if (drawerVisible.value && !pendingIds.value.includes(snap.id)) {
pendingIds.value = [...pendingIds.value, snap.id]
}
createVisible.value = false
} finally {
createLoading.value = false
}
}
async function loadOptions() {
try {
const res = await fetchOaReceiveAccountOptions()
const list = res?.list || []
const map = new Map<string, ReceiveAccountSnapshot>()
const opts: AccountOpt[] = []
for (const row of list) {
const snap = snapshotFromRow(row)
if (!snap.id) continue
map.set(snap.id, snap)
opts.push({ id: snap.id, label: optionLabelFromRow(snap) })
}
for (const x of normalized.value) {
if (!map.has(x.id)) map.set(x.id, x)
}
snapshotMap.value = map
accountOptions.value = opts
} catch {
snapshotMap.value = new Map()
accountOptions.value = []
ElMessage.error('加载收款账户选项失败')
}
}
function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
const matches = e && 'matches' in e ? e.matches : !!mql?.matches
isNarrow.value = matches
}
onMounted(() => {
loadOptions()
mql = window.matchMedia('(max-width: 767px)')
syncNarrow(mql)
mql.addEventListener('change', syncNarrow)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', syncNarrow)
})
</script>
<style scoped lang="scss">
.oa-receive-account-select {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
}
.oa-receive-account-select__select {
width: 100%;
max-width: 100%;
}
.oa-receive-account-select__select :deep(.el-select),
.oa-receive-account-select__select :deep(.el-select__wrapper),
.oa-receive-account-select__select :deep(.el-tooltip__trigger) {
width: 100%;
max-width: 100%;
min-width: 0;
}
.oa-receive-account-select__select :deep(.el-select__selection) {
flex-wrap: wrap;
}
.oa-receive-account-select__select :deep(.el-select__wrapper) {
min-height: 40px;
}
.oa-receive-account-select__add-footer {
padding: 4px 8px 8px;
border-top: 1px solid var(--mint-border, #d9e8e2);
}
.oa-receive-account-select__add-btn {
width: 100%;
justify-content: center;
color: var(--mint-primary-dark, #059669);
font-weight: 600;
}
.oa-receive-account-select__add-row {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
min-height: 44px;
margin-top: 8px;
padding: 8px;
border: 1px dashed var(--mint-border, #a7f3d0);
border-radius: 8px;
background: var(--mint-primary-bg, #ecfdf5);
color: var(--mint-primary-dark, #059669);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.oa-receive-account-select__trigger {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 100%;
min-height: 40px;
padding: 4px 12px;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
}
.oa-receive-account-select__readonly {
color: var(--mint-text, #0f172a);
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
.oa-receive-account-select__placeholder {
color: var(--mint-text-muted, #94a3b8);
}
.oa-receive-account-select__trigger-text {
min-width: 0;
color: var(--mint-text, #0f172a);
word-break: break-all;
}
.oa-receive-account-select__chev {
flex-shrink: 0;
margin-left: 8px;
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
}
.oa-receive-account-select__search {
margin-bottom: 10px;
}
.oa-receive-account-select__list {
max-height: 50vh;
overflow: auto;
}
.oa-receive-account-select__row {
display: flex;
gap: 8px;
align-items: center;
min-height: 44px;
padding: 6px 4px;
cursor: pointer;
}
.oa-receive-account-select__row-label {
min-width: 0;
color: var(--mint-text, #0f172a);
}
.oa-receive-account-select__empty {
padding: 16px;
color: var(--mint-text-muted, #94a3b8);
text-align: center;
}
.oa-receive-account-select__detail {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.oa-receive-account-select__line {
color: var(--mint-text-muted, #64748b);
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
@media (max-width: 767px) {
.oa-receive-account-select__select :deep(.el-select__wrapper),
.oa-receive-account-select__trigger {
min-height: 44px;
}
}
</style>