zjk
2026-06-03 c83db27e029cccb839991357129977a91ab70c72
feat: 新增财务账单管理模块,优化多页面代码与UI

- 新增财务账单路由、Mock API 服务及对应页面文件
- 清理系统配置页面中多余的报错提示逻辑
- 为多个数据表格添加自定义空状态模板与图片加载容错处理
- 新增车辆素材上传的图片尺寸校验(450*210)并添加尺寸提示文案
2个文件已添加
11个文件已修改
547 ■■■■■ 已修改文件
src/router/routes.static.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/financeBill.ts 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/vehicleMaterial/index.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/cruiseCheckInRule/components/DetailModal.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/accountPermission/roleManagement/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/config/index.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/financeBill/index.vue 311 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanList/detail.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPointManagement/list.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/detail.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/pricingManagement/detail.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/active/activityManagement/components/ActivityOrderList.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/contentCenter/components/AgreementListForm.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -360,6 +360,19 @@
    fullScreen: true,
  },
  {
    name: 'financeBill',
    path: '/system/financeBill',
    title: '财务账单',
    requiresAuth: true,
    icon: 'icon-park-outline:bill',
    menuType: 'page',
    componentPath: '/system/financeBill/index.vue',
    id: 204,
    keepAlive: true,
    pid: 2,
    fullScreen: true,
  },
  {
    name: 'userContent',
    path: '/userContent',
    title: '用户&内容',
src/service/api/financeBill.ts
New file
@@ -0,0 +1,97 @@
import dayjs from 'dayjs'
export interface FinanceBillRecord {
  id: number
  operationTime: string
  operatorName: string
  codeResult: 'success' | 'param_error' | 'repeat_push'
  returnResult: string
}
export interface FinanceBillLogRecord {
  id: number
  operatorName: string
  operatorAccount: string
  operationType: number
  operationContent: string
  operationIp: string
  operationTime: string
}
const operatorNames = ['蔡文婷', '黄芸欢', '王思龙']
const financeBillResultTemplates = [
  {
    codeResult: 'success' as const,
    returnResult: '返回结果返回结果返回结果返回结果返回结果返回结果返回结果返回结果返回结果返回结果...',
  },
  {
    codeResult: 'param_error' as const,
    returnResult: '参数校验失败,请检查账单批次号、账期和推送目标后重试。',
  },
  {
    codeResult: 'repeat_push' as const,
    returnResult: '重复事件,同一账单在短时间内已发起过一次推送,系统已自动拦截。',
  },
]
// TODO: 联调时将以下 mock 实现替换为真实接口请求。
const financeBillMockList: FinanceBillRecord[] = Array.from({ length: 28 }, (_, index) => {
  const result = financeBillResultTemplates[index % financeBillResultTemplates.length]
  return {
    id: index + 1,
    operationTime: dayjs('2026-09-09 11:09:08').subtract(index, 'minute').format('YYYY-MM-DD HH:mm:ss'),
    operatorName: operatorNames[index % operatorNames.length],
    codeResult: result.codeResult,
    returnResult: result.returnResult,
  }
})
const financeBillOperationLogMockList: FinanceBillLogRecord[] = Array.from({ length: 18 }, (_, index) => ({
  id: index + 1,
  operatorName: operatorNames[index % operatorNames.length],
  operatorAccount: `finance_${index + 1}`,
  operationType: index % 2 === 0 ? 2 : 1,
  operationContent: index % 2 === 0 ? '执行财务账单手动推送' : '查看财务账单详情',
  operationIp: `10.10.10.${index + 10}`,
  operationTime: dayjs('2026-09-09 11:09:08').subtract(index * 3, 'minute').format('YYYY-MM-DD HH:mm:ss'),
}))
function paginateList<T>(list: T[], pageNum = 1, pageSize = 10) {
  const startIndex = (pageNum - 1) * pageSize
  return {
    records: list.slice(startIndex, startIndex + pageSize),
    total: list.length,
  }
}
export function fetchFinanceBillPage(params: { pageNum?: number, pageSize?: number }) {
  const { pageNum = 1, pageSize = 10 } = params || {}
  return Promise.resolve({
    code: 200,
    data: paginateList(financeBillMockList, pageNum, pageSize),
  })
}
export function fetchFinanceBillDetail(id: number | string) {
  const target = financeBillMockList.find(item => String(item.id) === String(id)) || null
  return Promise.resolve({
    code: 200,
    data: target,
  })
}
export function manualPushFinanceBill() {
  return Promise.resolve({
    code: 200,
    msg: '手动推送成功',
  })
}
export function fetchFinanceBillOperationLogPage(params: { pageNum?: number, pageSize?: number }) {
  const { pageNum = 1, pageSize = 10 } = params || {}
  return Promise.resolve({
    code: 200,
    data: paginateList(financeBillOperationLogMockList, pageNum, pageSize),
  })
}
src/views/assetManagement/vehicleManagement/vehicleMaterial/index.vue
@@ -35,6 +35,28 @@
}
const listData = ref<MaterialItem[]>([])
function getImageSize(file: File) {
  return new Promise<{ width: number, height: number }>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => {
      const image = new Image()
      image.onload = () => {
        resolve({
          width: image.width,
          height: image.height,
        })
      }
      image.onerror = () => reject(new Error('图片尺寸读取失败'))
      image.src = String(reader.result || '')
    }
    reader.onerror = () => reject(new Error('图片文件读取失败'))
    reader.readAsDataURL(file)
  })
}
// 获取列表
async function getList() {
  startLoading()
@@ -74,6 +96,20 @@
  const maxSize = 2 * 1024 * 1024 // 2MB
  if (file.size > maxSize) {
    message.error('图片大小不能超过 2MB')
    return false
  }
  try {
    // 上传前校验图片实际宽高,只允许 450 * 210 的素材图。
    const { width, height } = await getImageSize(file)
    if (width !== 450 || height !== 210) {
      message.error('请上传尺寸为 450*210,比例为 15:7 的图片')
      return false
    }
  }
  catch (error) {
    console.error('图片尺寸校验失败:', error)
    message.error('图片尺寸校验失败,请重新选择图片')
    return false
  }
@@ -214,6 +250,9 @@
                    <div class="mt-12px text-12px" style="color: rgba(23, 31, 38, 0.6);">
                      点击上传图片
                    </div>
                    <div class="mt-4px text-12px" style="color: rgba(23, 31, 38, 0.4);">
                      尺寸需为 450*210
                    </div>
                  </div>
                </NUploadDragger>
              </NUpload>
src/views/operations/cruiseCheckInRule/components/DetailModal.vue
@@ -147,7 +147,13 @@
              :data="detailInfo.pointInfoList"
              :bordered="false"
              :max-height="300"
            />
            >
              <template #empty>
                <div class="flex-center">
                  <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
                </div>
              </template>
            </NDataTable>
          </div>
        </div>
      </div>
src/views/system/accountPermission/roleManagement/index.vue
@@ -393,7 +393,13 @@
          flex-height
          :bordered="false"
          :scroll-x="1060"
        />
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="count"
          :page="pageParams.pageNum"
src/views/system/config/index.vue
@@ -144,9 +144,6 @@
      window.$message?.success('保存成功')
      loadSystemSetting()
    }
    else {
      window.$message?.warning(res.msg || '保存失败')
    }
  }
  catch (error) {
    console.warn('验证失败', error)
@@ -175,13 +172,9 @@
      formModel.value.xczHmSpeedPerHour = d.xczHmSpeedPerHour ? d.xczHmSpeedPerHour.toString() : ''
      formModel.value.xczHmDuration = d.xczHmDuration ? d.xczHmDuration.toString() : ''
    }
    else {
      window.$message?.warning('获取系统配置失败')
    }
  }
  catch (e) {
    console.error(e)
    window.$message?.error('请求系统配置失败')
  }
}
src/views/system/financeBill/index.vue
New file
@@ -0,0 +1,311 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
import dayjs from 'dayjs'
import OperationLogModal from '@/components/custom/OperationLogModal.vue'
import Pagination from '@/components/common/Pagination.vue'
import {
  fetchFinanceBillDetail,
  fetchFinanceBillOperationLogPage,
  fetchFinanceBillPage,
  manualPushFinanceBill,
} from '@/service/api/financeBill'
type CodeResultType = 'success' | 'param_error' | 'repeat_push'
interface FinanceBillRow {
  id: number
  operationTime: string
  operatorName: string
  codeResult: CodeResultType
  returnResult: string
}
const dialog = useDialog()
const message = useMessage()
const loading = ref(false)
const listData = ref<FinanceBillRow[]>([])
const total = ref(0)
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const showOperationLogModal = ref(false)
const showDetailModal = ref(false)
const detailLoading = ref(false)
const detailData = ref<FinanceBillRow | null>(null)
const codeResultConfigMap: Record<CodeResultType, { label: string, type: 'success' | 'error' | 'warning' }> = {
  success: { label: '接收成功', type: 'success' },
  param_error: { label: '参数错误', type: 'error' },
  repeat_push: { label: '重复事件', type: 'warning' },
}
const columns = computed<DataTableColumns<FinanceBillRow>>(() => [
  {
    title: '操作时间',
    key: 'operationTime',
    width: 180,
    render: row => row.operationTime ? dayjs(row.operationTime).format('YYYY/MM/DD HH:mm:ss') : '-',
  },
  {
    title: '操作人',
    key: 'operatorName',
    width: 140,
    ellipsis: true,
    render: row => row.operatorName || '-',
  },
  {
    title: 'code结果',
    key: 'codeResult',
    width: 140,
    render: (row) => {
      const config = codeResultConfigMap[row.codeResult]
      return (
        <NTag bordered={false} type={config?.type || 'default'}>
          {config?.label || '-'}
        </NTag>
      )
    },
  },
  {
    title: '返回结果',
    key: 'returnResult',
    minWidth: 420,
    ellipsis: {
      tooltip: true,
    },
    render: row => row.returnResult || '-',
  },
  {
    title: '操作',
    key: 'actions',
    width: 100,
    fixed: 'right',
    render: row => (
      <NButton text type="primary" onClick={() => handleOpenDetail(row.id)}>
        详情
      </NButton>
    ),
  },
])
async function getList() {
  loading.value = true
  try {
    const res = await fetchFinanceBillPage(pageParams.value)
    if (res.code === 200 && res.data) {
      listData.value = res.data.records || []
      total.value = res.data.total || 0
    }
  }
  finally {
    loading.value = false
  }
}
async function handleOpenDetail(id: number) {
  detailLoading.value = true
  showDetailModal.value = true
  try {
    const res = await fetchFinanceBillDetail(id)
    if (res.code === 200) {
      detailData.value = res.data
    }
  }
  finally {
    detailLoading.value = false
  }
}
function handleOpenOperationLog() {
  showOperationLogModal.value = true
}
function handleManualPush() {
  dialog.warning({
    title: '确认手动推送',
    content: '确认立即执行财务账单手动推送吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: async () => {
      const res = await manualPushFinanceBill()
      if (res.code === 200) {
        message.success(res.msg || '手动推送成功')
        getList()
      }
    },
  })
}
function handlePageChange(page: number, pageSize: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = pageSize
  getList()
}
function handleCloseDetailModal() {
  showDetailModal.value = false
  detailData.value = null
}
onMounted(() => {
  getList()
})
</script>
<template>
  <div class="finance-bill-page roboTaxi">
    <n-card class="flex-1 robotaxi-table-card" :bordered="false">
      <template #header>
        <div class="flex items-center justify-between">
          <NButton @click="handleOpenOperationLog">
            操作日志
          </NButton>
          <NButton type="primary" secondary @click="handleManualPush">
            手动推送
          </NButton>
        </div>
      </template>
      <div class="flex-1 flex flex-col h-full">
        <n-data-table
          class="flex-1"
          :columns="columns"
          :data="listData"
          :loading="loading"
          :bordered="false"
          :row-key="(row: FinanceBillRow) => row.id"
          flex-height
          :scroll-x="980"
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="total"
          :page="pageParams.pageNum"
          :page-size="pageParams.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="handlePageChange"
        />
      </div>
    </n-card>
    <OperationLogModal
      v-model:show="showOperationLogModal"
      title="操作日志"
      :show-export="false"
      :operation-type-options="[
        { label: '手动推送', value: 1 },
        { label: '查看详情', value: 2 },
      ]"
      :request-list="fetchFinanceBillOperationLogPage"
    />
    <n-modal
      v-model:show="showDetailModal" :mask-closable="false" preset="card" title="推送详情" class="w-400px"
      :auto-focus="false" :segmented="{
        content: true,
        action: true,
      }"
      @update:show="(show) => !show && handleCloseDetailModal()"
    >
      <n-spin :show="detailLoading">
        <div class="detail-content">
          <div class="detail-section">
            <div class="detail-label">
              操作人
            </div>
            <div class="detail-value detail-value--strong">
              {{ detailData?.operatorName || '-' }}
            </div>
          </div>
          <div class="detail-section">
            <div class="detail-label">
              操作时间
            </div>
            <div class="detail-value">
              {{ detailData?.operationTime ? dayjs(detailData.operationTime).format('YYYY/MM/DD HH:mm:ss') : '-' }}
            </div>
          </div>
          <div class="detail-section">
            <div class="detail-label">
              code结果
            </div>
            <div class="detail-value">
              <NTag v-if="detailData" :bordered="false" :type="codeResultConfigMap[detailData.codeResult]?.type || 'default'">
                {{ codeResultConfigMap[detailData.codeResult]?.label || '-' }}
              </NTag>
              <span v-else>-</span>
            </div>
          </div>
          <div class="detail-section detail-section--result">
            <div class="detail-label">
              返回结果
            </div>
            <div class="detail-value detail-value--multiline">
              {{ detailData?.returnResult || '-' }}
            </div>
          </div>
        </div>
      </n-spin>
    </n-modal>
  </div>
