Digital Office Automation System
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ShopAnalysis.vue 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. <template>
  2. <div class="overall-analysis-page">
  3. <!-- 筛选 -->
  4. <div class="filter-card">
  5. <div class="filter-section">
  6. <div class="filter-row">
  7. <div class="filter-item" v-if="false">
  8. <label>店铺:</label>
  9. <el-select
  10. v-model="filters.shop"
  11. placeholder="请选择店铺(默认全部)"
  12. clearable
  13. filterable
  14. style="width: 180px"
  15. size="small"
  16. >
  17. <el-option label="全部店铺" value="" />
  18. <el-option
  19. v-for="shop in filterOptions.shops"
  20. :key="shop"
  21. :label="shop"
  22. :value="shop"
  23. />
  24. </el-select>
  25. </div>
  26. <div class="filter-item">
  27. <label>日期范围:</label>
  28. <el-date-picker
  29. v-model="dateRange"
  30. type="daterange"
  31. range-separator="至"
  32. start-placeholder="开始日期"
  33. end-placeholder="结束日期"
  34. format="yyyy-MM-dd"
  35. value-format="yyyy-MM-dd"
  36. @change="handleDateChange"
  37. style="width: 380px"
  38. size="small"
  39. />
  40. </div>
  41. <div class="filter-item">
  42. <el-button type="primary" @click="fetchData" :loading="loading" size="small">
  43. 查询分析
  44. </el-button>
  45. <el-button @click="resetFilter" size="small">重置筛选</el-button>
  46. </div>
  47. </div>
  48. </div>
  49. </div>
  50. <!-- 图表区域 -->
  51. <div class="charts-section">
  52. <el-row :gutter="20">
  53. <el-col :span="12">
  54. <!-- 各店铺销售金额占比 -->
  55. <div class="chart-card">
  56. <div class="card-header">
  57. <span>各店铺销售金额占比</span>
  58. </div>
  59. <div class="chart-container">
  60. <div
  61. id="shopAmountChart"
  62. style="width: 100%; height: 400px"
  63. ></div>
  64. </div>
  65. </div>
  66. </el-col>
  67. <el-col :span="12">
  68. <!-- 各店铺销售数量占比 -->
  69. <div class="chart-card">
  70. <div class="card-header">
  71. <span>各店铺销售数量占比</span>
  72. </div>
  73. <div class="chart-container">
  74. <div
  75. id="shopQuantityChart"
  76. style="width: 100%; height: 400px"
  77. ></div>
  78. </div>
  79. </div>
  80. </el-col>
  81. </el-row>
  82. <!-- 各品类销售趋势 -->
  83. <div class="chart-card">
  84. <div class="card-header">
  85. <span>各品类销售数量和销售额趋势</span>
  86. </div>
  87. <div class="chart-container">
  88. <div id="categoryTrendChart" style="width: 100%; height: 500px"></div>
  89. </div>
  90. </div>
  91. </div>
  92. </div>
  93. </template>
  94. <script>
  95. import * as echarts from "echarts";
  96. import { getReportFilterOptions, getShopAnalysisReport } from '@/api/sales-analysis'
  97. export default {
  98. name: "ShopAnalysis",
  99. components: {},
  100. data() {
  101. return {
  102. loading: false,
  103. dateRange: null,
  104. filters: {
  105. shop: "",
  106. },
  107. filterOptions: {
  108. shops: [],
  109. },
  110. statsData: {
  111. basicStats: null,
  112. shopAmountData: [],
  113. shopQuantityData: [],
  114. categoryTrendData: [],
  115. },
  116. shopAmountChart: null,
  117. shopQuantityChart: null,
  118. categoryTrendChart: null,
  119. };
  120. },
  121. filters: {
  122. jpMoney(value) {
  123. if (value === 0) {
  124. return "0";
  125. }
  126. if (value < 10000) {
  127. return value.toLocaleString("ja-JP", {
  128. style: "currency",
  129. currency: "JPY",
  130. minimumFractionDigits: 0,
  131. maximumFractionDigits: 0,
  132. });
  133. } else {
  134. // 将value转换为万日元
  135. const jpValue = (value / 10000).toFixed(0);
  136. return jpValue;
  137. }
  138. },
  139. },
  140. mounted() {
  141. this.initDateRange();
  142. this.fetchFilterOptions();
  143. this.fetchData();
  144. },
  145. beforeDestroy() {
  146. if (this.shopAmountChart) {
  147. this.shopAmountChart.dispose();
  148. }
  149. if (this.shopQuantityChart) {
  150. this.shopQuantityChart.dispose();
  151. }
  152. if (this.categoryTrendChart) {
  153. this.categoryTrendChart.dispose();
  154. }
  155. },
  156. methods: {
  157. // 初始化日期范围(默认最近30天)
  158. initDateRange() {
  159. const endDate = new Date();
  160. const startDate = new Date();
  161. startDate.setDate(endDate.getDate() - 30);
  162. this.dateRange = [
  163. startDate.toISOString().split("T")[0],
  164. endDate.toISOString().split("T")[0],
  165. ];
  166. },
  167. // 日期变化处理
  168. handleDateChange(dates) {
  169. this.dateRange = dates;
  170. },
  171. // 重置筛选
  172. resetFilter() {
  173. this.initDateRange();
  174. this.filters = {
  175. shop: "",
  176. };
  177. this.fetchData();
  178. },
  179. // 获取筛选选项
  180. async fetchFilterOptions() {
  181. try {
  182. const response = await getReportFilterOptions();
  183. if (response.code === 200) {
  184. this.filterOptions = {
  185. shops: response.data.shops || [],
  186. };
  187. } else {
  188. console.error("获取筛选选项失败:", response.message);
  189. }
  190. } catch (error) {
  191. console.error("获取筛选选项失败:", error);
  192. }
  193. },
  194. // 获取数据
  195. async fetchData() {
  196. if (!this.dateRange || this.dateRange.length !== 2) {
  197. this.$message.warning("请选择日期范围");
  198. return;
  199. }
  200. try {
  201. this.loading = true;
  202. // 构建查询参数
  203. const params = {
  204. startDate: this.dateRange[0],
  205. endDate: this.dateRange[1],
  206. };
  207. // 添加店铺筛选条件(如果选择了特定店铺)
  208. if (this.filters.shop) {
  209. params.shop = this.filters.shop;
  210. }
  211. const response = await getShopAnalysisReport(params);
  212. if (response.code === 200) {
  213. this.statsData = response.data;
  214. this.$nextTick(() => {
  215. this.renderShopAmountChart();
  216. this.renderShopQuantityChart();
  217. this.renderCategoryTrendChart();
  218. });
  219. } else {
  220. this.$message.error(response.message || "获取数据失败");
  221. }
  222. } catch (error) {
  223. console.error("获取数据失败:", error);
  224. this.$message.error("获取数据失败");
  225. } finally {
  226. this.loading = false;
  227. }
  228. },
  229. // 渲染店铺销售金额占比图表
  230. renderShopAmountChart() {
  231. if (
  232. !this.statsData.shopAmountData ||
  233. this.statsData.shopAmountData.length === 0
  234. ) {
  235. const chartDom = document.getElementById("shopAmountChart");
  236. if (chartDom && this.shopAmountChart) {
  237. this.shopAmountChart.clear();
  238. }
  239. return;
  240. }
  241. const chartDom = document.getElementById("shopAmountChart");
  242. if (!chartDom) return;
  243. if (this.shopAmountChart) {
  244. this.shopAmountChart.dispose();
  245. }
  246. this.shopAmountChart = echarts.init(chartDom);
  247. const data = this.statsData.shopAmountData.map((item) => ({
  248. name: item.shopName,
  249. value: item.amount,
  250. percentage: item.percentage,
  251. }));
  252. const option = {
  253. tooltip: {
  254. trigger: "item",
  255. formatter: (params) => {
  256. return `${params.name}<br/>销售额: ¥${this.formatNumber(
  257. params.value
  258. )}<br/>占比: ${params.data.percentage}%`;
  259. },
  260. },
  261. legend: {
  262. type: "scroll",
  263. orient: "vertical",
  264. right: 10,
  265. top: 20,
  266. bottom: 20,
  267. data: data.map((item) => item.name),
  268. textStyle: {
  269. color: "#666",
  270. },
  271. formatter: (name) => {
  272. const item = data.find((p) => p.name === name);
  273. const displayName =
  274. name.length > 15 ? name.slice(0, 15) + "..." : name;
  275. if (item && item.percentage !== undefined) {
  276. return `${displayName} ${item.percentage}%`;
  277. }
  278. return displayName;
  279. },
  280. },
  281. series: [
  282. {
  283. name: "销售金额",
  284. type: "pie",
  285. radius: ["50%", "70%"],
  286. center: ["40%", "50%"],
  287. avoidLabelOverlap: false,
  288. itemStyle: {
  289. borderRadius: 10,
  290. borderColor: "#fff",
  291. borderWidth: 2,
  292. },
  293. label: {
  294. show: false,
  295. position: "center",
  296. },
  297. emphasis: {
  298. label: {
  299. show: true,
  300. fontSize: "20",
  301. fontWeight: "bold",
  302. },
  303. },
  304. labelLine: {
  305. show: false,
  306. },
  307. data: data,
  308. color: [
  309. "#5470C6",
  310. "#91CC75",
  311. "#FAC858",
  312. "#EE6666",
  313. "#73C0DE",
  314. "#3BA272",
  315. "#FC8452",
  316. "#9A60B4",
  317. "#EA7CCC",
  318. ],
  319. },
  320. ],
  321. };
  322. this.shopAmountChart.setOption(option);
  323. },
  324. // 渲染店铺销售数量占比图表
  325. renderShopQuantityChart() {
  326. if (
  327. !this.statsData.shopQuantityData ||
  328. this.statsData.shopQuantityData.length === 0
  329. ) {
  330. const chartDom = document.getElementById("shopQuantityChart");
  331. if (chartDom && this.shopQuantityChart) {
  332. this.shopQuantityChart.clear();
  333. }
  334. return;
  335. }
  336. const chartDom = document.getElementById("shopQuantityChart");
  337. if (!chartDom) return;
  338. if (this.shopQuantityChart) {
  339. this.shopQuantityChart.dispose();
  340. }
  341. this.shopQuantityChart = echarts.init(chartDom);
  342. const data = this.statsData.shopQuantityData.map((item) => ({
  343. name: item.shopName,
  344. value: item.quantity,
  345. percentage: item.percentage,
  346. }));
  347. const option = {
  348. tooltip: {
  349. trigger: "item",
  350. formatter: (params) => {
  351. return `${params.name}<br/>销售数量: ${this.formatNumber(
  352. params.value
  353. )}<br/>占比: ${params.data.percentage}%`;
  354. },
  355. },
  356. legend: {
  357. type: "scroll",
  358. orient: "vertical",
  359. right: 10,
  360. top: 20,
  361. bottom: 20,
  362. data: data.map((item) => item.name),
  363. textStyle: {
  364. color: "#666",
  365. },
  366. formatter: (name) => {
  367. const item = data.find((p) => p.name === name);
  368. const displayName =
  369. name.length > 15 ? name.slice(0, 15) + "..." : name;
  370. if (item && item.percentage !== undefined) {
  371. return `${displayName} ${item.percentage}%`;
  372. }
  373. return displayName;
  374. },
  375. },
  376. series: [
  377. {
  378. name: "销售数量",
  379. type: "pie",
  380. radius: ["50%", "70%"],
  381. center: ["40%", "50%"],
  382. avoidLabelOverlap: false,
  383. itemStyle: {
  384. borderRadius: 10,
  385. borderColor: "#fff",
  386. borderWidth: 2,
  387. },
  388. label: {
  389. show: false,
  390. position: "center",
  391. },
  392. emphasis: {
  393. label: {
  394. show: true,
  395. fontSize: "20",
  396. fontWeight: "bold",
  397. },
  398. },
  399. labelLine: {
  400. show: false,
  401. },
  402. data: data,
  403. color: [
  404. "#91CC75",
  405. "#5470C6",
  406. "#FAC858",
  407. "#EE6666",
  408. "#73C0DE",
  409. "#3BA272",
  410. "#FC8452",
  411. "#9A60B4",
  412. "#EA7CCC",
  413. ],
  414. },
  415. ],
  416. };
  417. this.shopQuantityChart.setOption(option);
  418. },
  419. // 渲染品类销售趋势图表
  420. renderCategoryTrendChart() {
  421. if (
  422. !this.statsData.categoryTrendData ||
  423. this.statsData.categoryTrendData.length === 0
  424. ) {
  425. const chartDom = document.getElementById("categoryTrendChart");
  426. if (chartDom && this.categoryTrendChart) {
  427. this.categoryTrendChart.clear();
  428. }
  429. return;
  430. }
  431. const chartDom = document.getElementById("categoryTrendChart");
  432. if (!chartDom) return;
  433. if (this.categoryTrendChart) {
  434. this.categoryTrendChart.dispose();
  435. }
  436. this.categoryTrendChart = echarts.init(chartDom);
  437. const categories = this.statsData.categoryTrendData.map(
  438. (item) => item.category
  439. );
  440. const amounts = this.statsData.categoryTrendData.map(
  441. (item) => item.amount
  442. );
  443. const quantities = this.statsData.categoryTrendData.map(
  444. (item) => item.quantity
  445. );
  446. const option = {
  447. tooltip: {
  448. trigger: "axis",
  449. axisPointer: {
  450. type: "cross",
  451. crossStyle: {
  452. color: "#999",
  453. },
  454. },
  455. formatter: (params) => {
  456. let tooltipText = `${params[0].name}<br/>`;
  457. params.forEach((param) => {
  458. tooltipText += `${param.marker} ${param.seriesName}: `;
  459. const value = param.value || 0;
  460. if (param.seriesName === "销售额") {
  461. tooltipText += `¥${this.formatNumber(value.toFixed(0))}`;
  462. } else {
  463. tooltipText += `${this.formatNumber(value)}`;
  464. }
  465. tooltipText += "<br/>";
  466. });
  467. return tooltipText;
  468. },
  469. },
  470. legend: {
  471. data: ["销售额", "销售数量"],
  472. top: "top",
  473. itemGap: 20,
  474. textStyle: {
  475. color: "#666",
  476. },
  477. },
  478. grid: {
  479. left: "3%",
  480. right: "4%",
  481. bottom: "25%",
  482. containLabel: true,
  483. },
  484. xAxis: [
  485. {
  486. type: "category",
  487. data: categories,
  488. axisPointer: {
  489. type: "shadow",
  490. },
  491. axisTick: {
  492. show: false,
  493. },
  494. axisLine: {
  495. show: false,
  496. },
  497. axisLabel: {
  498. color: "#666",
  499. interval: 0,
  500. rotate: 45,
  501. },
  502. },
  503. ],
  504. yAxis: [
  505. {
  506. type: "value",
  507. name: "销售额",
  508. axisLabel: {
  509. formatter: "¥{value}",
  510. color: "#666",
  511. },
  512. nameTextStyle: {
  513. color: "#666",
  514. padding: [0, 0, 0, 40],
  515. },
  516. splitLine: {
  517. lineStyle: {
  518. type: "dashed",
  519. color: "#e0e6f1",
  520. },
  521. },
  522. axisLine: { show: false },
  523. axisTick: { show: false },
  524. },
  525. {
  526. type: "value",
  527. name: "销售数量",
  528. axisLabel: {
  529. formatter: "{value}",
  530. color: "#666",
  531. },
  532. nameTextStyle: {
  533. color: "#666",
  534. padding: [0, 40, 0, 0],
  535. },
  536. splitLine: { show: false },
  537. axisLine: { show: false },
  538. axisTick: { show: false },
  539. },
  540. ],
  541. series: [
  542. {
  543. name: "销售额",
  544. type: "bar",
  545. barWidth: "40%",
  546. data: amounts,
  547. itemStyle: {
  548. borderRadius: [5, 5, 0, 0],
  549. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  550. { offset: 0, color: "#5470C6" },
  551. { offset: 1, color: "#80A4F3" },
  552. ]),
  553. },
  554. },
  555. {
  556. name: "销售数量",
  557. type: "line",
  558. yAxisIndex: 1,
  559. smooth: true,
  560. data: quantities,
  561. symbol: "circle",
  562. symbolSize: 8,
  563. areaStyle: {
  564. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  565. {
  566. offset: 0,
  567. color: "rgba(91, 204, 189, 0.5)",
  568. },
  569. {
  570. offset: 1,
  571. color: "rgba(91, 204, 189, 0)",
  572. },
  573. ]),
  574. },
  575. itemStyle: {
  576. color: "#5BCCBD",
  577. },
  578. },
  579. ],
  580. dataZoom: [
  581. {
  582. type: "inside",
  583. start: 0,
  584. end: 100,
  585. },
  586. {
  587. start: 0,
  588. end: 100,
  589. height: 20,
  590. bottom: 5,
  591. },
  592. ],
  593. };
  594. this.categoryTrendChart.setOption(option);
  595. },
  596. // 格式化数字
  597. formatNumber(num) {
  598. if (!num) return "0";
  599. return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  600. },
  601. },
  602. };
  603. </script>
  604. <style scoped>
  605. .overall-analysis-page {
  606. height: 100vh;
  607. overflow-y: auto;
  608. margin: -20px;
  609. padding: 10px;
  610. display: flex;
  611. flex-direction: column;
  612. gap: 10px;
  613. }
  614. .filter-card {
  615. background-color: #fff;
  616. padding: 20px;
  617. border-radius: 8px;
  618. border: 1px solid #e8e8e8;
  619. }
  620. .filter-section {
  621. display: flex;
  622. flex-direction: column;
  623. gap: 10px;
  624. }
  625. .filter-row {
  626. display: flex;
  627. align-items: center;
  628. gap: 10px;
  629. }
  630. .filter-item {
  631. display: flex;
  632. align-items: center;
  633. gap: 4px;
  634. }
  635. .filter-item label {
  636. font-weight: 500;
  637. color: #333;
  638. white-space: nowrap;
  639. width: 70px;
  640. }
  641. .filter-item .el-select {
  642. width: 180px;
  643. }
  644. .filter-item .el-input {
  645. width: 180px;
  646. }
  647. .filter-actions {
  648. display: flex;
  649. gap: 10px;
  650. }
  651. .stats-cards {
  652. display: grid;
  653. grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  654. gap: 10px;
  655. }
  656. .stat-card {
  657. background: white;
  658. padding: 20px;
  659. border-radius: 8px;
  660. display: flex;
  661. align-items: center;
  662. gap: 15px;
  663. border: 1px solid #e8e8e8;
  664. }
  665. .stat-icon {
  666. width: 60px;
  667. height: 60px;
  668. border-radius: 50%;
  669. background: #409eff;
  670. display: flex;
  671. align-items: center;
  672. justify-content: center;
  673. color: white;
  674. font-size: 24px;
  675. flex-shrink: 0;
  676. }
  677. .stat-content {
  678. flex: 1;
  679. }
  680. .stat-title {
  681. font-size: 16px;
  682. color: #505050;
  683. margin-bottom: 5px;
  684. }
  685. .stat-value {
  686. font-size: 32px;
  687. font-weight: 500;
  688. color: #333;
  689. margin-bottom: 5px;
  690. }
  691. .stat-value small {
  692. font-size: 15px;
  693. color: #505050;
  694. }
  695. .stat-desc {
  696. font-size: 12px;
  697. color: #999;
  698. }
  699. .charts-section {
  700. display: grid;
  701. grid-template-columns: 1fr;
  702. gap: 20px;
  703. }
  704. .chart-card {
  705. background: white;
  706. }
  707. .chart-container {
  708. width: 100%;
  709. }
  710. .card-header {
  711. display: flex;
  712. align-items: center;
  713. font-weight: 500;
  714. font-size: 18px;
  715. padding: 20px;
  716. justify-content: center;
  717. }
  718. .chart-card {
  719. background-color: #fff;
  720. border-radius: 8px;
  721. border: 1px solid #e8e8e8;
  722. }
  723. .chart-container {
  724. padding: 10px 0;
  725. }
  726. </style>