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

+8
View File
@@ -195,6 +195,14 @@ export const reimburseApi = {
},
delete(id: BxId) {
return bxRequest.delete(`/reimburses/${id}`)
},
downloadImportTemplate() {
return bxRequest.get('/reimburses/import-template', { responseType: 'blob' })
},
importExcel(file: File) {
const formData = new FormData()
formData.append('file', file)
return bxRequest.post('/reimburses/import', formData)
}
}
@@ -0,0 +1,512 @@
<!-- 表头 Excel 风格列过滤:漏斗靠右,支持文本/枚举/数字/时间 -->
<template>
<el-popover
:visible="visible"
placement="bottom-end"
:width="300"
trigger="click"
:teleported="true"
popper-class="column-header-filter-popper"
@update:visible="onVisibleChange"
>
<template #reference>
<button
type="button"
class="column-header-filter-btn"
:class="{ active: isActive }"
:aria-label="`过滤${label}`"
>
<el-icon :size="14"><Filter /></el-icon>
</button>
</template>
<div class="column-header-filter-panel" @click.stop>
<div class="panel-hd">
<div class="panel-title">过滤 · {{ label }}</div>
<div class="panel-sub">{{ typeHint }}</div>
</div>
<div class="panel-bd">
<!-- 文本 / 枚举 -->
<template v-if="type === 'text' || type === 'enum'">
<div class="field">
<label>搜索可选值</label>
<el-input
v-model="draftKeyword"
clearable
placeholder="输入关键字过滤下方列表"
size="small"
/>
</div>
<div class="select-all-row">
<span>本页可选值</span>
<span>
<el-button link type="primary" size="small" @click="selectAllVisible">全选</el-button>
<el-button link type="primary" size="small" @click="selectNoneVisible"
>全不选</el-button
>
</span>
</div>
<div class="value-list">
<label v-for="opt in visibleOptions" :key="opt.value" class="value-item">
<el-checkbox
:model-value="draftSelected.includes(opt.value)"
@change="(v) => toggleSelected(opt.value, !!v)"
>
<span class="value-label">{{ opt.label }}</span>
</el-checkbox>
<span class="value-count">{{ opt.count }}</span>
</label>
<div v-if="!visibleOptions.length" class="empty-tip">无匹配项</div>
</div>
</template>
<!-- 数字 -->
<template v-else-if="type === 'number'">
<div class="field">
<label>条件</label>
<el-select v-model="draftOp" size="small" style="width: 100%">
<el-option label="介于" value="between" />
<el-option label="等于" value="eq" />
<el-option label="大于" value="gt" />
<el-option label="大于等于" value="gte" />
<el-option label="小于" value="lt" />
<el-option label="小于等于" value="lte" />
</el-select>
</div>
<div v-if="draftOp === 'between'" class="field">
<label>数值区间</label>
<div class="range-row">
<el-input
v-model="draftMin"
type="number"
size="small"
placeholder="最小值"
clearable
/>
<span class="sep">至</span>
<el-input
v-model="draftMax"
type="number"
size="small"
placeholder="最大值"
clearable
/>
</div>
</div>
<div v-else class="field">
<label>数值</label>
<el-input
v-model="draftValue"
type="number"
size="small"
placeholder="输入数值"
clearable
/>
</div>
</template>
<!-- 时间 -->
<template v-else-if="type === 'datetime'">
<div class="field">
<label>开始日期</label>
<el-date-picker
v-model="draftFrom"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始日期"
size="small"
clearable
style="width: 100%"
/>
</div>
<div class="field">
<label>结束日期</label>
<el-date-picker
v-model="draftTo"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
size="small"
clearable
style="width: 100%"
/>
</div>
<p class="field-tip">闭区间:含起止当天</p>
</template>
</div>
<div class="panel-ft">
<el-button size="small" @click="clearColumn">
<el-icon><Delete /></el-icon>清空本列
</el-button>
<div class="panel-ft-end">
<el-button size="small" @click="close">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" size="small" @click="confirm">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</div>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Filter, Delete, Close, Check } from '@element-plus/icons-vue'
import {
isColumnFilterActive,
type ColumnFilterType,
type ColumnFilterState,
type TextColumnFilterState,
type NumberColumnFilterState,
type DatetimeColumnFilterState,
type NumberFilterOp
} from '@/composables/useClientColumnFilters'
defineOptions({ name: 'ColumnHeaderFilter' })
const props = defineProps<{
prop: string
label: string
type: ColumnFilterType
isActive: boolean
modelValue?: ColumnFilterState | null
options?: Array<{ value: string; label: string; count: number }>
}>()
const emit = defineEmits<{
'update:modelValue': [value: ColumnFilterState | null]
clear: []
}>()
const visible = ref(false)
const draftKeyword = ref('')
const draftSelected = ref<string[]>([])
const draftOp = ref<NumberFilterOp>('between')
const draftValue = ref('')
const draftMin = ref('')
const draftMax = ref('')
const draftFrom = ref('')
const draftTo = ref('')
const typeHint = computed(() => {
if (props.type === 'text') return '文本:搜索并勾选本页出现的值'
if (props.type === 'enum') return '枚举:勾选固定选项'
if (props.type === 'number') return '数字:运算符 + 单值或区间'
return '时间:按日期闭区间过滤'
})
const visibleOptions = computed(() => {
const list = props.options || []
const q = draftKeyword.value.trim().toLowerCase()
if (!q) return list
return list.filter(
(opt) => opt.label.toLowerCase().includes(q) || String(opt.value).toLowerCase().includes(q)
)
})
function resetDraftFromModel() {
const m = props.modelValue
if (props.type === 'text' || props.type === 'enum') {
const s = (m || { selected: [], keyword: '' }) as TextColumnFilterState
draftSelected.value = [...(s.selected || [])]
draftKeyword.value = s.keyword || ''
return
}
if (props.type === 'number') {
const s = (m || { op: 'between', value: '', min: '', max: '' }) as NumberColumnFilterState
draftOp.value = s.op || 'between'
draftValue.value = s.value ?? ''
draftMin.value = s.min ?? ''
draftMax.value = s.max ?? ''
return
}
if (props.type === 'datetime') {
const s = (m || { from: '', to: '' }) as DatetimeColumnFilterState
draftFrom.value = s.from || ''
draftTo.value = s.to || ''
}
}
function onVisibleChange(next: boolean) {
if (next) resetDraftFromModel()
visible.value = next
}
function close() {
visible.value = false
}
function toggleSelected(value: string, checked: boolean) {
if (checked) {
if (!draftSelected.value.includes(value)) draftSelected.value.push(value)
} else {
draftSelected.value = draftSelected.value.filter((x) => x !== value)
}
}
function selectAllVisible() {
for (const opt of visibleOptions.value) {
if (!draftSelected.value.includes(opt.value)) draftSelected.value.push(opt.value)
}
}
function selectNoneVisible() {
const hide = new Set(visibleOptions.value.map((o) => o.value))
draftSelected.value = draftSelected.value.filter((v) => !hide.has(v))
}
function buildState(): ColumnFilterState {
if (props.type === 'text' || props.type === 'enum') {
return {
selected: [...draftSelected.value],
keyword: draftKeyword.value
}
}
if (props.type === 'number') {
return {
op: draftOp.value,
value: draftValue.value,
min: draftMin.value,
max: draftMax.value
}
}
return {
from: draftFrom.value || '',
to: draftTo.value || ''
}
}
function confirm() {
if (props.type === 'number' && draftOp.value === 'between') {
if (
draftMin.value !== '' &&
draftMax.value !== '' &&
Number(draftMin.value) > Number(draftMax.value)
) {
ElMessage.warning('最小值不能大于最大值')
return
}
}
if (props.type === 'datetime') {
if (draftFrom.value && draftTo.value && draftFrom.value > draftTo.value) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
}
const state = buildState()
if (isColumnFilterActive(props.type, state)) {
emit('update:modelValue', state)
} else {
emit('update:modelValue', null)
emit('clear')
}
close()
}
function clearColumn() {
emit('update:modelValue', null)
emit('clear')
close()
}
watch(
() => props.modelValue,
() => {
if (visible.value) resetDraftFromModel()
}
)
</script>
<style scoped lang="scss">
.column-header-filter-btn {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
color: #94a3b8;
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
&:hover {
color: var(--mint-text, #1e293b);
background: #e2e8f0;
}
&.active {
color: #fff;
background: var(--primary, #10b981);
}
}
.column-header-filter-panel {
margin: -4px;
}
.panel-hd {
padding: 4px 4px 10px;
border-bottom: 1px solid var(--el-border-color-lighter, #e2e8f0);
}
.panel-title {
margin-bottom: 2px;
font-size: 13px;
font-weight: 700;
color: var(--mint-text, #1e293b);
}
.panel-sub {
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.panel-bd {
max-height: 280px;
padding: 10px 4px;
overflow: auto;
}
.field {
margin-bottom: 10px;
label {
display: block;
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
}
.field-tip {
margin: 0;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.range-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.sep {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.select-all-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.value-list {
max-height: 180px;
padding: 4px;
overflow: auto;
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
border-radius: 8px;
}
.value-item {
display: flex;
gap: 8px;
align-items: center;
padding: 4px 6px;
cursor: pointer;
border-radius: 6px;
&:hover {
background: var(--primary-bg, #ecfdf5);
}
:deep(.el-checkbox) {
flex: 1;
height: auto;
margin-right: 0;
}
.value-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value-count {
flex-shrink: 0;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
}
.empty-tip {
padding: 16px 8px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
text-align: center;
}
.panel-ft {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 10px 4px 4px;
border-top: 1px solid var(--el-border-color-lighter, #e2e8f0);
}
.panel-ft-end {
display: flex;
gap: 8px;
align-items: center;
}
</style>
<style lang="scss">
.column-header-filter-popper.el-popover {
padding: 12px;
}
.col-header-with-filter {
display: flex;
gap: 4px;
align-items: center;
width: 100%;
min-width: 0;
}
.col-header-with-filter__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
color: inherit;
text-align: center;
white-space: nowrap;
}
.col-header-with-filter__label.is-left {
text-align: left;
}
.col-header-with-filter__label.is-right {
text-align: right;
}
</style>
@@ -18,7 +18,7 @@ import { PL_PLAN_TYPE_TABS } from '@/utils/pl-display'
const props = defineProps<{
modelValue: string
/** 覆盖默认单据类型 Tab(如危化品证上传页追加「未上传证书」) */
/** 覆盖默认单据类型 Tab(如运输计划追加「待安排」、危化品证上传页追加「未上传证书」) */
tabs?: { label: string; name: string }[]
}>()
@@ -0,0 +1,262 @@
import { computed, reactive, type Ref, type ComputedRef } from 'vue'
export type ColumnFilterType = 'text' | 'enum' | 'number' | 'datetime'
export type NumberFilterOp = 'between' | 'eq' | 'gt' | 'gte' | 'lt' | 'lte'
export interface ColumnFilterEnumOption {
value: string
label: string
}
export interface ColumnFilterDef {
prop: string
label: string
type: ColumnFilterType
/** 枚举固定选项;未提供时从当前页去重 */
enumOptions?: ColumnFilterEnumOption[]
/** 自定义取值(展示值 / 比较值) */
getValue?: (row: any) => unknown
}
export interface TextColumnFilterState {
selected: string[]
keyword: string
}
export interface NumberColumnFilterState {
op: NumberFilterOp
value: string
min: string
max: string
}
export interface DatetimeColumnFilterState {
from: string
to: string
}
export type ColumnFilterState =
| TextColumnFilterState
| NumberColumnFilterState
| DatetimeColumnFilterState
function parseDateMs(value: unknown): number | null {
const text = String(value ?? '').trim()
if (!text) return null
const t = Date.parse(text.replace(/-/g, '/'))
return Number.isNaN(t) ? null : t
}
function startOfDayMs(yyyyMmDd: string): number | null {
return parseDateMs(`${yyyyMmDd} 00:00:00`)
}
function endOfDayMs(yyyyMmDd: string): number | null {
return parseDateMs(`${yyyyMmDd} 23:59:59`)
}
function defaultState(type: ColumnFilterType): ColumnFilterState {
if (type === 'number') {
return { op: 'between', value: '', min: '', max: '' }
}
if (type === 'datetime') {
return { from: '', to: '' }
}
return { selected: [], keyword: '' }
}
export function isColumnFilterActive(
type: ColumnFilterType,
state?: ColumnFilterState | null
): boolean {
if (!state) return false
if (type === 'text' || type === 'enum') {
const s = state as TextColumnFilterState
return Array.isArray(s.selected) && s.selected.length > 0
}
if (type === 'number') {
const s = state as NumberColumnFilterState
if (s.op === 'between') {
return (s.min !== '' && s.min != null) || (s.max !== '' && s.max != null)
}
return s.value !== '' && s.value != null
}
if (type === 'datetime') {
const s = state as DatetimeColumnFilterState
return !!(s.from || s.to)
}
return false
}
function resolveRawValue(row: any, def: ColumnFilterDef): unknown {
if (def.getValue) return def.getValue(row)
return row?.[def.prop]
}
function resolveCompareKey(row: any, def: ColumnFilterDef): string {
const raw = resolveRawValue(row, def)
if (raw == null) return ''
return String(raw)
}
function matchOne(row: any, def: ColumnFilterDef, state: ColumnFilterState): boolean {
if (!isColumnFilterActive(def.type, state)) return true
if (def.type === 'text' || def.type === 'enum') {
const s = state as TextColumnFilterState
const key = resolveCompareKey(row, def)
return s.selected.includes(key)
}
if (def.type === 'number') {
const s = state as NumberColumnFilterState
const val = Number(resolveRawValue(row, def))
if (Number.isNaN(val)) return false
if (s.op === 'eq') return s.value !== '' && val === Number(s.value)
if (s.op === 'gt') return s.value !== '' && val > Number(s.value)
if (s.op === 'gte') return s.value !== '' && val >= Number(s.value)
if (s.op === 'lt') return s.value !== '' && val < Number(s.value)
if (s.op === 'lte') return s.value !== '' && val <= Number(s.value)
const minOk = s.min === '' || s.min == null || val >= Number(s.min)
const maxOk = s.max === '' || s.max == null || val <= Number(s.max)
return minOk && maxOk
}
if (def.type === 'datetime') {
const s = state as DatetimeColumnFilterState
const t = parseDateMs(resolveRawValue(row, def))
if (t == null) return false
const fromOk =
!s.from ||
(() => {
const from = startOfDayMs(s.from)
return from != null && t >= from
})()
const toOk =
!s.to ||
(() => {
const to = endOfDayMs(s.to)
return to != null && t <= to
})()
return fromOk && toOk
}
return true
}
export function useClientColumnFilters(
sourceRows: Ref<any[]> | ComputedRef<any[]>,
columnDefs: ColumnFilterDef[]
) {
const defMap = new Map(columnDefs.map((d) => [d.prop, d]))
const filters = reactive<Record<string, ColumnFilterState>>({})
const filteredRows = computed(() => {
const list = sourceRows.value || []
const activeProps = Object.keys(filters).filter((prop) => {
const def = defMap.get(prop)
return def && isColumnFilterActive(def.type, filters[prop])
})
if (!activeProps.length) return list
return list.filter((row) =>
activeProps.every((prop) => {
const def = defMap.get(prop)!
return matchOne(row, def, filters[prop])
})
)
})
const activeFilterProps = computed(() =>
Object.keys(filters).filter((prop) => {
const def = defMap.get(prop)
return def && isColumnFilterActive(def.type, filters[prop])
})
)
const hasActiveFilters = computed(() => activeFilterProps.value.length > 0)
function getFilterState(prop: string): ColumnFilterState {
const def = defMap.get(prop)
if (!def) return defaultState('text')
if (!filters[prop]) {
filters[prop] = defaultState(def.type)
}
return filters[prop]
}
function setFilterState(prop: string, state: ColumnFilterState | null) {
const def = defMap.get(prop)
if (!def) return
if (!state || !isColumnFilterActive(def.type, state)) {
delete filters[prop]
return
}
filters[prop] = JSON.parse(JSON.stringify(state))
}
function clearFilter(prop: string) {
delete filters[prop]
}
function clearAllFilters() {
Object.keys(filters).forEach((k) => delete filters[k])
}
function isFilterActive(prop: string): boolean {
const def = defMap.get(prop)
if (!def) return false
return isColumnFilterActive(def.type, filters[prop])
}
/** 当前页去重可选值(含计数) */
function collectUniqueOptions(
prop: string
): Array<{ value: string; label: string; count: number }> {
const def = defMap.get(prop)
if (!def) return []
if (def.type === 'enum' && def.enumOptions?.length) {
const counts = new Map<string, number>()
for (const row of sourceRows.value || []) {
const key = resolveCompareKey(row, def)
counts.set(key, (counts.get(key) || 0) + 1)
}
return def.enumOptions.map((opt) => ({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0
}))
}
const map = new Map<string, { label: string; count: number }>()
for (const row of sourceRows.value || []) {
const key = resolveCompareKey(row, def)
const label = key || '(空)'
const prev = map.get(key)
if (prev) prev.count += 1
else map.set(key, { label, count: 1 })
}
return [...map.entries()]
.map(([value, meta]) => ({ value, label: meta.label, count: meta.count }))
.sort((a, b) => a.label.localeCompare(b.label, 'zh-CN'))
}
function getColumnDef(prop: string): ColumnFilterDef | undefined {
return defMap.get(prop)
}
return {
filters,
filteredRows,
activeFilterProps,
hasActiveFilters,
getFilterState,
setFilterState,
clearFilter,
clearAllFilters,
isFilterActive,
collectUniqueOptions,
getColumnDef
}
}
+18
View File
@@ -324,6 +324,9 @@ export function canPrintSlipCommand(planType: string | undefined, command: PlPri
export type PlPlanTypeTab = 'all' | 'inbound' | 'outbound' | 'direct'
/** 运输计划列表 Tab(在单据类型后增加「待安排」) */
export type PlPlanListTab = PlPlanTypeTab | 'pending_arrange'
/** 危化品证上传页 Tab(在单据类型后增加「未上传证书」) */
export type PlPlanCertTab = PlPlanTypeTab | 'cert_incomplete'
@@ -334,6 +337,11 @@ export const PL_PLAN_TYPE_TABS: { label: string; name: PlPlanTypeTab }[] = [
{ label: '直销单', name: 'direct' }
]
export const PL_PLAN_LIST_TABS: { label: string; name: PlPlanListTab }[] = [
...PL_PLAN_TYPE_TABS,
{ label: '待安排', name: 'pending_arrange' }
]
export const PL_PLAN_CERT_TABS: { label: string; name: PlPlanCertTab }[] = [
...PL_PLAN_TYPE_TABS,
{ label: '未上传证书', name: 'cert_incomplete' }
@@ -343,6 +351,16 @@ export function plPlanTypeTabToFilter(tab: PlPlanTypeTab): string {
return tab === 'all' ? '' : tab
}
export function plPlanListTabToFilters(tab: PlPlanListTab): {
planType: string
followStatus?: string
} {
if (tab === 'pending_arrange') {
return { planType: '', followStatus: 'pending_arrange' }
}
return { planType: plPlanTypeTabToFilter(tab) }
}
export function plPlanCertTabToFilters(tab: PlPlanCertTab): {
planType: string
certUploadStatus?: string
+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()
}