</template>
<style scoped lang="scss">
.finance-bill-page {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 12px;
  box-sizing: border-box;
}
.detail-content {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.detail-section {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.detail-section--result {
  gap: 10px;
}
.detail-label {
  font-size: 14px;
  line-height: 20px;
  color: rgba(23, 31, 38, 0.6);
}
.detail-value {
  font-size: 14px;
  line-height: 22px;
  color: #171f26;
  word-break: break-all;
}
.detail-value--strong {
  font-weight: 500;
}
.detail-value--multiline {
  white-space: pre-wrap;
}
</style>
src/views/testDemo/testPlanList/detail.vue
@@ -735,7 +735,13 @@
                :scroll-x="1100"
                :header-height="18"
                :row-height="18"
              />
              >
                <template #empty>
                  <div class="flex-center">
                    <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
                  </div>
                </template>
              </n-data-table>
              <Pagination
                :count="recordTotal"
                :page="recordCurrentPage"
src/views/testDemo/testPointManagement/list.vue
@@ -605,7 +605,13 @@
          :scroll-x="tableScrollX"
          :row-key="(row) => row.id"
          @update:checked-row-keys="handleCheck"
        />
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="pagination.itemCount"
          :page="pagination.page"
src/views/transaction/directServiceManagement/detail.vue
@@ -201,7 +201,13 @@
          :data="selectedVehicles"
          :bordered="false"
          :single-line="false"
        />
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </NDataTable>
      </div>
      <!-- 服务用户卡片 -->
@@ -214,7 +220,13 @@
          :data="selectedUsers"
          :bordered="false"
          :single-line="false"
        />
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </NDataTable>
      </div>
    </div>
  </div>
src/views/transaction/pricingManagement/detail.vue
@@ -1105,7 +1105,13 @@
                    :columns="weatherColumns"
                    :data="model.dynamicPricingJson.weatherRules"
                    :bordered="false"
                  />
                  >
                    <template #empty>
                      <div class="flex-center">
                        <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
                      </div>
                    </template>
                  </n-data-table>
                </div>
              </div>
            </NSpace>
@@ -1419,7 +1425,13 @@
                      :columns="weatherViewColumns"
                      :data="model.dynamicPricingJson.weatherRules"
                      :bordered="false"
                    />
                    >
                      <template #empty>
                        <div class="flex-center">
                          <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
                        </div>
                      </template>
                    </n-data-table>
                    <div v-else>
                      暂无配置
                    </div>
src/views/userContent/active/activityManagement/components/ActivityOrderList.vue
@@ -601,7 +601,13 @@
          :row-key="(row) => row.id"
          flex-height
          @update:checked-row-keys="handleCheck"
        />
        >
          <template #empty>
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </NDataTable>
        <Pagination
          :count="total"
          :page="currentPage"
src/views/userContent/contentCenter/components/AgreementListForm.vue
@@ -355,7 +355,13 @@
      :row-key="row => row.id"
      :row-props="getRowProps"
      class="custom-table"
    />
    >
      <template #empty>
        <div class="flex-center">
          <img src="/src/assets/element-img/no-data.png" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
        </div>
      </template>
    </n-data-table>
    <n-modal
      v-model:show="showModal"