Quellcode durchsuchen

feat: 更新财务报表功能

叶静 vor 6 Tagen
Ursprung
Commit
e9465a7ed7

+ 13 - 0
src/app/shop/admin/finance/finance.service.js

@@ -173,6 +173,14 @@ const route = {
         title: '提现管理',
       },
     },
+    {
+      path: 'report',
+      name: 'shop.admin.finance.report',
+      component: () => import('@/app/shop/admin/finance/report/index.vue'),
+      meta: {
+        title: '财务报表',
+      },
+    },
   ],
 };
 
@@ -231,6 +239,11 @@ const api = {
       data: null,
     }),
   },
+
+  // 财务报表相关 API
+  report: {
+    ...CRUD('/cif/subject', ['list', 'export'], { exportMethod: 'GET' }),
+  },
 };
 
 export { route, api, financeConfig, financeUtils };

+ 412 - 0
src/app/shop/admin/finance/report/index.vue

@@ -0,0 +1,412 @@
+<template>
+  <el-container class="report-view panel-block">
+    <el-header class="sa-header">
+      <!-- 简化搜索组件 -->
+      <div class="search-container">
+        <sa-search-simple
+          :searchFields="searchFields"
+          :defaultValues="defaultSearchValues"
+          @search="(val) => getData(1, val)"
+          @reset="refreshData"
+        >
+          <template #custom="{ data }">
+            <el-form-item label="">
+              <el-date-picker
+                v-model="data.createTime"
+                type="daterange"
+                value-format="YYYY-MM-DD"
+                format="YYYY-MM-DD"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                :editable="false"
+                :disabled-date="disabledDate"
+                style="width: 300px"
+              />
+            </el-form-item>
+          </template>
+        </sa-search-simple>
+      </div>
+      <div class="sa-title sa-flex sa-row-between">
+        <div class="label sa-flex">财务报表</div>
+        <div>
+          <el-button class="sa-button-refresh" icon="RefreshRight" @click="refreshData"></el-button>
+          <el-button
+            icon="Download"
+            type="primary"
+            :loading="exportLoading"
+            :disabled="exportLoading"
+            @click="exportReport"
+          >
+            {{ exportLoading ? '导出中...' : '导出报表' }}
+          </el-button>
+        </div>
+      </div>
+    </el-header>
+    <el-main class="sa-p-0">
+      <div class="sa-table-wrap panel-block panel-block--bottom" v-loading="loading">
+        <el-table
+          height="100%"
+          class="sa-table"
+          :data="flatTableData"
+          @sort-change="fieldFilter"
+          stripe
+          border
+        >
+          <template #empty>
+            <sa-empty />
+          </template>
+          <el-table-column label="序号" width="120" align="left">
+            <template #default="scope">
+              <div class="subject-id-cell" :style="{ paddingLeft: scope.row.level * 20 + 'px' }">
+                <!-- 展开/收起按钮 -->
+                <el-icon
+                  v-if="scope.row.isParent"
+                  class="expand-icon"
+                  @click="toggleExpand(scope.row)"
+                >
+                  <ArrowRight v-if="!expandedRows.has(scope.row.subjectId)" />
+                  <ArrowDown v-else />
+                </el-icon>
+                <span v-else class="expand-placeholder"></span>
+                <span class="subject-id">
+                  {{ scope.row.subjectId }}
+                </span>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column prop="name" label="科目名称" min-width="200" sortable="custom">
+            <template #default="scope">
+              <span class="sa-table-line-1">
+                {{ scope.row.name || '-' }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column
+            prop="aliases"
+            label="别名"
+            min-width="100"
+            align="center"
+            sortable="custom"
+          >
+            <template #default="scope">
+              <span>{{ scope.row.aliases || '-' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="direction" label="余额方向" min-width="100" align="center">
+            <template #default="scope">
+              <el-tag :type="getDirectionType(scope.row.direction)" size="small">
+                {{ getDirectionText(scope.row.direction) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="initAmount" label="初始日余额" min-width="120" align="right">
+            <template #default="scope">
+              <span class="amount-text">{{ formatAmount(scope.row.initAmount) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="debitAmount" label="借方发生额" min-width="120" align="right">
+            <template #default="scope">
+              <span class="amount-text debit-amount">{{
+                formatAmount(scope.row.debitAmount)
+              }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="creditAmount" label="贷方发生额" min-width="120" align="right">
+            <template #default="scope">
+              <span class="amount-text credit-amount">{{
+                formatAmount(scope.row.creditAmount)
+              }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="endAmount" label="日余额" min-width="120" align="right">
+            <template #default="scope">
+              <span class="amount-text end-amount">{{ formatAmount(scope.row.endAmount) }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-main>
+    <sa-view-bar>
+      <!-- 移除统计信息 -->
+    </sa-view-bar>
+  </el-container>
+</template>
+
+<script setup>
+  import { onMounted, reactive, ref, computed } from 'vue';
+  import { ArrowRight, ArrowDown } from '@element-plus/icons-vue';
+  import { api } from '../finance.service';
+
+  // 当前搜索条件
+  const currentSearchParams = ref({});
+
+  // 导出loading状态
+  const exportLoading = ref(false);
+
+  // 列表
+  const table = reactive({
+    data: [],
+    order: '',
+    sort: '',
+    selected: [],
+  });
+  const loading = ref(true);
+
+  // 搜索字段配置
+  const searchFields = reactive({
+    createTime: {
+      type: 'custom', // 标记为自定义字段
+      label: '时间范围',
+    },
+  });
+
+  // 获取默认时间范围(昨天一天)
+  const getDefaultDateRange = () => {
+    const yesterday = new Date();
+    yesterday.setDate(yesterday.getDate() - 1); // 昨天的日期
+    const yesterdayStr = yesterday.toISOString().split('T')[0]; // YYYY-MM-DD
+    return [
+      yesterdayStr, // 开始日期:昨天
+      yesterdayStr, // 结束日期:昨天
+    ];
+  };
+
+  // 禁用日期函数 - 禁用今天及以后的日期
+  const disabledDate = (time) => {
+    const today = new Date();
+    today.setHours(0, 0, 0, 0); // 设置为今天的开始时间
+    return time.getTime() >= today.getTime(); // 禁用今天及以后的日期
+  };
+
+  // 默认搜索值
+  const defaultSearchValues = reactive({
+    createTime: getDefaultDateRange(),
+  });
+
+  // 展开状态管理
+  const expandedRows = ref(new Set());
+
+  // 切换展开状态
+  const toggleExpand = (row) => {
+    if (expandedRows.value.has(row.subjectId)) {
+      expandedRows.value.delete(row.subjectId);
+    } else {
+      expandedRows.value.add(row.subjectId);
+    }
+  };
+
+  // 扁平化数据处理 - 根据展开状态决定是否显示子科目
+  const flattenData = (data, level = 0) => {
+    const result = [];
+    data.forEach((item) => {
+      // 添加当前项,标记层级
+      result.push({
+        ...item,
+        level: level,
+        isParent: !!(item.children && item.children.length > 0),
+      });
+
+      // 如果有子项且当前项已展开,则显示子项(默认展开所有主科目)
+      if (item.children && item.children.length > 0) {
+        // 如果是主科目(level 0)且不在展开列表中,默认展开
+        if (level === 0 && !expandedRows.value.has(item.subjectId)) {
+          expandedRows.value.add(item.subjectId);
+        }
+
+        if (expandedRows.value.has(item.subjectId)) {
+          result.push(...flattenData(item.children, level + 1));
+        }
+      }
+    });
+    return result;
+  };
+
+  // 计算属性 - 扁平化的表格数据
+  const flatTableData = computed(() => {
+    return flattenData(table.data);
+  });
+
+  // 工具函数
+  function getDirectionText(direction) {
+    const directionMap = {
+      D: '借',
+      C: '贷',
+    };
+    return directionMap[direction] || '未知';
+  }
+
+  function getDirectionType(direction) {
+    const typeMap = {
+      D: 'success', // 借方用绿色
+      C: 'warning', // 贷方用橙色
+    };
+    return typeMap[direction] || 'info';
+  }
+
+  function formatAmount(amount) {
+    if (amount === null || amount === undefined) return '0.00';
+    return Number(amount).toLocaleString('zh-CN', {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 2,
+    });
+  }
+
+  // 刷新数据 - 使用默认时间范围
+  const refreshData = () => {
+    // 重置搜索参数为默认值
+    currentSearchParams.value = { ...defaultSearchValues };
+    // 使用默认时间范围刷新数据
+    getData(1, defaultSearchValues);
+  };
+
+  // table 字段排序
+  function fieldFilter({ prop, order }) {
+    table.order = order == 'ascending' ? 'asc' : 'desc';
+    table.sort = prop;
+    getData();
+  }
+
+  // 获取数据
+  async function getData(page = 1, searchParams = {}) {
+    // 保存搜索条件供导出使用
+    if (Object.keys(searchParams).length > 0) {
+      currentSearchParams.value = { ...searchParams };
+    }
+    loading.value = true;
+
+    try {
+      // 构建请求参数
+      const params = {
+        ...searchParams,
+      };
+
+      // 添加排序参数
+      if (table.order && table.sort) {
+        params.order = table.order;
+        params.sort = table.sort;
+      }
+
+      // 处理时间范围搜索,直接使用年月日格式
+      if (searchParams.createTime && searchParams.createTime.length === 2) {
+        params.startTime = searchParams.createTime[0];
+        params.endTime = searchParams.createTime[1];
+        delete params.createTime;
+      }
+
+      // 调用API
+      const { code, data } = await api.report.list(params);
+
+      if (code == '200') {
+        table.data = data || [];
+      }
+    } catch (error) {
+      table.data = [];
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  // 导出报表
+  async function exportReport() {
+    if (exportLoading.value) return; // 防止重复点击
+
+    exportLoading.value = true;
+    try {
+      // 构建导出参数,与列表请求参数保持一致,但排除分页相关数据
+      const exportParams = {
+        ...currentSearchParams.value, // 当前搜索参数
+      };
+
+      // 处理时间范围搜索(与 getData 函数保持一致),直接使用年月日格式
+      if (exportParams.createTime && exportParams.createTime.length === 2) {
+        exportParams.startTime = exportParams.createTime[0];
+        exportParams.endTime = exportParams.createTime[1];
+        delete exportParams.createTime;
+      }
+
+      // 生成包含时间范围的文件名作为兜底方案
+      let fileName = '财务报表';
+      if (exportParams.startTime && exportParams.endTime) {
+        if (exportParams.startTime === exportParams.endTime) {
+          // 单日报表
+          fileName = `财务报表_${exportParams.startTime}`;
+        } else {
+          // 时间范围报表
+          fileName = `财务报表_${exportParams.startTime}_至_${exportParams.endTime}`;
+        }
+      }
+
+      // 调用导出API,优先使用服务器返回的文件名,备用使用生成的文件名
+      await api.report.export(exportParams, fileName);
+    } finally {
+      exportLoading.value = false;
+    }
+  }
+
+  onMounted(() => {
+    // 设置当前搜索参数为默认值,确保搜索组件显示默认时间
+    currentSearchParams.value = { ...defaultSearchValues };
+    // 使用默认时间范围进行初始查询
+    getData(1, defaultSearchValues);
+  });
+</script>
+
+<style scoped>
+  /* 表格样式优化 */
+  :deep(.el-table) {
+    .el-table__row {
+      /* 主科目行样式 */
+      &[data-level='0'] {
+        background-color: #fafafa;
+        font-weight: 500;
+      }
+
+      /* 子科目行样式 */
+      &[data-level='1'] {
+        background-color: #ffffff;
+      }
+    }
+  }
+
+  /* 序号单元格样式 */
+  .subject-id-cell {
+    display: flex;
+    align-items: center;
+  }
+
+  /* 展开按钮样式 */
+  .expand-icon {
+    cursor: pointer;
+    margin-right: 8px;
+    width: 16px;
+    height: 16px;
+    color: #606266;
+    transition: color 0.3s;
+    flex-shrink: 0;
+  }
+
+  .expand-icon:hover {
+    color: #409eff;
+  }
+
+  /* 展开按钮占位符 */
+  .expand-placeholder {
+    width: 24px;
+    height: 16px;
+    margin-right: 8px;
+  }
+
+  /* 序号样式 */
+  .subject-id {
+    flex: 1;
+    font-weight: 500;
+  }
+
+  /* 为子科目序号添加连接线效果 */
+  .subject-id-cell[style*='padding-left: 20px'] .subject-id::before {
+    content: '├─';
+    color: #dcdfe6;
+    margin-right: 4px;
+  }
+</style>

+ 56 - 7
src/sheep/request/crud.js

@@ -96,7 +96,11 @@ export const RESTORE = (url, id) =>
   });
 
 // 通用增删改查
-export const CRUD = (url, methods = ['list', 'detail', 'add', 'edit', 'delete', 'export']) => {
+export const CRUD = (
+  url,
+  methods = ['list', 'detail', 'add', 'edit', 'delete', 'export'],
+  options = {},
+) => {
   const apis = {};
   if (methods.includes('list'))
     apis.list = (params, pageInParams = true) => LIST(url, params, pageInParams);
@@ -104,9 +108,12 @@ export const CRUD = (url, methods = ['list', 'detail', 'add', 'edit', 'delete',
   if (methods.includes('add')) apis.add = (data) => ADD(url, data);
   if (methods.includes('edit')) apis.edit = (id, data) => EDIT(url, id, data);
   if (methods.includes('delete')) apis.delete = (id) => DELETE(url, id);
-  if (methods.includes('report')) apis.report = (params, filename) => REPORT(url, params, filename);
+  if (methods.includes('report'))
+    apis.report = (params, filename) =>
+      REPORT(url, params, filename, 'report', options.reportMethod);
   if (methods.includes('export'))
-    apis.export = (params, filename) => REPORT(url, params, filename, 'export');
+    apis.export = (params, filename) =>
+      REPORT(url, params, filename, 'export', options.exportMethod);
   return apis;
 };
 
@@ -120,7 +127,13 @@ export const RECYCLE = (url) => {
 };
 
 // 导出报表 - 需要超级管理员权限
-export const REPORT = async (url, params, filename = '导出数据', typeName = 'report') => {
+export const REPORT = async (
+  url,
+  params,
+  filename = '导出数据',
+  typeName = 'report',
+  method = null,
+) => {
   // 获取用户信息进行权限验证
   const userInfo = $storage.get('userInfo');
 
@@ -129,14 +142,22 @@ export const REPORT = async (url, params, filename = '导出数据', typeName =
     ElMessage.error('只有超级管理员可以导出');
     return Promise.reject(new Error('权限不足:只有超级管理员可以导出'));
   }
+
+  // 确定HTTP方法:优先使用传入的method参数,否则使用默认逻辑
+  const httpMethod = method || (typeName == 'report' ? 'GET' : 'POST');
+
   let response = null;
   try {
     // 发送导出请求
     response = await request({
       url: url + `/${typeName}`,
-      method: typeName == 'report' ? 'GET' : 'POST',
-      ...(typeName == 'report' ? { params } : { data: params }),
+      method: httpMethod,
+      ...(httpMethod === 'GET' ? { params } : { data: params }),
       responseType: 'blob', // 用于文件下载
+      headers: {
+        Accept:
+          'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/octet-stream, */*',
+      },
       options: {
         showSuccessMessage: false,
       },
@@ -149,7 +170,35 @@ export const REPORT = async (url, params, filename = '导出数据', typeName =
       const downloadUrl = window.URL.createObjectURL(blob);
       const link = document.createElement('a');
       link.href = downloadUrl;
-      link.download = `${filename}_${new Date().toISOString().slice(0, 10)}.xlsx`;
+
+      // 优先从响应头的 content-disposition 中获取文件名
+      let finalFilename = `${filename}_${new Date().toISOString().slice(0, 10)}.xlsx`;
+
+      // 尝试多种方式获取 content-disposition
+      const contentDisposition =
+        response.headers['content-disposition'] ||
+        response.headers['Content-Disposition'] ||
+        response.headers.get?.('content-disposition') ||
+        response.headers.get?.('Content-Disposition');
+
+      if (contentDisposition) {
+        // 尝试从 content-disposition 中提取文件名
+        const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
+        if (filenameMatch && filenameMatch[1]) {
+          let extractedFilename = filenameMatch[1].replace(/['"]/g, '');
+          // 处理 URL 编码的文件名
+          try {
+            extractedFilename = decodeURIComponent(extractedFilename);
+          } catch (e) {
+            // 如果解码失败,使用原始文件名
+          }
+          if (extractedFilename) {
+            finalFilename = extractedFilename;
+          }
+        }
+      }
+
+      link.download = finalFilename;
       document.body.appendChild(link);
       link.click();
       document.body.removeChild(link);