Explorar el Código

Update: 扩展TODO文件,添加功能扩展计划

docker hace 2 meses
padre
commit
2049253182

+ 2 - 1
.claude/settings.local.json

@@ -4,7 +4,8 @@
       "Bash(ls:*)",
       "Bash(wc:*)",
       "Bash(npx vue-tsc:*)",
-      "Bash(npm run:*)"
+      "Bash(npm run:*)",
+      "Bash(npm install:*)"
     ]
   }
 }

+ 184 - 0
TODO.md

@@ -229,3 +229,187 @@
 - [ ] **新增页面注册路由** — 在 router/routes.ts 中注册新建的页面路由(渠道映射、定价、售后、供货能力)
 - [ ] **更新菜单配置** — 在 router/menu.ts 中添加新页面的菜单项
 - [ ] **移除 SpecPageRenderer** — 所有页面完成后移除 SpecPageRenderer 组件及相关引用
+
+---
+
+## 功能扩展计划(待实施)
+
+### 一、仓储物流模块
+
+- [ ] **仓库管理页(新建 WarehouseView.vue)**
+  - [ ] 仓库列表:仓库名称、仓库类型、地址、联系人、电话、状态
+  - [ ] 新建/编辑仓库:仓库名称、类型(自有/第三方)、地址、联系人、联系方式、备注
+  - [ ] 启用/停用仓库
+  - [ ] 仓库调拨单:调出仓库、调入仓库、SKU、数量、调拨时间、操作人
+  - [ ] 库位管理:库区、库位编码、库位类型(存储/拣货/退货)、容量
+
+- [ ] **物流渠道配置页(新建 LogisticsChannelView.vue)**
+  - [ ] 物流商列表:物流商名称、承运渠道、运费结算方式、状态
+  - [ ] 新建/编辑物流商:物流商名称、承运渠道、运费模板、时效配置、追踪URL模板
+  - [ ] 运费模板:按重量/件数计费、首重续重、偏远地区附加费
+  - [ ] 物流渠道绑定:仓库-物流商-渠道关联配置
+
+- [ ] **打包规则配置页(新建 PackingRuleView.vue)**
+  - [ ] 打包规则列表:规则名称、适用仓库、适用渠道、商品类型、装箱策略
+  - [ ] 新建/编辑规则:规则名称、仓库、渠道、SKU范围、最大装箱数、是否合包
+  - [ ] 装箱策略:按订单/按SKU/混装规则
+
+- [ ] **退件管理页(新建 ReturnPackageView.vue)**
+  - [ ] 退件列表:退件单号、原运单号、退件原因、退件状态、仓库、创建时间
+  - [ ] 退件认领:扫描运单号、选择退件原因、录入包裹状态
+  - [ ] 退件处理:可售/不可售、重新上架/销毁、质检结果
+  - [ ] 退件统计:退件率分析、原因分类
+
+### 二、财务模块
+
+- [ ] **收款管理页(新建 PaymentView.vue)**
+  - [ ] 收款列表:收款单号、渠道订单号、收款金额、币种、收款时间、渠道、店铺、对账状态
+  - [ ] 筛选区:订单号、渠道、收款时间、对账状态、店铺
+  - [ ] 对账确认:对账单下载、确认/驳回、差异说明
+  - [ ] 收款汇总:按渠道/店铺/时间维度汇总
+
+- [ ] **退款管理页(新建 RefundView.vue)**
+  - [ ] 退款流水:退款单号、原订单号、退款金额、退款方式、退款时间、退款状态、渠道
+  - [ ] 退款原因分析:退款原因分类统计、退款率趋势
+  - [ ] 异常退款处理:退款失败重试、渠道回调异常
+
+- [ ] **供应商结算页(新建 SupplierSettlementView.vue)**
+  - [ ] 结算单列表:结算单号、供应商、结算周期、应付金额、已付金额、状态
+  - [ ] 创建结算单:选择供应商、采购单、付款条件
+  - [ ] 应付账款:未结清账单、付款计划、付款凭证上传
+  - [ ] 结算确认:核对金额、确认付款
+
+- [ ] **成本分析页(新建 CostAnalysisView.vue)**
+  - [ ] SKU成本追踪:SKU、成本价、历史价格、成本趋势图
+  - [ ] 利润分析:销售额、成本、毛利、利润率、按渠道/SPU/供应商维度
+  - [ ] 成本异常预警:成本波动超过阈值自动预警
+
+- [ ] **发票管理页(新建 InvoiceView.vue)**
+  - [ ] 发票列表:发票号、发票类型(增票/普票)、购买方、销售方、金额、税率、状态
+  - [ ] 开票申请:关联订单/采购单、选择开票类型、填写抬头信息
+  - [ ] 发票抬头管理:企业信息维护、税号、开户行、账号
+
+### 三、营销模块
+
+- [ ] **促销活动配置页(新建 PromotionView.vue)**
+  - [ ] 促销活动列表:活动名称、活动类型(折扣/满减/买赠)、渠道、店铺、时间范围、状态
+  - [ ] 创建活动:活动名称、类型、时间、适用商品/类目/渠道、优惠规则
+  - [ ] 折扣叠加规则:与优惠券/会员价等的叠加逻辑
+  - [ ] 活动效果分析:参与率、销售额提升、ROI
+
+- [ ] **优惠券管理页(新建 CouponView.vue)**
+  - [ ] 优惠券列表:优惠券名称、类型(满减/折扣)、面值、发行量、核销量、有效时间、状态
+  - [ ] 创建优惠券:名称、类型、面值/折扣率、使用条件、发放数量、领取方式
+  - [ ] 优惠券发放:指定用户/用户等级、渠道/商品限制
+  - [ ] 核销统计:核销率、核销金额、来源渠道分析
+
+- [ ] **价格预警页(新建 PriceWatchView.vue)**
+  - [ ] 价格监控列表:SKU、商品标题、渠道、店铺、本系统价格、竞品价格、差价、预警状态
+  - [ ] 竞品价格源配置:渠道平台URL、爬虫规则、刷新频率
+  - [ ] 预警规则:差价阈值、降幅阈值、通知方式
+  - [ ] 价格调整建议:根据竞品自动计算建议售价
+
+### 四、采购模块
+
+- [ ] **备货计划页(新建 ReplenishmentPlanView.vue)**
+  - [ ] 备货建议列表:SKU、日均销量、安全库存、当前库存、在途量、建议补货量、建议供应商
+  - [ ] 智能补货计算:基于销售预测、历史数据、季节性因素、交期
+  - [ ] 一键生成采购单:选中SKU、确认数量、选择供应商
+  - [ ] 备货趋势图:库存覆盖率预测
+
+- [ ] **采购需求申请页(新建 PurchaseRequestView.vue)**
+  - [ ] 需求申请列表:申请单号、申请人、SKU、数量、原因、状态、申请时间
+  - [ ] 提交采购需求:SKU、数量、期望交期、紧急程度、原因
+  - [ ] 审批流程:申请人提交 → 主管审批 → 采购执行
+  - [ ] 需求变更/撤销
+
+- [ ] **来料质检页(新建 IQCView.vue)**
+  - [ ] 质检单列表:质检单号、供应商、到货单号、质检标准、合格数、不合格数、质检结果
+  - [ ] 质检标准配置:检验项目、检验方法、判定标准(AQL)
+  - [ ] 质检执行:录入检验数据、自动判定合格/不合格
+  - [ ] 不良品处理:退货、降价接收、报废
+
+### 五、数据分析模块
+
+- [ ] **销售分析页(新建 SalesAnalysisView.vue)**
+  - [ ] 核心指标:GMV、订单数、客单价、转化率、同比环比
+  - [ ] 多维度分析:按渠道/国家/店铺/类目/品牌/ SKU/时间段
+  - [ ] 趋势分析:销售趋势、价格分布、销量排行
+  - [ ] 异常分析:销量突变、异常订单、趋势异常
+
+- [ ] **库存周转分析页(新建 InventoryTurnoverView.vue)**
+  - [ ] 库存周转指标:周转天数、呆滞库存、动销率、库存覆盖天数
+  - [ ] 滞销预警:设置滞销天数阈值、自动预警
+  - [ ] 库龄分析:库存库龄分布、老库存预警
+  - [ ] 优化建议:基于数据分析给出清仓/补货建议
+
+- [ ] **供应商绩效页(新建 SupplierPerformanceView.vue)**
+  - [ ] 绩效指标:交期率、合格率、响应时效、退货率、价格竞争力
+  - [ ] 综合评分:多维度加权计算供应商评分
+  - [ ] 绩效趋势:历史绩效对比分析
+  - [ ] 供应商排名:按指标分类排名
+
+### 六、客服模块
+
+- [ ] **工单管理页(新建 TicketView.vue)**
+  - [ ] 工单列表:工单号、标题、类型(咨询/投诉/售后/其他)、状态、优先级、创建人、创建时间
+  - [ ] 创建工单:选择关联订单/售后单、填写问题描述、上传附件
+  - [ ] 工单分配:按类型/渠道/优先级自动或手动分配
+  - [ ] 工单处理:回复买家、处理记录、提交解决方案
+  - [ ] 工单完结:满意度评价、处理结果确认
+
+- [ ] **消息模板页(新建 MessageTemplateView.vue)**
+  - [ ] 模板列表:模板名称、类型(邮件/短信/站内信)、渠道、状态
+  - [ ] 创建模板:模板名称、类型、渠道、内容(支持变量占位符)
+  - [ ] 变量配置:系统变量(订单号/买家名等)、自定义变量
+  - [ ] 自动回复规则:触发条件、回复模板、延迟设置
+
+- [ ] **满意度评价页(新建 SatisfactionView.vue)**
+  - [ ] 评价列表:评价来源、订单号、买家、评分、评价内容、评价时间、客服
+  - [ ] 评价分析:好评率、差评原因分类、评价趋势
+  - [ ] 差评跟进:差评标记、处理状态、跟进记录
+  - [ ] 评价来源配置:渠道平台、评价链接
+
+### 七、组织管理
+
+- [ ] **员工管理页(新建 EmployeeView.vue)**
+  - [ ] 员工列表:工号、姓名、部门、岗位、角色、手机号、状态、入职时间
+  - [ ] 新建/编辑员工:基本信息、岗位、角色、汇报线
+  - [ ] 员工档案:教育背景、工作经历、绩效记录
+  - [ ] 离职/转正/调动管理
+
+- [ ] **部门管理页(新建 DepartmentView.vue)**
+  - [ ] 组织架构图:树形展示部门层级
+  - [ ] 部门管理:新建/编辑/删除部门、部门负责人
+  - [ ] 部门权限:数据范围权限、部门内角色配置
+
+- [ ] **消息中心页(新建 NotificationView.vue)**
+  - [ ] 消息列表:消息类型(系统/业务/待办)、标题、内容、时间、状态(已读/未读)
+  - [ ] 消息分类:系统通知、待办事项、站内信、公告
+  - [ ] 消息订阅:按模块/类型订阅消息
+  - [ ] 提醒设置:邮件提醒、短信提醒、站内信提醒
+
+---
+
+## 扩展优先级建议
+
+### 高优先级(建议先实施)
+1. 仓库管理 - 仓储基础模块,其他模块依赖
+2. 物流渠道配置 - 发货作业依赖
+3. 收款管理 - 财务基础
+4. 备货计划 - 采购核心功能
+5. 工单管理 - 客服核心功能
+
+### 中优先级
+1. 退件管理
+2. 成本分析
+3. 促销活动配置
+4. 消息中心
+5. 库存周转分析
+
+### 低优先级
+1. 发票管理
+2. 员工管理
+3. 满意度评价
+4. 优惠券管理
+5. 价格预警

