ReportCenterView.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <template>
  2. <div class="app-page">
  3. <!-- 页面头部 -->
  4. <section class="glass-card page-hero">
  5. <div class="page-hero__meta">
  6. <span class="page-hero__eyebrow">BI Center</span>
  7. <h1>报表中心</h1>
  8. <p>当前把报表目录、筛选条件和导出动作先放进统一框架,适合后续接真实图表和异步导出能力。</p>
  9. </div>
  10. <div class="chip-list">
  11. <span class="chip">支持异步导出</span>
  12. <span class="chip">可保存常用视图</span>
  13. </div>
  14. </section>
  15. <!-- 报表类型与筛选 -->
  16. <section class="glass-card section-card">
  17. <div class="table-toolbar">
  18. <el-form inline class="filter-form">
  19. <el-form-item label="报表类型">
  20. <el-select v-model="reportType" placeholder="请选择报表类型" style="width:170px" @change="onReportTypeChange">
  21. <el-option label="渠道报表" value="channel" />
  22. <el-option label="SKU 报表" value="sku" />
  23. <el-option label="履约报表" value="fulfillment" />
  24. <el-option label="自定义报表" value="custom" />
  25. </el-select>
  26. </el-form-item>
  27. <!-- 时间范围:所有类型都有 -->
  28. <el-form-item label="时间范围">
  29. <el-date-picker
  30. v-model="filters.timeRange"
  31. type="daterange"
  32. start-placeholder="开始日期"
  33. end-placeholder="结束日期"
  34. style="width:240px"
  35. />
  36. </el-form-item>
  37. <!-- 渠道报表筛选 -->
  38. <template v-if="reportType === 'channel'">
  39. <el-form-item label="渠道">
  40. <el-select v-model="filters.channel" placeholder="全部渠道" clearable style="width:140px">
  41. <el-option label="Shopify US" value="Shopify US" />
  42. <el-option label="Shopify JP" value="Shopify JP" />
  43. <el-option label="TikTok UK" value="TikTok UK" />
  44. </el-select>
  45. </el-form-item>
  46. <el-form-item label="店铺">
  47. <el-select v-model="filters.shop" placeholder="全部店铺" clearable style="width:140px">
  48. <el-option label="旗舰店" value="旗舰店" />
  49. <el-option label="折扣店" value="折扣店" />
  50. </el-select>
  51. </el-form-item>
  52. </template>
  53. <!-- SKU 报表筛选 -->
  54. <template v-if="reportType === 'sku'">
  55. <el-form-item label="SKU">
  56. <el-input v-model="filters.sku" placeholder="请输入 SKU" clearable style="width:160px" />
  57. </el-form-item>
  58. <el-form-item label="供应商">
  59. <el-select v-model="filters.supplier" placeholder="全部供应商" clearable style="width:140px">
  60. <el-option label="供应商 A" value="供应商 A" />
  61. <el-option label="供应商 B" value="供应商 B" />
  62. </el-select>
  63. </el-form-item>
  64. </template>
  65. <!-- 履约报表筛选 -->
  66. <template v-if="reportType === 'fulfillment'">
  67. <el-form-item label="仓库">
  68. <el-select v-model="filters.warehouse" placeholder="全部仓库" clearable style="width:140px">
  69. <el-option label="华东仓" value="华东仓" />
  70. <el-option label="华南仓" value="华南仓" />
  71. <el-option label="海外仓-US" value="海外仓-US" />
  72. </el-select>
  73. </el-form-item>
  74. <el-form-item label="国家">
  75. <el-select v-model="filters.country" placeholder="全部国家" clearable style="width:140px">
  76. <el-option label="美国" value="US" />
  77. <el-option label="日本" value="JP" />
  78. <el-option label="英国" value="UK" />
  79. </el-select>
  80. </el-form-item>
  81. </template>
  82. </el-form>
  83. <div class="chip-list">
  84. <el-button type="primary" @click="generateReport">生成报表</el-button>
  85. <el-button @click="doExport('excel')">导出 Excel</el-button>
  86. <el-button @click="doExport('csv')">导出 CSV</el-button>
  87. <el-button @click="savePersonalView">保存视图</el-button>
  88. </div>
  89. </div>
  90. </section>
  91. <!-- 已保存视图 -->
  92. <section v-if="savedViews.length" class="glass-card section-card" style="padding:12px 24px">
  93. <div class="chip-list" style="margin-bottom:0">
  94. <span style="color:var(--cb-text-soft);font-size:13px;margin-right:8px">我的视图:</span>
  95. <el-tag
  96. v-for="(view, idx) in savedViews"
  97. :key="idx"
  98. closable
  99. size="large"
  100. @close="removeView(idx)"
  101. @click="applyView(view)"
  102. style="cursor:pointer"
  103. >
  104. {{ view.name }}
  105. </el-tag>
  106. </div>
  107. </section>
  108. <!-- 报表数据 -->
  109. <section class="glass-card section-card" v-if="reportData.length">
  110. <div class="section-card__title" style="margin-bottom:16px">
  111. <div>
  112. <h3>{{ reportTypeLabel }} — 报表数据</h3>
  113. <p>共 {{ reportData.length }} 条指标</p>
  114. </div>
  115. </div>
  116. <el-table :data="reportData" stripe style="width:100%" v-loading="loading">
  117. <el-table-column prop="metric" label="指标名称" min-width="200" />
  118. <el-table-column prop="value" label="数值" width="160" />
  119. <el-table-column prop="mom" label="环比" width="120">
  120. <template #default="{ row }">
  121. <span :class="trendClass(row.mom)">{{ row.mom }}</span>
  122. </template>
  123. </el-table-column>
  124. <el-table-column prop="yoy" label="同比" width="120">
  125. <template #default="{ row }">
  126. <span :class="trendClass(row.yoy)">{{ row.yoy }}</span>
  127. </template>
  128. </el-table-column>
  129. <el-table-column prop="dimension" label="维度分组" min-width="200" />
  130. <template #empty>
  131. <el-empty description="暂无数据" />
  132. </template>
  133. </el-table>
  134. <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="reportDataTotal > 0">
  135. <el-pagination v-model:current-page="reportDataPage" v-model:page-size="reportDataPageSize" :total="reportDataTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="generateReport" @current-change="generateReport" />
  136. </div>
  137. </section>
  138. <!-- 图表区 -->
  139. <section v-if="reportData.length" class="page-grid page-grid--two">
  140. <article class="glass-card section-card">
  141. <h3 style="margin:0 0 16px">{{ reportTypeLabel }} — 指标对比</h3>
  142. <v-chart :option="barOption" autoresize style="height:300px" />
  143. </article>
  144. <article class="glass-card section-card">
  145. <h3 style="margin:0 0 16px">{{ reportTypeLabel }} — 环比趋势</h3>
  146. <v-chart :option="lineOption" autoresize style="height:300px" />
  147. </article>
  148. </section>
  149. <!-- 报表目录 -->
  150. <section class="glass-card section-card">
  151. <div class="section-card__title" style="margin-bottom:16px">
  152. <div>
  153. <h3>报表目录</h3>
  154. <p>已保存的报表模板列表。</p>
  155. </div>
  156. </div>
  157. <el-table :data="reportList" stripe style="width:100%" v-loading="loading">
  158. <el-table-column prop="reportType" label="报表类型" width="160" />
  159. <el-table-column prop="dimensions" label="维度" min-width="250" />
  160. <el-table-column prop="owner" label="使用角色" width="140" />
  161. <el-table-column prop="updatedAt" label="最近更新时间" width="170" />
  162. <el-table-column label="操作" width="120">
  163. <template #default="{ row }">
  164. <el-button link type="primary" @click="loadSavedReport(row)">查看</el-button>
  165. </template>
  166. </el-table-column>
  167. <template #empty>
  168. <el-empty description="暂无数据" />
  169. </template>
  170. </el-table>
  171. <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="reportListTotal > 0">
  172. <el-pagination v-model:current-page="reportListPage" v-model:page-size="reportListPageSize" :total="reportListTotal" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
  173. </div>
  174. </section>
  175. <!-- 保存视图对话框 -->
  176. <el-dialog v-model="saveViewVisible" title="保存个人视图" width="400px" destroy-on-close>
  177. <el-form :model="viewForm" label-width="80px">
  178. <el-form-item label="视图名称">
  179. <el-input v-model="viewForm.name" placeholder="请输入视图名称" />
  180. </el-form-item>
  181. </el-form>
  182. <template #footer>
  183. <el-button @click="saveViewVisible = false">取消</el-button>
  184. <el-button type="primary" @click="confirmSaveView">保存</el-button>
  185. </template>
  186. </el-dialog>
  187. </div>
  188. </template>
  189. <script setup lang="ts">
  190. import { computed, onMounted, ref, reactive } from 'vue';
  191. import { ElMessage } from 'element-plus';
  192. import VChart from 'vue-echarts';
  193. import { use } from 'echarts/core';
  194. import { CanvasRenderer } from 'echarts/renderers';
  195. import { LineChart, BarChart } from 'echarts/charts';
  196. import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
  197. import { api } from '@/api/services';
  198. import type { ReportItem, ReportDataItem } from '@/types/page';
  199. use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent]);
  200. const reportType = ref('channel');
  201. const reportList = ref<ReportItem[]>([]);
  202. const reportData = ref<ReportDataItem[]>([]);
  203. const loading = ref(false);
  204. const reportDataPage = ref(1);
  205. const reportDataPageSize = ref(10);
  206. const reportDataTotal = computed(() => reportData.value.length);
  207. const reportListPage = ref(1);
  208. const reportListPageSize = ref(10);
  209. const reportListTotal = computed(() => reportList.value.length);
  210. const filters = ref({
  211. timeRange: null as [Date, Date] | null,
  212. channel: '',
  213. shop: '',
  214. sku: '',
  215. supplier: '',
  216. warehouse: '',
  217. country: ''
  218. });
  219. interface SavedView {
  220. name: string;
  221. reportType: string;
  222. filters: typeof filters.value;
  223. }
  224. const savedViews = ref<SavedView[]>([]);
  225. const saveViewVisible = ref(false);
  226. const viewForm = reactive({ name: '' });
  227. const reportTypeLabel = computed(() => {
  228. const map: Record<string, string> = {
  229. channel: '渠道报表',
  230. sku: 'SKU 报表',
  231. fulfillment: '履约报表',
  232. custom: '自定义报表'
  233. };
  234. return map[reportType.value] || '报表';
  235. });
  236. const trendClass = (val: string) => {
  237. if (!val) return '';
  238. if (val.startsWith('+') || val.startsWith('↑')) return 'trend-up';
  239. if (val.startsWith('-') || val.startsWith('↓')) return 'trend-down';
  240. return '';
  241. };
  242. const barOption = computed(() => {
  243. const metrics = reportData.value.map(r => r.metric);
  244. const values = reportData.value.map(r => parseFloat(r.value.replace(/[^0-9.]/g, '')) || 0);
  245. return {
  246. tooltip: { trigger: 'axis' as const },
  247. grid: { left: 80, right: 20, top: 20, bottom: 60 },
  248. xAxis: { type: 'category' as const, data: metrics, axisLabel: { rotate: 30 } },
  249. yAxis: { type: 'value' as const },
  250. series: [{ type: 'bar', data: values, itemStyle: { color: '#409EFF', borderRadius: [4, 4, 0, 0] } }]
  251. };
  252. });
  253. const lineOption = computed(() => {
  254. const metrics = reportData.value.map(r => r.metric);
  255. const momValues = reportData.value.map(r => {
  256. const m = r.mom.match(/[\d.]+/);
  257. const sign = r.mom.startsWith('-') || r.mom.startsWith('↓') ? -1 : 1;
  258. return m ? sign * parseFloat(m[0]) : 0;
  259. });
  260. return {
  261. tooltip: { trigger: 'axis' as const },
  262. grid: { left: 60, right: 20, top: 20, bottom: 60 },
  263. xAxis: { type: 'category' as const, data: metrics, axisLabel: { rotate: 30 } },
  264. yAxis: { type: 'value' as const, axisLabel: { formatter: '{value}%' } },
  265. series: [{
  266. type: 'line', smooth: true, data: momValues,
  267. areaStyle: { opacity: 0.15 },
  268. itemStyle: { color: '#67C23A' }
  269. }]
  270. };
  271. });
  272. const loadData = async () => {
  273. loading.value = true;
  274. try {
  275. const res = await api.getReports();
  276. reportList.value = res.items;
  277. } finally {
  278. loading.value = false;
  279. }
  280. };
  281. const onReportTypeChange = () => {
  282. reportData.value = [];
  283. };
  284. const generateReport = async () => {
  285. loading.value = true;
  286. try {
  287. const res = await api.getReportData();
  288. reportData.value = res.items;
  289. ElMessage.success('报表数据已加载');
  290. } finally {
  291. loading.value = false;
  292. }
  293. };
  294. const loadSavedReport = async (item: ReportItem) => {
  295. reportType.value = item.reportType === '渠道销售日报' ? 'channel' : 'channel';
  296. loading.value = true;
  297. try {
  298. const res = await api.getReportData();
  299. reportData.value = res.items;
  300. ElMessage.success(`已加载「${item.reportType}」`);
  301. } finally {
  302. loading.value = false;
  303. }
  304. };
  305. const doExport = (format: string) => {
  306. if (reportData.value.length === 0) {
  307. ElMessage.warning('请先生成报表数据');
  308. return;
  309. }
  310. ElMessage.info(`正在导出 ${format.toUpperCase()} 文件,完成后自动下载`);
  311. };
  312. const savePersonalView = () => {
  313. viewForm.name = '';
  314. saveViewVisible.value = true;
  315. };
  316. const confirmSaveView = () => {
  317. if (!viewForm.name.trim()) {
  318. ElMessage.warning('请输入视图名称');
  319. return;
  320. }
  321. savedViews.value.push({
  322. name: viewForm.name,
  323. reportType: reportType.value,
  324. filters: { ...filters.value }
  325. });
  326. saveViewVisible.value = false;
  327. ElMessage.success('视图已保存');
  328. };
  329. const applyView = (view: SavedView) => {
  330. reportType.value = view.reportType;
  331. Object.assign(filters.value, view.filters);
  332. generateReport();
  333. };
  334. const removeView = (idx: number) => {
  335. savedViews.value.splice(idx, 1);
  336. };
  337. onMounted(loadData);
  338. </script>
  339. <style scoped>
  340. .filter-form :deep(.el-form-item) { margin-bottom: 0; }
  341. .trend-up { color: var(--el-color-success); font-weight: 500; }
  342. .trend-down { color: var(--el-color-danger); font-weight: 500; }
  343. </style>