提交
This commit is contained in:
1 parent
c3bd2c00ae
commit
a52fe77e34
39 files changed
+609647
-9238
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user