两端选择器都只展示 options 接口返回的本人账户,空列表不再像是系统没有账户。 Co-authored-by: Cursor <cursoragent@cursor.com>
633 lines
18 KiB
Vue
633 lines
18 KiB
Vue
<!-- 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>
|