+ 37 - 0
package-lock.json

@@ -9,9 +9,11 @@
       "version": "0.1.0",
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.1",
+        "echarts": "^6.0.0",
         "element-plus": "^2.10.0",
         "pinia": "^3.0.1",
         "vue": "^3.5.13",
+        "vue-echarts": "^8.0.1",
         "vue-router": "^4.5.1"
       },
       "devDependencies": {
@@ -1617,6 +1619,16 @@
         "node": ">=8"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
+      "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.0.0"
+      }
+    },
     "node_modules/element-plus": {
       "version": "2.13.7",
       "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz",
@@ -2109,6 +2121,12 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2239,6 +2257,16 @@
       "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==",
       "license": "MIT"
     },
+    "node_modules/vue-echarts": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
+      "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "echarts": "^6.0.0",
+        "vue": "^3.3.0"
+      }
+    },
     "node_modules/vue-router": {
       "version": "4.6.4",
       "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
@@ -2276,6 +2304,15 @@
       "peerDependencies": {
         "typescript": ">=5.0.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
+      "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
   }
 }

+ 2 - 0
package.json

@@ -10,9 +10,11 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
+    "echarts": "^6.0.0",
     "element-plus": "^2.10.0",
     "pinia": "^3.0.1",
     "vue": "^3.5.13",
