feat: 增加顺丰月结列表,支持导出、结算和补填运费

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-21 20:17:45 +08:00
1 parent 77e8c78cb6
commit a81f177cb6
4 files changed
+532

No files matched your search

@@ -0,0 +1,38 @@
import request from '@/utils/http'
export type SfExpressSettlement = {
id: string
waybillNo: string
createByName: string
createTime: string
settleStatus: string
settleStatusLabel: string
freightAmount: number | null
freightAmountLabel: string
settleTime: string
}
export function fetchSfExpressSettlements(params: Record<string, unknown>) {
return request.get<{ list: SfExpressSettlement[]; total: number }>({
url: '/web/admin/sample-freights/sf-settlements',
params
})
}
export function fetchSfExpressSettlementExport(params: Record<string, unknown>) {
return request.get<{ list: SfExpressSettlement[] }>({
url: '/web/admin/sample-freights/sf-settlements/export',
params
})
}
export function settleSfExpressSettlements(data: { ids: string[]; freightAmount?: number }) {
return request.post({ url: '/web/admin/sample-freights/sf-settlements/settle', data })
}
export function fillSfExpressSettlementAmount(id: string, freightAmount: number) {
return request.post({
url: `/web/admin/sample-freights/sf-settlements/${id}/fill-amount`,
data: { freightAmount }
})
}
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildSfExpressExportRows } from './sf-express-settlement-export.ts'
test('export rows match the settlement list columns', () => {
const rows = buildSfExpressExportRows([
{
waybillNo: '',
createByName: '张三',
createTime: '2026-10-21 09:00:00',
settleStatusLabel: '未结算',
freightAmountLabel: '月结',
settleTime: ''
}
])
assert.deepEqual(rows[0], ['—', '张三', '2026-10-21 09:00:00', '未结算', '月结', '—'])
})
@@ -0,0 +1,30 @@
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
const HEADERS = ['顺丰单号', '创建人', '创建时间', '结算状态', '运费', '结算时间']
export function buildSfExpressExportRows(list: Array<Record<string, string>>) {
return list.map((row) => [
row.waybillNo || '—',
row.createByName || '—',
row.createTime || '—',
row.settleStatusLabel || '—',
row.freightAmountLabel || '—',
row.settleTime || '—'
])
}
export async function saveSfExpressSettlementWorkbook(list: Array<Record<string, string>>) {
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('顺丰月结')
sheet.addRow(HEADERS)
buildSfExpressExportRows(list).forEach((line) => sheet.addRow(line))
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const stamp = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
const name = `顺丰月结-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}.xlsx`
FileSaver.saveAs(blob, name)
}
@@ -0,0 +1,447 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">顺丰月结</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="顺丰单号">
<el-input v-model="searchForm.waybillNo" placeholder="顺丰单号" clearable />
</el-form-item>
<el-form-item label="结算状态">
<el-select
v-model="searchForm.settleStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="未结算" value="wjs" />
<el-option label="已结算" value="yjs" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="searchForm.createTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
class="btn-main"
type="primary"
:disabled="settleDisabled"
@click="handleSettle"
>
<el-icon><Check /></el-icon>
标记已结算
</el-button>
<el-button class="btn-ghost" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableRows"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column
prop="waybillNo"
label="顺丰单号"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.waybillNo || '—' }}</template>
</el-table-column>
<el-table-column
prop="createByName"
label="创建人"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.createByName || '—' }}</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.createTime || '—' }}</template>
</el-table-column>
<el-table-column label="结算状态" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.settleStatus === 'yjs'" type="success" size="small">已结算</el-tag>
<el-tag v-else-if="row.settleStatus === 'wjs'" type="info" size="small"
>未结算</el-tag
>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
prop="freightAmountLabel"
label="运费"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.freightAmountLabel || '—' }}</template>
</el-table-column>
<el-table-column
prop="settleTime"
label="结算时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.settleTime || '—' }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="row.settleStatus === 'yjs' && row.freightAmountLabel === '月结'"
class="btn-op-item"
@click="openFill(row)"
>
<el-icon><EditPen /></el-icon>
补填运费
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
</section>
<el-dialog
v-model="settleVisible"
title="标记已结算"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" @submit.prevent>
<el-form-item>
<template #label>
运费<span class="form-label-tip">不填则保持当前展示:月结或原金额</span>
</template>
<el-input v-model="settleAmount" placeholder="选填,0 也是有效金额" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="settleVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="settleSubmitting"
@click="confirmSettle"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="fillVisible"
title="补填运费"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="运费">
<el-input v-model="fillAmount" placeholder="请填写运费,0 也是有效金额" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="fillVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="fillSubmitting" @click="confirmFill">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Download, EditPen, RefreshLeft, Search } from '@element-plus/icons-vue'
import {
fetchSfExpressSettlementExport,
fetchSfExpressSettlements,
fillSfExpressSettlementAmount,
settleSfExpressSettlements,
type SfExpressSettlement
} from '@/api/sf-express-settlement'
import { buildSettlePayload } from '@/utils/sf-express-freight'
import { saveSfExpressSettlementWorkbook } from '@/utils/sf-express-settlement-export'
defineOptions({ name: 'CellStorageSfExpressSettlement' })
const loading = ref(false)
const tableRows = ref<SfExpressSettlement[]>([])
const selectedRows = ref<SfExpressSettlement[]>([])
const searchForm = reactive({
waybillNo: '',
settleStatus: '',
createTimeRange: null as [string, string] | null
})
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const settleVisible = ref(false)
const settleAmount = ref('')
const settleIds = ref<string[]>([])
const settleSubmitting = ref(false)
const fillVisible = ref(false)
const fillAmount = ref('')
const fillId = ref('')
const fillSubmitting = ref(false)
const settleDisabled = computed(
() =>
selectedRows.value.length === 0 ||
selectedRows.value.some((row) => row.settleStatus === 'yjs')
)
function currentFilters() {
const [from, to] = searchForm.createTimeRange || []
const waybillNo = searchForm.waybillNo.trim()
return {
waybillNo: waybillNo || undefined,
settleStatus: searchForm.settleStatus || undefined,
createTimeFrom: from || undefined,
createTimeTo: to || undefined
}
}
function unwrapBody(res: unknown) {
const record = res as { data?: { list?: unknown }; list?: unknown; code?: unknown }
if (record?.data?.list) return record.data
return record?.list ? record : record?.data || record
}
function parseNonNegativeAmount(raw: unknown): 'blank' | 'invalid' | number {
const text = String(raw ?? '').trim()
if (!text) return 'blank'
const value = Number(text)
if (!Number.isFinite(value) || value < 0) return 'invalid'
return value
}
async function loadData() {
loading.value = true
try {
const res = await fetchSfExpressSettlements({
page: pagination.page,
pageSize: pagination.pageSize,
...currentFilters()
})
const data = unwrapBody(res) as { list?: SfExpressSettlement[]; total?: number }
tableRows.value = Array.isArray(data?.list) ? data.list : []
pagination.total = Number(data?.total || 0)
selectedRows.value = []
} catch {
tableRows.value = []
pagination.total = 0
selectedRows.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.waybillNo = ''
searchForm.settleStatus = ''
searchForm.createTimeRange = null
handleSearch()
}
function handleSelectionChange(rows: SfExpressSettlement[]) {
selectedRows.value = rows
}
async function submitSettle(payload: { ids: string[]; freightAmount?: number }) {
settleSubmitting.value = true
try {
await settleSfExpressSettlements(payload)
settleVisible.value = false
await loadData()
} catch {
// 拦截器已提示失败原因
} finally {
settleSubmitting.value = false
}
}
async function handleSettle() {
if (settleDisabled.value) return
const ids = selectedRows.value.map((row) => row.id).filter(Boolean)
if (!ids.length) return
if (ids.length > 1) {
try {
await ElMessageBox.confirm('本次仅更新结算状态和结算时间。', '标记已结算', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
} catch {
return
}
await submitSettle(buildSettlePayload(ids, ''))
return
}
settleIds.value = ids
settleAmount.value = ''
settleVisible.value = true
}
async function confirmSettle() {
const parsed = parseNonNegativeAmount(settleAmount.value)
if (parsed === 'invalid') {
ElMessage.warning('请填写运费')
return
}
const text = parsed === 'blank' ? '' : String(parsed)
await submitSettle(buildSettlePayload(settleIds.value, text))
}
function openFill(row: SfExpressSettlement) {
if (row.settleStatus !== 'yjs' || row.freightAmountLabel !== '月结') return
fillId.value = row.id
fillAmount.value = ''
fillVisible.value = true
}
async function confirmFill() {
const parsed = parseNonNegativeAmount(fillAmount.value)
if (parsed === 'blank' || parsed === 'invalid') {
ElMessage.warning('请填写运费')
return
}
fillSubmitting.value = true
try {
await fillSfExpressSettlementAmount(fillId.value, parsed)
fillVisible.value = false
await loadData()
} catch {
// 拦截器已提示失败原因
} finally {
fillSubmitting.value = false
}
}
async function handleExport() {
try {
const res = await fetchSfExpressSettlementExport(currentFilters())
const code = (res as { code?: unknown })?.code
if (code != null && Number(code) !== 200) {
ElMessage.warning('没有可导出的数据')
return
}
const body = (res as { data?: { list?: unknown } })?.data?.list
? (res as { data?: { list?: SfExpressSettlement[] } }).data
: (res as { list?: SfExpressSettlement[] })
const list = body?.list || []
if (!list.length) {
ElMessage.warning('没有可导出的数据')
return
}
await saveSfExpressSettlementWorkbook(list as Array<Record<string, string>>)
} catch (error: any) {
const message = String(error?.message || '')
const code = error?.code
if (message.includes('没有可导出的数据') || (code != null && Number(code) !== 200)) {
ElMessage.warning('没有可导出的数据')
}
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.muted {
color: var(--mint-text-muted, #64748b);
}
</style>