This commit is contained in:
zlwu committed 2026-08-19 09:14:37 +08:00
1 parent c3bd2c00ae
commit a52fe77e34
39 files changed
+609647 -9238

No files matched your search

+392 -27
View File
@@ -57,6 +57,12 @@
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增报销申请
</el-button>
<el-button class="btn-ghost" :loading="templateLoading" @click="downloadImportTemplate">
<el-icon><Download /></el-icon>下载模板
</el-button>
<el-button class="btn-ghost" @click="openImportDialog">
<el-icon><FolderAdd /></el-icon>批量导入
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1 || !isDraftEditable(selectedRows[0])"
@@ -83,6 +89,13 @@
<el-button class="btn-ghost" @click="openFlowDialog()">
<el-icon><View /></el-icon>查看审批流
</el-button>
<el-button
v-if="hasActiveFilters"
class="btn-ghost"
@click="clearAllFilters"
>
<el-icon><Filter /></el-icon>清空列过滤
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -100,7 +113,7 @@
<div ref="reimburseApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-reimburse-apply"
class="list-table"
:data="rows"
:data="filteredRows"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@@ -113,20 +126,51 @@
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="applyCode"
label="报销单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请时间</span>
<ColumnHeaderFilter
prop="applyTime"
label="申请时间"
type="datetime"
:is-active="isFilterActive('applyTime')"
:model-value="filters.applyTime || null"
@update:model-value="(v) => setFilterState('applyTime', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ formatApplyDateOnly(row.applyTime) }}
</template>
</el-table-column>
<el-table-column
prop="applyCode"
label="报销单号"
class-name="col-apply-code"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">报销单号</span>
<ColumnHeaderFilter
prop="applyCode"
label="报销单号"
type="text"
:is-active="isFilterActive('applyCode')"
:model-value="filters.applyCode || null"
:options="collectUniqueOptions('applyCode')"
@update:model-value="(v) => setFilterState('applyCode', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
@@ -135,20 +179,49 @@
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
align="left"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('expenseName')"
prop="expenseName"
label="费用项目"
min-width="260"
min-width="200"
align="left"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">报销标题</span>
<ColumnHeaderFilter
prop="title"
label="报销标题"
type="text"
:is-active="isFilterActive('title')"
:model-value="filters.title || null"
:options="collectUniqueOptions('title')"
@update:model-value="(v) => setFilterState('title', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('expenseName')"
prop="expenseName"
label="费用项目"
min-width="280"
align="left"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">费用项目</span>
<ColumnHeaderFilter
prop="expenseName"
label="费用项目"
type="text"
:is-active="isFilterActive('expenseName')"
:model-value="filters.expenseName || null"
:options="collectUniqueOptions('expenseName')"
@update:model-value="(v) => setFilterState('expenseName', v)"
/>
</div>
</template>
<template #default="{ row }">
<div class="expense-items-lines">
<div
@@ -167,18 +240,46 @@
label="报销金额(元)"
:formatter="moneyTableFormatter"
class-name="col-money"
min-width="120"
min-width="150"
align="right"
header-align="center"
/>
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">报销金额(元)</span>
<ColumnHeaderFilter
prop="amount"
label="报销金额(元)"
type="number"
:is-active="isFilterActive('amount')"
:model-value="filters.amount || null"
@update:model-value="(v) => setFilterState('amount', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
min-width="130"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">状态</span>
<ColumnHeaderFilter
prop="status"
label="状态"
type="enum"
:is-active="isFilterActive('status')"
:model-value="filters.status || null"
:options="collectUniqueOptions('status')"
@update:model-value="(v) => setFilterState('status', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
@@ -189,10 +290,24 @@
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
min-width="140"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">当前节点</span>
<ColumnHeaderFilter
prop="currentNode"
label="当前节点"
type="text"
:is-active="isFilterActive('currentNode')"
:model-value="filters.currentNode || null"
:options="collectUniqueOptions('currentNode')"
@update:model-value="(v) => setFilterState('currentNode', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -209,7 +324,7 @@
ref="reimburseApplyStatsRowRef"
storage-key="bx-reimburse-apply-column-stats"
:columns="reimburseApplyStatsRowColumns"
:data="rows"
:data="filteredRows"
:table-wrap-el="reimburseApplyTableWrapRef"
:get-cell-value="getReimburseApplyStatCellValue"
/>
@@ -459,6 +574,81 @@
@confirm="onInvoicesPicked"
/>
<el-dialog
v-model="importDialogVisible"
title="批量导入报销申请"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<p class="import-hint">
请先下载最新模板填写。灰色【示例】行导入时自动跳过。导入结果为「待提交」草稿,发票请事后在系统中关联。导入完成后可下载带「导入结果」列的结果表。
</p>
<el-upload
drag
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls"
:file-list="importFileList"
:on-change="onImportFileChange"
:on-remove="onImportFileRemove"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="importResult" class="import-result">
<p v-if="importResult.tip" class="import-tip">{{ importResult.tip }}</p>
<p>
成功 {{ importResult.successGroups || 0 }} 组({{ importResult.successRows || 0 }} 行),失败
{{ importResult.failGroups || 0 }} 组({{ importResult.failRows || 0 }} 行)
<span v-if="importResult.skippedRows">,跳过 {{ importResult.skippedRows }} 行</span>
</p>
<el-table
v-if="importResult.rowResults?.length"
:data="importResult.rowResults"
size="small"
max-height="280"
>
<el-table-column prop="excelRow" label="行号" min-width="70" align="center" />
<el-table-column prop="groupNo" label="分组号" min-width="80" />
<el-table-column prop="status" label="导入结果" min-width="90" align="center">
<template #default="{ row }">
<el-tag
:type="row.status === '成功' ? 'success' : row.status === '失败' ? 'danger' : 'info'"
size="small"
>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="220" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button class="btn-ghost" @click="importDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button
v-if="importResult?.resultFileBase64"
class="btn-ghost"
@click="downloadImportResultFile"
>
<el-icon><Download /></el-icon>下载结果表
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="importLoading"
:disabled="!importFileList.length"
@click="submitImport"
>
<el-icon><Check /></el-icon>开始导入
</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-dialog
v-model="dialogs.view"
@@ -600,8 +790,13 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
useClientColumnFilters,
type ColumnFilterDef
} from '@/composables/useClientColumnFilters'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -619,8 +814,13 @@ import {
Promotion,
VideoPlay,
Document,
Download
Download,
FolderAdd,
UploadFilled,
Check,
Filter
} from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
import { reimburseApi, invoiceWalletApi } from '@/api/bx'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { canDeleteByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
@@ -680,6 +880,49 @@ const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search,
}
)
const columnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyTime', label: '申请时间', type: 'datetime' },
{ prop: 'applyCode', label: '报销单号', type: 'text' },
{ prop: 'title', label: '报销标题', type: 'text' },
{
prop: 'expenseName',
label: '费用项目',
type: 'text',
getValue: (row) => {
const lines = getReimburseExpenseItemLines(row.details, row)
return lines?.length ? lines.join('\n') : row.expenseName || ''
}
},
{ prop: 'amount', label: '报销金额(元)', type: 'number' },
{
prop: 'status',
label: '状态',
type: 'enum',
enumOptions: [
{ value: 'draft', label: '待提交' },
{ value: 'pending', label: '审批中' },
{ value: 'approved', label: '审批通过' },
{ value: 'rejected', label: '审批驳回' }
]
},
{
prop: 'currentNode',
label: '当前节点',
type: 'text',
getValue: (row) => workflowNodeLabel(row.currentNode, row.status) || ''
}
]
const {
filters,
filteredRows,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
collectUniqueOptions
} = useClientColumnFilters(rows, columnFilterDefs)
const { expenseTree } = useExpenseItemTree()
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const userStore = useUserStore()
@@ -742,11 +985,20 @@ const reimburseApplyStatsRowColumns = computed((): StatsRowColumn[] => {
return cols
})
function formatApplyDateOnly(value: unknown): string {
const text = String(value ?? '').trim()
if (!text) return '-'
return text.slice(0, 10)
}
function getReimburseApplyStatCellValue(row: any, prop: string): unknown {
if (prop === 'expenseName') {
const lines = getReimburseExpenseItemLines(row.details, row)
return lines?.length ? lines.join('\n') : row.expenseName
}
if (prop === 'applyTime') {
return formatApplyDateOnly(row?.applyTime)
}
return row?.[prop]
}
@@ -837,6 +1089,12 @@ const walletBalance = ref(0)
const editingId = ref<number | null>(null)
const titleAutoLinked = ref(true)
const templateLoading = ref(false)
const importDialogVisible = ref(false)
const importLoading = ref(false)
const importFileList = ref<UploadFile[]>([])
const importResult = ref<Record<string, any> | null>(null)
/** 同一发票共用边框色(与 BxInvoiceSelectDialog / bx-mobile 一致) */
const INVOICE_GROUP_COLORS = [
'#0891b2',
@@ -1113,7 +1371,8 @@ async function openEditDialog(row: any) {
titleAutoLinked.value = isAutoReimburseTitle(addForm.title)
addForm.remark = data.remark || ''
addForm.details = (data.details || []).map((d: any) => ({
expenseItemId: d.expenseId,
// 费用项 id 可能是纯数字字符串;必须 String,否则 el-tree-select 对不上树节点会直接显示数字
expenseItemId: d.expenseId != null && d.expenseId !== '' ? String(d.expenseId) : '',
amount: d.amount,
remark: d.remark || '',
occurDate: d.occurDate || ''
@@ -1150,6 +1409,94 @@ async function refreshList() {
await refresh()
}
async function downloadImportTemplate() {
templateLoading.value = true
try {
const res: any = await reimburseApi.downloadImportTemplate()
const blob =
res instanceof Blob
? res
: new Blob([res as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '报销申请导入模板.xlsx'
link.click()
URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载模板失败')
} finally {
templateLoading.value = false
}
}
function openImportDialog() {
importFileList.value = []
importResult.value = null
importDialogVisible.value = true
}
function onImportFileChange(_file: UploadFile, files: UploadFile[]) {
importFileList.value = files.slice(-1)
}
function onImportFileRemove() {
importFileList.value = []
}
async function submitImport() {
const raw = importFileList.value[0]?.raw
if (!raw) {
ElMessage.warning('请选择要导入的文件')
return
}
importLoading.value = true
importResult.value = null
try {
const res: any = await reimburseApi.importExcel(raw)
const data = pickBxData(res) || res?.data || res
importResult.value = data
const ok = Number(data?.successGroups || 0)
const fail = Number(data?.failGroups || 0)
if (data?.tip) {
ElMessage.warning(String(data.tip))
} else if (ok > 0 && fail === 0) {
ElMessage.success(`成功导入 ${ok} 组`)
} else if (ok > 0) {
ElMessage.warning(`部分成功:成功 ${ok} 组,失败 ${fail} 组`)
} else if (fail > 0) {
ElMessage.error('导入失败,请查看结果清单或下载结果表')
} else {
ElMessage.warning('没有导入任何数据')
}
if (ok > 0) await refreshList()
} finally {
importLoading.value = false
}
}
function downloadImportResultFile() {
const b64 = importResult.value?.resultFileBase64
if (!b64) {
ElMessage.warning('暂无结果文件可下载')
return
}
const binary = atob(String(b64))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = String(importResult.value?.resultFileName || '报销申请导入结果.xlsx')
link.click()
URL.revokeObjectURL(url)
}
onMounted(() => {
refreshList()
})
@@ -1484,6 +1831,24 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
</script>
<style scoped lang="scss">
.import-hint {
margin: 0 0 12px;
color: var(--mint-text-muted, #64748b);
font-size: 13px;
line-height: 1.5;
}
.import-tip {
margin: 0 0 8px;
color: #d97706;
font-size: 13px;
line-height: 1.5;
}
.import-result {
margin-top: 16px;
}
.invoice-balance-tip {
margin: 0 0 10px;
font-size: 13px;
+30 -17
View File
@@ -2,7 +2,11 @@
<template>
<div class="page-container">
<section class="panel">
<PlPlanTypeTabs v-model="activePlanTypeTab" @change="handlePlanTypeTabChange" />
<PlPlanTypeTabs
:model-value="activePlanTypeTab"
:tabs="PL_PLAN_LIST_TABS"
@change="handlePlanTypeTabChange"
/>
<!-- 筛选区 -->
<div class="filter-block">
@@ -17,7 +21,12 @@
/>
</el-form-item>
<el-form-item label="跟进状态">
<el-select v-model="searchForm.followStatus" placeholder="全部" clearable>
<el-select
v-model="searchForm.followStatus"
placeholder="全部"
clearable
:disabled="activePlanTypeTab === 'pending_arrange'"
>
<el-option
v-for="opt in PL_PLAN_FOLLOW_STATUS_OPTIONS"
:key="opt.value"
@@ -226,9 +235,7 @@
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlanTypeTagType(row.planType)" size="small">
{{ getPlanTypeLabel(row.planType) }}
</el-tag>
{{ getPlanTypeLabel(row.planType) }}
</template>
</el-table-column>
<el-table-column
@@ -557,8 +564,9 @@ import {
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
plPlanTypeTabToFilter,
type PlPlanTypeTab,
PL_PLAN_LIST_TABS,
plPlanListTabToFilters,
type PlPlanListTab,
type PlPrintSlipCommand
} from '@/utils/pl-display'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
@@ -742,7 +750,7 @@ async function handlePlanMutationSuccess(payload?: { planId?: string; openArrang
const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null))
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const activePlanTypeTab = ref<PlPlanTypeTab>('all')
const activePlanTypeTab = ref<PlPlanListTab>('all')
const searchForm = reactive({
planNo: '',
@@ -759,11 +767,6 @@ const pagination = reactive({
total: 0
})
function getPlanTypeTagType(type: string) {
const map: Record<string, string> = { direct: 'primary', inbound: 'success', outbound: 'warning' }
return map[type] || 'info'
}
function getPlanTypeLabel(type?: string | null) {
if (!type) return '—'
const map: Record<string, string> = { direct: '直销单', inbound: '入库单', outbound: '出库单' }
@@ -788,11 +791,12 @@ async function fetchData() {
loading.value = true
try {
const keyword = quickSearch.value.trim()
const tabFilters = plPlanListTabToFilters(activePlanTypeTab.value)
const res = await planApi.list({
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: searchForm.planType || undefined,
followStatus: searchForm.followStatus || undefined,
planType: tabFilters.planType || undefined,
followStatus: tabFilters.followStatus || searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
productName: keyword ? undefined : searchForm.productName || undefined,
licensePlate: keyword ? undefined : searchForm.licensePlate || undefined,
@@ -814,8 +818,17 @@ function handleSearch() {
fetchData()
}
function handlePlanTypeTabChange(tab: PlPlanTypeTab) {
searchForm.planType = plPlanTypeTabToFilter(tab)
function handlePlanTypeTabChange(tab: PlPlanListTab | string) {
const nextTab = tab as PlPlanListTab
const prevTab = activePlanTypeTab.value
activePlanTypeTab.value = nextTab
const filters = plPlanListTabToFilters(nextTab)
searchForm.planType = filters.planType
if (nextTab === 'pending_arrange') {
searchForm.followStatus = 'pending_arrange'
} else if (prevTab === 'pending_arrange') {
searchForm.followStatus = ''
}
pagination.page = 1
fetchData()
}