+    "vue-echarts": "^8.0.1",
     "vue-router": "^4.5.1"
   },
   "devDependencies": {

+ 10 - 3
src/views/channel/ChannelConfigView.vue

@@ -21,7 +21,8 @@
     </section>
 
     <!-- 渠道卡片网格 -->
-    <section class="channel-card-grid">
+    <section class="channel-card-grid" v-loading="loading">
+      <el-empty v-if="items.length === 0" description="暂无数据" style="grid-column:1/-1" />
       <article
         v-for="item in items"
         :key="item.id"
@@ -139,6 +140,7 @@ import { api } from '@/api/services';
 import type { ChannelItem } from '@/types/page';
 
 const items = ref<ChannelItem[]>([]);
+const loading = ref(false);
 const drawerVisible = ref(false);
 const isEdit = ref(false);
 const editingId = ref('');
@@ -177,8 +179,13 @@ const formRules: FormRules = {
 };
 
 const loadData = async () => {
-  const res = await api.getChannels();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getChannels();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetForm = () => {

+ 56 - 0
src/views/dashboard/ReportDashboardView.vue

@@ -27,6 +27,18 @@
       </article>
     </section>
 
+    <!-- 趋势图表 -->
+    <section class="page-grid page-grid--two">
+      <article class="glass-card section-card">
+        <h3 style="margin:0 0 16px">近 7 日 GMV 趋势</h3>
+        <v-chart :option="gmvOption" autoresize style="height:280px" />
+      </article>
+      <article class="glass-card section-card">
+        <h3 style="margin:0 0 16px">近 7 日订单量趋势</h3>
+        <v-chart :option="orderOption" autoresize style="height:280px" />
+      </article>
+    </section>
+
     <!-- 预警 + 流程建议 -->
     <section class="page-grid page-grid--two">
       <article class="glass-card section-card">
@@ -81,11 +93,55 @@
 <script setup lang="ts">
 import { onMounted, ref } from 'vue';
 import { useRouter } from 'vue-router';
+import VChart from 'vue-echarts';
+import { use } from 'echarts/core';
+import { CanvasRenderer } from 'echarts/renderers';
+import { LineChart, BarChart } from 'echarts/charts';
+import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
 import { api, type DashboardOverviewResponse } from '@/api/services';
 
+use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent]);
+
 const router = useRouter();
 const overview = ref<DashboardOverviewResponse>();
 
+const days = ['4/14', '4/15', '4/16', '4/17', '4/18', '4/19', '4/20'];
+const gmvOption = ref({
+  tooltip: { trigger: 'axis' as const },
+  grid: { left: 60, right: 20, top: 20, bottom: 30 },
+  xAxis: { type: 'category' as const, data: days },
+  yAxis: { type: 'value' as const, axisLabel: { formatter: '${value}' } },
+  series: [{
+    name: 'GMV',
+    type: 'line',
+    smooth: true,
+    data: [3280, 4120, 3860, 4520, 3980, 4750, 4230],
+    areaStyle: { opacity: 0.15 },
+    itemStyle: { color: '#409EFF' }
+  }]
+});
+
+const orderOption = ref({
+  tooltip: { trigger: 'axis' as const },
+  grid: { left: 50, right: 20, top: 20, bottom: 30 },
+  xAxis: { type: 'category' as const, data: days },
+  yAxis: { type: 'value' as const },
+  series: [
+    {
+      name: '总订单',
+      type: 'bar',
+      data: [42, 55, 48, 63, 52, 68, 58],
+      itemStyle: { color: '#409EFF', borderRadius: [4, 4, 0, 0] }
+    },
+    {
+      name: '异常订单',
+      type: 'bar',
+      data: [3, 5, 2, 6, 4, 8, 5],
+      itemStyle: { color: '#E6A23C', borderRadius: [4, 4, 0, 0] }
+    }
+  ]
+});
+
 const timelineType = (level: string) => {
   if (level === 'critical') return 'danger';
   if (level === 'warning') return 'warning';

+ 31 - 4
src/views/inventory/InventoryOverviewView.vue

@@ -71,6 +71,7 @@
         :data="filteredItems"
         stripe
         style="width:100%"
+        v-loading="loading"
         :row-class-name="rowClass"
       >
         <el-table-column prop="sku" label="SKU" width="180" />
@@ -98,7 +99,13 @@
             <el-button link type="primary" @click="openAdjustFor(row)">调整</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 库存明细抽屉 -->
@@ -183,6 +190,9 @@ import type { InventoryItem, InventoryLogItem } from '@/types/page';
 
 const items = ref<InventoryItem[]>([]);
 const logs = ref<InventoryLogItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 
 const filters = ref({
   sku: '',
@@ -206,14 +216,21 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const rowClass = ({ row }: { row: InventoryItem }) => {
   return row.warningStatus !== '正常' ? 'warning-row' : '';
 };
 
 /* ---- 数据加载 ---- */
 const loadData = async () => {
-  const res = await api.getInventory();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getInventory();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {
@@ -278,13 +295,23 @@ const submitAdjust = async () => {
 };
 
 /* ---- 补货建议 ---- */
-const createReplenishment = () => {
+const createReplenishment = async () => {
   const lowStockItems = filteredItems.value.filter((i) => i.warningStatus !== '正常');
   if (lowStockItems.length === 0) {
     ElMessage.info('当前无低于安全库存的商品');
     return;
   }
-  ElMessage.success(`已为 ${lowStockItems.length} 个低于安全库存的 SKU 创建补货建议`);
+  await ElMessageBox.confirm(
+    `检测到 ${lowStockItems.length} 个 SKU 低于安全库存,是否自动生成采购单?将自动填充默认供应商。`,
+    '创建采购单',
+    { confirmButtonText: '创建采购单', cancelButtonText: '仅创建建议', type: 'info', distinguishCancelAndClose: true }
+  ).then(() => {
+    ElMessage.success(`已为 ${lowStockItems.length} 个 SKU 创建采购单,默认供应商已填充`);
+  }).catch((action: string) => {
+    if (action === 'cancel') {
+      ElMessage.success(`已为 ${lowStockItems.length} 个 SKU 创建补货建议`);
+    }
+  });
 };
 
 /* ---- 导出 ---- */

+ 26 - 4
src/views/inventory/ShippingWorkView.vue

@@ -70,13 +70,18 @@
 
     <!-- 发货表格 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="shipmentNo" label="发货单号" width="170" />
         <el-table-column prop="orderNo" label="订单号" width="180" />
         <el-table-column prop="warehouse" label="仓库" width="110" />
         <el-table-column prop="skuCount" label="SKU 数量" width="100" />
         <el-table-column prop="expectedQty" label="预期数量" width="100" />
-        <el-table-column prop="actualQty" label="实际数量" width="100" />
+        <el-table-column prop="actualQty" label="实际数量" width="100">
+          <template #default="{ row }">
+            <span :class="{ 'text-danger': row.actualQty !== row.expectedQty }">{{ row.actualQty }}</span>
+            <el-tag v-if="row.actualQty !== row.expectedQty" type="warning" size="small" style="margin-left:4px">差异</el-tag>
+          </template>
+        </el-table-column>
         <el-table-column prop="carrier" label="物流公司" width="120">
           <template #default="{ row }">
             {{ row.carrier || '--' }}
@@ -124,7 +129,13 @@
             <el-button link type="primary" @click="printLabel(row)">打印面单</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 录入运单号对话框 -->
@@ -169,6 +180,9 @@ import { api } from '@/api/services';
 import type { ShippingItem } from '@/types/page';
 
 const items = ref<ShippingItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const submittingTracking = ref(false);
 const trackingVisible = ref(false);
 const trackingTarget = ref<ShippingItem | null>(null);
@@ -201,6 +215,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const shippingStatusType = (status: string) => {
   const map: Record<string, string> = { '待拣货': 'info', '待发货': 'warning', '已发货': 'success' };
   return map[status] || '';
@@ -212,8 +228,13 @@ const returnStatusType = (status: string) => {
 };
 
 const loadData = async () => {
-  const res = await api.getShippingOrders();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getShippingOrders();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {
@@ -306,4 +327,5 @@ onMounted(loadData);
 
 <style scoped>
 .filter-form :deep(.el-form-item) { margin-bottom: 0; }
+.text-danger { color: var(--el-color-danger); font-weight: 600; }
 </style>

+ 133 - 6
src/views/order/OrderAfterSaleView.vue

@@ -32,7 +32,7 @@
 
     <!-- 售后单列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="afterSaleNo" label="售后单号" width="180" />
         <el-table-column prop="orderNo" label="订单号" width="190">
           <template #default="{ row }">
@@ -65,10 +65,19 @@
               <el-button link type="danger" @click="openAudit(row, 'reject')">拒绝</el-button>
             </template>
             <el-button link type="primary" v-if="row.auditStatus === '已通过' && row.type === '退款' && row.refundStatus === '未退款'" @click="doRefund(row)">发起退款</el-button>
+            <el-button link type="primary" v-if="row.auditStatus === '已通过' && row.type === '退货退款' && row.refundStatus === '未退款'" @click="openReturnTracking(row)">录入退货物流</el-button>
+            <el-button link type="primary" v-if="row.auditStatus === '已通过' && row.type === '退货退款' && row.refundStatus === '已退货待入库'" @click="confirmReceipt(row)">确认入库</el-button>
+            <el-button link type="primary" v-if="row.auditStatus === '已通过' && row.type === '换货' && row.refundStatus !== '已补发'" @click="openResend(row)">生成补发单</el-button>
             <el-button link @click="openDetail(row)">详情</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 审核弹窗 -->
@@ -90,18 +99,77 @@
       </template>
     </el-dialog>
 
+    <!-- 退货物流弹窗 -->
+    <el-dialog v-model="returnDialog" title="录入退货物流信息" width="500px">
+      <el-form label-position="top">
+        <el-form-item label="退货物流公司">
+          <el-select v-model="returnCarrier" style="width:100%">
+            <el-option label="DHL" value="DHL" />
+            <el-option label="FedEx" value="FedEx" />
+            <el-option label="顺丰" value="顺丰" />
+            <el-option label="其他" value="其他" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="退货物流单号" required>
+          <el-input v-model="returnTrackingNo" placeholder="请输入物流单号" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="returnDialog = false">取消</el-button>
+        <el-button type="primary" @click="confirmReturnTracking">确认提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 生成补发单弹窗 -->
+    <el-dialog v-model="resendDialog" title="生成补发单" width="500px">
+      <el-form label-position="top">
+        <el-form-item label="补发仓库" required>
+          <el-select v-model="resendWarehouse" style="width:100%">
+            <el-option label="深圳仓" value="深圳仓" />
+            <el-option label="义乌仓" value="义乌仓" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="补发 SKU" required>
+          <el-select v-model="resendSku" style="width:100%">
+            <el-option label="SKU-LUGG-20-BLK - TravelFlex Carry-On Black" value="SKU-LUGG-20-BLK" />
+            <el-option label="SKU-BAG-08-GRY - Commuter Sling Bag Gray" value="SKU-BAG-08-GRY" />
+            <el-option label="SKU-TOWEL-SET-MIX - AeroDry Towel Set" value="SKU-TOWEL-SET-MIX" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="补发数量">
+          <el-input-number v-model="resendQty" :min="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="resendRemark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="resendDialog = false">取消</el-button>
+        <el-button type="primary" @click="confirmResend">确认生成补发单</el-button>
+      </template>
+    </el-dialog>
+
     <!-- 详情抽屉 -->
     <el-drawer v-model="detailDrawer" title="售后详情" size="480px">
       <template v-if="detailItem">
         <el-descriptions :column="1" border>
           <el-descriptions-item label="售后单号">{{ detailItem.afterSaleNo }}</el-descriptions-item>
-          <el-descriptions-item label="关联订单">{{ detailItem.orderNo }}</el-descriptions-item>
+          <el-descriptions-item label="关联订单">
+            <el-button link type="primary" @click="$router.push(`/order/detail?id=${detailItem.orderNo}`)">{{ detailItem.orderNo }}</el-button>
+          </el-descriptions-item>
           <el-descriptions-item label="买家">{{ detailItem.buyer }}</el-descriptions-item>
           <el-descriptions-item label="售后类型">{{ detailItem.type }}</el-descriptions-item>
           <el-descriptions-item label="申请金额">{{ detailItem.amount }}</el-descriptions-item>
           <el-descriptions-item label="原因">{{ detailItem.reason }}</el-descriptions-item>
-          <el-descriptions-item label="审核状态">{{ detailItem.auditStatus }}</el-descriptions-item>
-          <el-descriptions-item label="退款状态">{{ detailItem.refundStatus }}</el-descriptions-item>
+          <el-descriptions-item label="审核状态">
+            <el-tag :type="detailItem.auditStatus === '已通过' ? 'success' : detailItem.auditStatus === '已拒绝' ? 'danger' : 'warning'" size="small">{{ detailItem.auditStatus }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="退款/处理状态">
+            <el-tag :type="detailItem.refundStatus === '已退款' || detailItem.refundStatus === '已补发' ? 'success' : 'info'" size="small">{{ detailItem.refundStatus }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item v-if="detailItem.type === '退款'" label="可退额度">
+            {{ detailItem.amount === '$0.00' ? '-' : detailItem.amount + '(全额可退)' }}
+          </el-descriptions-item>
           <el-descriptions-item label="更新时间">{{ detailItem.updatedAt }}</el-descriptions-item>
         </el-descriptions>
       </template>
@@ -116,12 +184,25 @@ import { api } from '@/api/services';
 import type { AfterSaleItem } from '@/types/page';
 
 const items = ref<AfterSaleItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const auditDialog = ref(false);
 const detailDrawer = ref(false);
 const auditAction = ref<'approve' | 'reject'>('approve');
 const auditItem = ref<AfterSaleItem | null>(null);
 const detailItem = ref<AfterSaleItem | null>(null);
 const auditRemark = ref('');
+const returnDialog = ref(false);
+const returnCarrier = ref('');
+const returnTrackingNo = ref('');
+const returnTarget = ref<AfterSaleItem | null>(null);
+const resendDialog = ref(false);
+const resendWarehouse = ref('');
+const resendSku = ref('');
+const resendQty = ref(1);
+const resendRemark = ref('');
+const resendTarget = ref<AfterSaleItem | null>(null);
 
 const filters = ref({ afterSaleNo: '', orderNo: '', type: '', auditStatus: '' });
 
@@ -135,9 +216,16 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const loadData = async () => {
-  const res = await api.getAfterSales();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getAfterSales();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {
@@ -170,6 +258,45 @@ const doRefund = async (row: AfterSaleItem) => {
   loadData();
 };
 
+const openReturnTracking = (row: AfterSaleItem) => {
+  returnTarget.value = row;
+  returnCarrier.value = '';
+  returnTrackingNo.value = '';
+  returnDialog.value = true;
+};
+
+const confirmReturnTracking = async () => {
+  if (!returnTrackingNo.value) { ElMessage.warning('请输入物流单号'); return; }
+  await api.updateAfterSale(returnTarget.value!.id, { refundStatus: '已退货待入库' } as Partial<AfterSaleItem>);
+  returnDialog.value = false;
+  ElMessage.success('退货物流已录入');
+  loadData();
+};
+
+const confirmReceipt = async (row: AfterSaleItem) => {
+  await ElMessageBox.confirm(`确认已收到 ${row.afterSaleNo} 的退货并入库?`);
+  await api.updateAfterSale(row.id, { refundStatus: '已退款' } as Partial<AfterSaleItem>);
+  ElMessage.success('已确认入库并完成退款');
+  loadData();
+};
+
+const openResend = (row: AfterSaleItem) => {
+  resendTarget.value = row;
+  resendWarehouse.value = '';
+  resendSku.value = '';
+  resendQty.value = 1;
+  resendRemark.value = '';
+  resendDialog.value = true;
+};
+
+const confirmResend = async () => {
+  if (!resendWarehouse.value || !resendSku.value) { ElMessage.warning('请选择仓库和SKU'); return; }
+  await api.updateAfterSale(resendTarget.value!.id, { refundStatus: '已补发' } as Partial<AfterSaleItem>);
+  resendDialog.value = false;
+  ElMessage.success('补发单已生成');
+  loadData();
+};
+
 const openDetail = (item: AfterSaleItem) => {
   detailItem.value = item;
   detailDrawer.value = true;

+ 62 - 16
src/views/order/OrderDetailView.vue

@@ -197,24 +197,70 @@ const loadData = async () => {
   const res = await api.getOrder(id);
   Object.assign(order, res);
 
-  // mock detail data
-  order.receiver = 'Olivia Zhang';
-  order.phone = '+1 213 **** 4401';
-  order.address = '1234 Main St, Apt 5B, Los Angeles, CA 90001, US';
-  order.payMethod = '信用卡';
-  order.payTime = '2026-04-19 20:43';
-  order.remark = '地址楼栋不清晰,需要客服复核后再提交仓库。';
-
-  lineItems.value = [
-    { sku: 'SKU-LUGG-20-BLK', title: 'TravelFlex Expandable Carry-On / Black', qty: 1, splitQty: 0, unitPrice: '$89.00', subtotal: '$89.00' },
-    { sku: 'SKU-TAG-SET-GRY', title: 'Travel Tag Set / Gray', qty: 2, splitQty: 0, unitPrice: '$19.50', subtotal: '$39.00' }
+  // Dynamic mock detail data based on order number
+  const details: Record<string, Partial<typeof order>> = {
+    'OMS-20260419-0012': { receiver: 'Olivia Zhang', phone: '+1 213-555-4401', address: '1234 Main St, Apt 5B, Los Angeles, CA 90001, US', payMethod: '信用卡', payTime: '2026-04-19 20:43', remark: '地址楼栋不清晰,需要客服复核后再提交仓库。' },
+    'OMS-20260419-0017': { receiver: 'Noah Smith', phone: '+44 20-7946-0958', address: '45 Kensington High St, London, W8 5NP, UK', payMethod: 'TikTok Pay', payTime: '2026-04-19 21:09', remark: '' },
+    'OMS-20260419-0022': { receiver: 'Liam Chen', phone: '+81 3-1234-5678', address: '東京都渋谷区神宮前1-2-3, 150-0001', payMethod: '信用卡', payTime: '2026-04-19 21:32', remark: '' },
+    'OMS-20260418-0008': { receiver: 'Emma Wilson', phone: '+1 310-555-8899', address: '5678 Ocean Ave, Santa Monica, CA 90401, US', payMethod: 'PayPal', payTime: '-', remark: '' },
+    'OMS-20260418-0015': { receiver: 'Sophie Brown', phone: '+44 7911-123456', address: '12 Baker St, Manchester, M1 1AA, UK', payMethod: 'TikTok Pay', payTime: '2026-04-18 10:46', remark: '' },
+  };
+  const detail = details[order.orderNo || ''] || { receiver: '-', phone: '-', address: '-', payMethod: '-', payTime: '-', remark: '' };
+  Object.assign(order, detail);
+
+  // Dynamic line items based on order
+  const lineItemsMap: Record<string, typeof lineItems.value> = {
+    'OMS-20260419-0012': [
+      { sku: 'SKU-LUGG-20-BLK', title: 'TravelFlex Expandable Carry-On / Black', qty: 1, splitQty: 0, unitPrice: '$89.00', subtotal: '$89.00' },
+      { sku: 'SKU-TAG-SET-GRY', title: 'Travel Tag Set / Gray', qty: 2, splitQty: 0, unitPrice: '$19.50', subtotal: '$39.00' }
+    ],
+    'OMS-20260419-0017': [
+      { sku: 'SKU-BAG-ML-BRW', title: 'Classic Leather Tote / Brown', qty: 1, splitQty: 0, unitPrice: '£65.00', subtotal: '£65.00' }
+    ],
+    'OMS-20260419-0022': [
+      { sku: 'SKU-SPRT-YGA-BLU', title: 'Yoga Mat Pro / Blue', qty: 1, splitQty: 0, unitPrice: '¥4,200', subtotal: '¥4,200' },
+      { sku: 'SKU-SPRT-BTL-GRN', title: 'Sports Bottle 750ml / Green', qty: 3, splitQty: 0, unitPrice: '¥1,500', subtotal: '¥4,500' }
+    ],
+    'OMS-20260418-0008': [
+      { sku: 'SKU-LUGG-28-NVY', title: 'TravelFlex Large Check-In / Navy', qty: 1, splitQty: 0, unitPrice: '$129.00', subtotal: '$129.00' }
+    ],
+    'OMS-20260418-0015': [
+      { sku: 'SKU-BAG-BPK-OLV', title: 'Urban Backpack / Olive', qty: 2, splitQty: 0, unitPrice: '£45.00', subtotal: '£90.00' }
+    ],
+  };
+  lineItems.value = lineItemsMap[order.orderNo || ''] || [
+    { sku: 'SKU-GEN-001', title: '通用商品 / Default', qty: 1, splitQty: 0, unitPrice: '-', subtotal: '-' }
   ];
 
-  timeline.value = [
-    { time: '2026-04-19 21:16', title: '地址校验告警', summary: '地址楼栋信息不完整,标记为"地址需复核"', type: 'warning' },
-    { time: '2026-04-19 21:10', title: '库存锁定完成', summary: '系统自动锁定 SKU-LUGG-20-BLK × 1, SKU-TAG-SET-GRY × 2', type: 'success' },
-    { time: '2026-04-19 20:43', title: '支付确认', summary: '状态 created → paid,金额 $128.00', type: 'primary' },
-    { time: '2026-04-19 20:42', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+  // Dynamic timeline based on order
+  const timelineMap: Record<string, typeof timeline.value> = {
+    'OMS-20260419-0012': [
+      { time: '2026-04-19 21:16', title: '地址校验告警', summary: '地址楼栋信息不完整,标记为"地址需复核"', type: 'warning' },
+      { time: '2026-04-19 21:10', title: '库存锁定完成', summary: '系统自动锁定 SKU-LUGG-20-BLK × 1, SKU-TAG-SET-GRY × 2', type: 'success' },
+      { time: '2026-04-19 20:43', title: '支付确认', summary: '状态 created → paid,金额 $128.00', type: 'primary' },
+      { time: '2026-04-19 20:42', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+    ],
+    'OMS-20260419-0017': [
+      { time: '2026-04-19 21:12', title: '库存锁定完成', summary: '系统自动锁定 SKU-BAG-ML-BRW × 1', type: 'success' },
+      { time: '2026-04-19 21:09', title: '支付确认', summary: '状态 created → paid,金额 £65.00', type: 'primary' },
+      { time: '2026-04-19 21:08', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+    ],
+    'OMS-20260419-0022': [
+      { time: '2026-04-19 21:35', title: '库存锁定完成', summary: '系统自动锁定 SKU-SPRT-YGA-BLU × 1, SKU-SPRT-BTL-GRN × 3', type: 'success' },
+      { time: '2026-04-19 21:32', title: '支付确认', summary: '状态 created → paid,金额 ¥8,700', type: 'primary' },
+      { time: '2026-04-19 21:31', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+    ],
+    'OMS-20260418-0008': [
+      { time: '2026-04-18 09:15', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+    ],
+    'OMS-20260418-0015': [
+      { time: '2026-04-18 10:48', title: '库存锁定完成', summary: '系统自动锁定 SKU-BAG-BPK-OLV × 2', type: 'success' },
+      { time: '2026-04-18 10:46', title: '支付确认', summary: '状态 created → paid,金额 £90.00', type: 'primary' },
+      { time: '2026-04-18 10:45', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
+    ],
+  };
+  timeline.value = timelineMap[order.orderNo || ''] || [
+    { time: order.createdAt || '-', title: '订单接入 OMS', summary: `来自 ${order.channel} 的订单推送`, type: 'primary' }
   ];
 };
 

+ 62 - 4
src/views/order/OrderListView.vue

@@ -50,7 +50,7 @@
 
     <!-- 订单列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%" @selection-change="onSelection" :row-class-name="rowClass">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading" @selection-change="onSelection" :row-class-name="rowClass">
         <el-table-column type="selection" width="45" />
         <el-table-column prop="orderNo" label="订单号" width="190" />
         <el-table-column prop="channel" label="渠道" width="120" />
@@ -80,22 +80,61 @@
           <template #default="{ row }">
             <el-button link type="primary" @click="$router.push(`/order/detail?id=${row.id}`)">详情</el-button>
             <el-button link type="primary" @click="$router.push('/order/after-sale')">售后</el-button>
+            <el-button link type="primary" @click="openAssign(row)">分配处理人</el-button>
+            <el-button link type="primary" @click="openAddTag(row)">添加标签</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
+
+    <el-dialog v-model="assignDialog" title="分配处理人" width="400px">
+      <el-form label-position="top">
+        <el-form-item label="选择处理人">
+          <el-select v-model="assignPerson" style="width:100%">
+            <el-option label="运营组 A / 陈欣" value="运营组 A / 陈欣" />
+            <el-option label="运营组 B / 王磊" value="运营组 B / 王磊" />
+            <el-option label="客服组 / 张丽" value="客服组 / 张丽" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="assignDialog = false">取消</el-button>
+        <el-button type="primary" @click="confirmAssign">确认分配</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="tagDialog" title="添加标签" width="400px">
+      <el-form label-position="top">
+        <el-form-item label="标签内容">
+          <el-input v-model="tagContent" placeholder="输入标签" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="tagDialog = false">取消</el-button>
+        <el-button type="primary" @click="confirmTag">确认</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import { api } from '@/api/services';
 import type { OrderItem } from '@/types/page';
 
 const items = ref<OrderItem[]>([]);
 const selected = ref<OrderItem[]>([]);
 const savedView = ref('');
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const orderStatuses = ['created', 'paid', 'allocated', 'shipped', 'delivered', 'completed', 'cancelled', 'refunded'];
 
 const filters = ref({ orderNo: '', channel: '', orderStatus: '', exceptionTag: '' });
@@ -110,6 +149,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const statusType = (s: string) => {
   const map: Record<string, string> = { created: 'info', paid: '', allocated: 'warning', shipped: '', delivered: 'success', completed: 'success', cancelled: 'danger', refunded: 'danger' };
   return map[s] || '';
@@ -125,8 +166,13 @@ const rowClass = ({ row }: { row: OrderItem }) => {
 };
 
 const loadData = async () => {
-  const res = await api.getOrders();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getOrders();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {
@@ -148,6 +194,18 @@ const batchMark = () => {
   ElMessage.success(`已标记 ${selected.value.length} 个订单`);
 };
 
+const assignDialog = ref(false);
+const assignPerson = ref('');
+const assignTarget = ref<OrderItem | null>(null);
+const tagDialog = ref(false);
+const tagContent = ref('');
+const tagTarget = ref<OrderItem | null>(null);
+
+const openAssign = (row: OrderItem) => { assignTarget.value = row; assignPerson.value = ''; assignDialog.value = true; };
+const confirmAssign = () => { ElMessage.success(`已分配 ${assignPerson.value}`); assignDialog.value = false; };
+const openAddTag = (row: OrderItem) => { tagTarget.value = row; tagContent.value = ''; tagDialog.value = true; };
+const confirmTag = () => { ElMessage.success(`已添加标签:${tagContent.value}`); tagDialog.value = false; };
+
 onMounted(loadData);
 </script>
 

+ 16 - 3
src/views/product/ProductEditorView.vue

@@ -218,10 +218,10 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from 'vue';
-import { useRoute } from 'vue-router';
+import { computed, onMounted, reactive, ref, watch } from 'vue';
+import { useRoute, onBeforeRouteLeave } from 'vue-router';
 import { Plus } from '@element-plus/icons-vue';
-import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import { api } from '@/api/services';
 import type { ProductItem } from '@/types/page';
 
@@ -234,6 +234,7 @@ const detailImagesText = ref('https://img.mock/detail-1.jpg\nhttps://img.mock/de
 const baseFormRef = ref<FormInstance>();
 
 const isEdit = computed(() => !!route.query.id);
+const isDirty = ref(false);
 
 const form = reactive({
   title: '',
@@ -274,6 +275,16 @@ const readyForPublish = computed(() =>
   Boolean(form.title && form.mainImage && form.locales.en.title && skuRows.value.length)
 );
 
+watch(() => ({ ...form }), () => { isDirty.value = true; }, { deep: true });
+
+onBeforeRouteLeave((_to, _from, next) => {
+  if (isDirty.value) {
+    ElMessageBox.confirm('有未保存的修改,确认离开?', '提示', { type: 'warning' }).then(() => next()).catch(() => next(false));
+  } else {
+    next();
+  }
+});
+
 const onCategoryChange = () => {
   /* 类目切换后清空属性模板相关 */
 };
@@ -306,6 +317,7 @@ const saveDraft = async () => {
     await api.createProduct({ title: form.title, category: form.category, brand: form.brand, status: '草稿' } as Partial<ProductItem>);
   }
   ElMessage.success('草稿已保存');
+  isDirty.value = false;
 };
 
 const submitForValidation = () => {
@@ -314,6 +326,7 @@ const submitForValidation = () => {
     return;
   }
   ElMessage.success('校验通过,可进入渠道映射页发布');
+  isDirty.value = false;
 };
 
 onMounted(async () => {

+ 56 - 3
src/views/product/ProductListView.vue

@@ -44,6 +44,7 @@
           <el-button @click="showImport = true">批量导入</el-button>
           <el-button :disabled="!selected.length" @click="batchAction('上架')">批量上架</el-button>
           <el-button :disabled="!selected.length" @click="batchAction('下架')">批量下架</el-button>
+          <el-button @click="showPublishLog = true">查看发布日志</el-button>
         </div>
         <el-button @click="loadData">刷新</el-button>
       </div>
@@ -51,7 +52,7 @@
 
     <!-- 列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%" @selection-change="onSelection">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading" @selection-change="onSelection">
         <el-table-column type="selection" width="45" />
         <el-table-column label="主图" width="72">
           <template #default="{ row }">
@@ -90,11 +91,18 @@
           <template #default="{ row }">
             <el-button link type="primary" @click="$router.push(`/product/editor?id=${row.id}`)">编辑</el-button>
             <el-button link type="primary" @click="$router.push('/product/mapping')">映射</el-button>
+            <el-button link type="primary" @click="copyProduct(row)">复制</el-button>
             <el-button link type="primary" @click="toggleStatus(row)">{{ row.status === '已上架' ? '下架' : '上架' }}</el-button>
             <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 批量导入弹窗 -->
@@ -111,6 +119,25 @@
         <el-button type="primary" @click="doImport">确认导入</el-button>
       </template>
     </el-dialog>
+
+    <!-- 发布日志弹窗 -->
+    <el-dialog v-model="showPublishLog" title="渠道发布日志" width="620px">
+      <el-table :data="publishLogs" stripe size="small">
+        <el-table-column prop="time" label="时间" width="160" />
+        <el-table-column prop="spu" label="SPU" width="140" />
+        <el-table-column prop="channel" label="渠道" width="120" />
+        <el-table-column prop="action" label="操作" width="80" />
+        <el-table-column prop="result" label="结果" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.result === '成功' ? 'success' : 'danger'" size="small">{{ row.result }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="detail" label="详情" min-width="180" />
+      </el-table>
+      <template #footer>
+        <el-button @click="showPublishLog = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -124,6 +151,10 @@ import type { ProductItem } from '@/types/page';
 const items = ref<ProductItem[]>([]);
 const selected = ref<ProductItem[]>([]);
 const showImport = ref(false);
+const showPublishLog = ref(false);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 
 const filters = ref({
   keyword: '',
@@ -147,6 +178,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const statusType = (s: string) => {
   if (s === '已上架') return 'success';
   if (s === '草稿') return 'info';
@@ -154,8 +187,13 @@ const statusType = (s: string) => {
 };
 
 const loadData = async () => {
-  const res = await api.getProducts();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getProducts();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {
@@ -196,6 +234,21 @@ const doImport = () => {
   showImport.value = false;
 };
 
+const copyProduct = async (row: ProductItem) => {
+  await ElMessageBox.confirm(`确认复制「${row.title}」创建新商品?`, '复制商品');
+  await api.createProduct({ ...row, id: '', title: row.title + ' (副本)', status: '草稿', spu: row.spu + '-COPY' });
+  ElMessage.success('商品已复制');
+  loadData();
+};
+
+const publishLogs = computed(() => [
+  { time: '2026-04-20 09:15', spu: 'SPU-LUGG-20', channel: 'Shopify US', action: '上架', result: '成功', detail: '已同步至 Shopify' },
+  { time: '2026-04-20 09:10', spu: 'SPU-LUGG-28', channel: 'TikTok UK', action: '上架', result: '失败', detail: '类目属性"材质"未填写' },
+  { time: '2026-04-19 16:30', spu: 'SPU-BAG-ML', channel: 'Shopify JP', action: '更新', result: '成功', detail: '价格已更新' },
+  { time: '2026-04-19 14:00', spu: 'SPU-SPRT-YGA', channel: 'TikTok UK', action: '上架', result: '成功', detail: '已同步至 TikTok Shop' },
+  { time: '2026-04-18 11:20', spu: 'SPU-BAG-BPK', channel: 'Shopify US', action: '下架', result: '成功', detail: '已从 Shopify 下架' },
+]);
+
 onMounted(loadData);
 </script>
 

+ 19 - 3
src/views/product/ProductMappingView.vue

@@ -45,7 +45,7 @@
 
     <!-- 映射列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="internalSku" label="内部 SKU" width="160" />
         <el-table-column prop="productTitle" label="商品" min-width="200" />
         <el-table-column prop="channel" label="渠道" width="120" />
@@ -78,7 +78,13 @@
             <el-button link type="primary" :disabled="row.mappingStatus !== '已映射' || row.validateStatus === '失败'" @click="publishMapping(row)">发布</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 新增/编辑映射弹窗 -->
@@ -128,6 +134,9 @@ import { api } from '@/api/services';
 import type { MappingItem } from '@/types/page';
 
 const items = ref<MappingItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const dialogVisible = ref(false);
 const editingItem = ref<MappingItem | null>(null);
 
@@ -158,9 +167,16 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const loadData = async () => {
-  const res = await api.getMappings();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getMappings();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 19 - 3
src/views/product/ProductPricingView.vue

@@ -46,7 +46,7 @@
 
     <!-- 列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="sku" label="SKU" width="160" />
         <el-table-column prop="productTitle" label="商品" min-width="220" />
         <el-table-column prop="currency" label="币种" width="70" />
@@ -74,7 +74,13 @@
             </el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 新增/编辑弹窗 -->
@@ -146,6 +152,9 @@ import { api } from '@/api/services';
 import type { PricingRuleItem } from '@/types/page';
 
 const items = ref<PricingRuleItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const dialogVisible = ref(false);
 const batchPriceDialog = ref(false);
 const editingItem = ref<PricingRuleItem | null>(null);
@@ -165,9 +174,16 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const loadData = async () => {
-  const res = await api.getPricingRules();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getPricingRules();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 96 - 11
src/views/report/ReportCenterView.vue

@@ -114,14 +114,14 @@
     </section>
 
     <!-- 报表数据 -->
-    <section class="glass-card section-card">
+    <section class="glass-card section-card" v-if="reportData.length">
       <div class="section-card__title" style="margin-bottom:16px">
         <div>
           <h3>{{ reportTypeLabel }} — 报表数据</h3>
           <p>共 {{ reportData.length }} 条指标</p>
         </div>
       </div>
-      <el-table :data="reportData" stripe style="width:100%">
+      <el-table :data="reportData" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="metric" label="指标名称" min-width="200" />
         <el-table-column prop="value" label="数值" width="160" />
         <el-table-column prop="mom" label="环比" width="120">
@@ -135,7 +135,25 @@
           </template>
         </el-table-column>
         <el-table-column prop="dimension" label="维度分组" min-width="200" />
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="reportDataTotal > 0">
+        <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" />
+      </div>
+    </section>
+
+    <!-- 图表区 -->
+    <section v-if="reportData.length" class="page-grid page-grid--two">
+      <article class="glass-card section-card">
+        <h3 style="margin:0 0 16px">{{ reportTypeLabel }} — 指标对比</h3>
+        <v-chart :option="barOption" autoresize style="height:300px" />
+      </article>
+      <article class="glass-card section-card">
+        <h3 style="margin:0 0 16px">{{ reportTypeLabel }} — 环比趋势</h3>
+        <v-chart :option="lineOption" autoresize style="height:300px" />
+      </article>
     </section>
 
     <!-- 报表目录 -->
@@ -146,7 +164,7 @@
           <p>已保存的报表模板列表。</p>
         </div>
       </div>
-      <el-table :data="reportList" stripe style="width:100%">
+      <el-table :data="reportList" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="reportType" label="报表类型" width="160" />
         <el-table-column prop="dimensions" label="维度" min-width="250" />
         <el-table-column prop="owner" label="使用角色" width="140" />
@@ -156,7 +174,13 @@
             <el-button link type="primary" @click="loadSavedReport(row)">查看</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="reportListTotal > 0">
+        <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" />
+      </div>
     </section>
 
     <!-- 保存视图对话框 -->
@@ -177,12 +201,26 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, reactive } from 'vue';
 import { ElMessage } from 'element-plus';
+import VChart from 'vue-echarts';
+import { use } from 'echarts/core';
+import { CanvasRenderer } from 'echarts/renderers';
+import { LineChart, BarChart } from 'echarts/charts';
+import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
 import { api } from '@/api/services';
 import type { ReportItem, ReportDataItem } from '@/types/page';
 
+use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent]);
+
 const reportType = ref('channel');
 const reportList = ref<ReportItem[]>([]);
 const reportData = ref<ReportDataItem[]>([]);
+const loading = ref(false);
+const reportDataPage = ref(1);
+const reportDataPageSize = ref(10);
+const reportDataTotal = computed(() => reportData.value.length);
+const reportListPage = ref(1);
+const reportListPageSize = ref(10);
+const reportListTotal = computed(() => reportList.value.length);
 
 const filters = ref({
   timeRange: null as [Date, Date] | null,
@@ -221,9 +259,46 @@ const trendClass = (val: string) => {
   return '';
 };
 
+const barOption = computed(() => {
+  const metrics = reportData.value.map(r => r.metric);
+  const values = reportData.value.map(r => parseFloat(r.value.replace(/[^0-9.]/g, '')) || 0);
+  return {
+    tooltip: { trigger: 'axis' as const },
+    grid: { left: 80, right: 20, top: 20, bottom: 60 },
+    xAxis: { type: 'category' as const, data: metrics, axisLabel: { rotate: 30 } },
+    yAxis: { type: 'value' as const },
+    series: [{ type: 'bar', data: values, itemStyle: { color: '#409EFF', borderRadius: [4, 4, 0, 0] } }]
+  };
+});
+
+const lineOption = computed(() => {
+  const metrics = reportData.value.map(r => r.metric);
+  const momValues = reportData.value.map(r => {
+    const m = r.mom.match(/[\d.]+/);
+    const sign = r.mom.startsWith('-') || r.mom.startsWith('↓') ? -1 : 1;
+    return m ? sign * parseFloat(m[0]) : 0;
+  });
+  return {
+    tooltip: { trigger: 'axis' as const },
+    grid: { left: 60, right: 20, top: 20, bottom: 60 },
+    xAxis: { type: 'category' as const, data: metrics, axisLabel: { rotate: 30 } },
+    yAxis: { type: 'value' as const, axisLabel: { formatter: '{value}%' } },
+    series: [{
+      type: 'line', smooth: true, data: momValues,
+      areaStyle: { opacity: 0.15 },
+      itemStyle: { color: '#67C23A' }
+    }]
+  };
+});
+
 const loadData = async () => {
-  const res = await api.getReports();
-  reportList.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getReports();
+    reportList.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const onReportTypeChange = () => {
@@ -231,16 +306,26 @@ const onReportTypeChange = () => {
 };
 
 const generateReport = async () => {
-  const res = await api.getReportData();
-  reportData.value = res.items;
-  ElMessage.success('报表数据已加载');
+  loading.value = true;
+  try {
+    const res = await api.getReportData();
+    reportData.value = res.items;
+    ElMessage.success('报表数据已加载');
+  } finally {
+    loading.value = false;
+  }
 };
 
 const loadSavedReport = async (item: ReportItem) => {
   reportType.value = item.reportType === '渠道销售日报' ? 'channel' : 'channel';
-  const res = await api.getReportData();
-  reportData.value = res.items;
-  ElMessage.success(`已加载「${item.reportType}」`);
+  loading.value = true;
+  try {
+    const res = await api.getReportData();
+    reportData.value = res.items;
+    ElMessage.success(`已加载「${item.reportType}」`);
+  } finally {
+    loading.value = false;
+  }
 };
 
 const doExport = (format: string) => {

+ 23 - 7
src/views/supplier/PurchaseOrderView.vue

@@ -45,7 +45,7 @@
 
     <!-- 列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%" @selection-change="onSelection">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading" @selection-change="onSelection">
         <el-table-column type="selection" width="45" />
         <el-table-column prop="poNo" label="采购单号" width="160" />
         <el-table-column prop="supplier" label="供应商" min-width="180" />
@@ -69,7 +69,13 @@
             <el-button link type="danger" @click="closePO(row)" :disabled="row.status === '已关闭' || row.status === 'completed'">关闭</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 创建采购单弹窗 -->
@@ -190,6 +196,9 @@ import type { PurchaseOrderItem, PurchaseOrderFormData } from '@/types/page';
 
 const items = ref<PurchaseOrderItem[]>([]);
 const selected = ref<PurchaseOrderItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const createDialogVisible = ref(false);
 const arrivalDialogVisible = ref(false);
 const submitting = ref(false);
@@ -271,6 +280,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const parseProgress = (p: string) => {
   const num = parseInt(p, 10);
   return isNaN(num) ? 0 : Math.min(100, Math.max(0, num));
@@ -306,12 +317,17 @@ const lineTotal = (item: CreateFormItem) => {
 };
 
 const loadData = async () => {
-  const [poRes, supplierRes] = await Promise.all([
-    api.getPurchaseOrders(),
-    api.getSuppliers()
-  ]);
-  items.value = poRes.items;
-  supplierOptions.value = supplierRes.items.map((s) => s.name);
+  loading.value = true;
+  try {
+    const [poRes, supplierRes] = await Promise.all([
+      api.getPurchaseOrders(),
+      api.getSuppliers()
+    ]);
+    items.value = poRes.items;
+    supplierOptions.value = supplierRes.items.map((s) => s.name);
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 19 - 3
src/views/supplier/SupplierListView.vue

@@ -47,7 +47,7 @@
 
     <!-- 列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="name" label="供应商名称" min-width="200" />
         <el-table-column prop="contact" label="联系人" width="120" />
         <el-table-column prop="phone" label="电话" width="140" />
@@ -70,7 +70,13 @@
             <el-button link type="primary" @click="toggleStatus(row)">{{ row.status === '合作中' ? '停用' : '启用' }}</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 新建/编辑弹窗 -->
@@ -160,6 +166,9 @@ import { api } from '@/api/services';
 import type { SupplierItem, SupplierFormData } from '@/types/page';
 
 const items = ref<SupplierItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const dialogVisible = ref(false);
 const isEdit = ref(false);
 const editId = ref('');
@@ -213,6 +222,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const ratingClass = (r: string) => {
   if (r === 'A') return 'rating-a';
   if (r === 'B') return 'rating-b';
@@ -220,8 +231,13 @@ const ratingClass = (r: string) => {
 };
 
 const loadData = async () => {
-  const res = await api.getSuppliers();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getSuppliers();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 23 - 7
src/views/supplier/SupplyCapabilityView.vue

@@ -42,7 +42,7 @@
 
     <!-- 列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="supplier" label="供应商" min-width="180" />
         <el-table-column prop="sku" label="SKU" width="140">
           <template #default="{ row }">
@@ -81,7 +81,13 @@
             <el-button link type="danger" @click="toggleStatus(row)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 新建/编辑弹窗 -->
@@ -175,6 +181,9 @@ import { api } from '@/api/services';
 import type { SupplyCapabilityItem } from '@/types/page';
 
 const items = ref<SupplyCapabilityItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const dialogVisible = ref(false);
 const isEdit = ref(false);
 const editId = ref('');
@@ -239,13 +248,20 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const loadData = async () => {
-  const [capRes, supplierRes] = await Promise.all([
-    api.getSupplyCapabilities(),
-    api.getSuppliers()
-  ]);
-  items.value = capRes.items;
-  supplierOptions.value = supplierRes.items.map((s) => s.name);
+  loading.value = true;
+  try {
+    const [capRes, supplierRes] = await Promise.all([
+      api.getSupplyCapabilities(),
+      api.getSuppliers()
+    ]);
+    items.value = capRes.items;
+    supplierOptions.value = supplierRes.items.map((s) => s.name);
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 19 - 4
src/views/system/ApiKeyView.vue

@@ -22,7 +22,7 @@
 
     <!-- API Key 表格 -->
     <section class="glass-card section-card">
-      <el-table :data="items" stripe style="width:100%">
+      <el-table :data="items" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="name" label="Key 名称" width="180" />
         <el-table-column prop="system" label="所属系统" width="160" />
         <el-table-column prop="scope" label="权限范围" min-width="220" />
@@ -54,7 +54,13 @@
             <el-button link type="danger" @click="deleteKey(row)">删除</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 创建 Key 对话框 -->
@@ -159,13 +165,17 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, ref, reactive } from 'vue';
+import { computed, onMounted, ref, reactive } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import type { FormInstance, FormRules } from 'element-plus';
 import { api } from '@/api/services';
 import type { ApiKeyItem } from '@/types/page';
 
 const items = ref<ApiKeyItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
+const total = computed(() => items.value.length);
 const creating = ref(false);
 const rotating = ref(false);
 const createVisible = ref(false);
@@ -196,8 +206,13 @@ const createFormRules: FormRules = {
 const rotateForm = reactive({ transitionDays: 7 });
 
 const loadData = async () => {
-  const res = await api.getApiKeys();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getApiKeys();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 /* ---- 创建 Key ---- */

+ 19 - 2
src/views/system/OperationLogView.vue

@@ -72,6 +72,7 @@
         :data="filteredItems"
         stripe
         style="width:100%"
+        v-loading="loading"
         :row-class-name="rowClass"
         @row-click="openDetail"
         highlight-current-row
@@ -96,7 +97,13 @@
             <el-button link type="primary" @click.stop="openDetail(row)">详情</el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 详情抽屉 -->
@@ -149,6 +156,9 @@ import { api } from '@/api/services';
 import type { LogItem } from '@/types/page';
 
 const items = ref<LogItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const detailVisible = ref(false);
 const detailItem = ref<LogItem | null>(null);
 
@@ -172,6 +182,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 const actionClass = (type: string) => {
   return highRiskActions.includes(type) ? 'action-danger' : '';
 };
@@ -181,8 +193,13 @@ const rowClass = ({ row }: { row: LogItem }) => {
 };
 
 const loadData = async () => {
-  const res = await api.getLogs();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getLogs();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {

+ 19 - 3
src/views/system/RolePermissionView.vue

@@ -50,7 +50,7 @@
 
     <!-- 角色列表 -->
     <section class="glass-card section-card">
-      <el-table :data="filteredItems" stripe style="width:100%">
+      <el-table :data="filteredItems" stripe style="width:100%" v-loading="loading">
         <el-table-column prop="name" label="角色名称" width="140" />
         <el-table-column prop="description" label="描述" min-width="220" />
         <el-table-column prop="boundUsers" label="绑定用户数" width="120">
@@ -77,7 +77,13 @@
             </el-button>
           </template>
         </el-table-column>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
       </el-table>
+      <div style="display:flex;justify-content:flex-end;margin-top:16px" v-if="total > 0">
+        <el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />
+      </div>
     </section>
 
     <!-- 角色权限表单抽屉 -->
@@ -164,6 +170,9 @@ import { api } from '@/api/services';
 import type { RoleItem } from '@/types/page';
 
 const items = ref<RoleItem[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
 const permissionDrawerVisible = ref(false);
 const isEditRole = ref(false);
 const editingRoleId = ref('');
@@ -185,6 +194,8 @@ const filteredItems = computed(() => {
   });
 });
 
+const total = computed(() => filteredItems.value.length);
+
 /* ---- 权限树 ---- */
 const pagePermissionTree = [
   {
@@ -281,8 +292,13 @@ const roleFormRules: FormRules = {
 
 /* ---- 数据加载 ---- */
 const loadData = async () => {
-  const res = await api.getRoles();
-  items.value = res.items;
+  loading.value = true;
+  try {
+    const res = await api.getRoles();
+    items.value = res.items;
+  } finally {
+    loading.value = false;
+  }
 };
 
 const resetFilters = () => {