瀏覽代碼

完善功能

Gogs 1 月之前
父節點
當前提交
b59db792da

+ 6 - 2
apps/factory-app/src/App.vue

@@ -24,7 +24,9 @@ import {
   UnorderedListOutlined,
   DollarOutlined,
   PayCircleOutlined,
-  UserOutlined
+  UserOutlined,
+  DatabaseOutlined,
+  BarChartOutlined
 } from '@ant-design/icons-vue';
 import BasicLayout from '@/layouts/BasicLayout.vue';
 import { useAuthStore } from '@/stores/auth';
@@ -50,7 +52,9 @@ onMounted(() => {
       { key: 'records', label: '计件记录', icon: UnorderedListOutlined, path: '/records' },
       { key: 'prices', label: '工价设置', icon: DollarOutlined, path: '/prices' },
       { key: 'salary', label: '工资管理', icon: PayCircleOutlined, path: '/salary' },
-      { key: 'users', label: '用户管理', icon: UserOutlined, path: '/users' }
+      { key: 'users', label: '用户管理', icon: UserOutlined, path: '/users' },
+      { key: 'stats', label: '数据统计', icon: BarChartOutlined, path: '/stats' },
+      { key: 'backup', label: '数据备份', icon: DatabaseOutlined, path: '/backup' }
     ]
   });
   handleResize();

+ 12 - 0
apps/factory-app/src/router/index.ts

@@ -58,6 +58,18 @@ const routes = [
     component: () => import('@/views/UsersView.vue'),
     meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '用户管理'] }
   },
+  {
+    path: '/backup',
+    name: 'Backup',
+    component: () => import('@/views/BackupView.vue'),
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '数据备份'] }
+  },
+  {
+    path: '/stats',
+    name: 'Stats',
+    component: () => import('@/views/StatsView.vue'),
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '数据统计'] }
+  },
   {
     path: '/change-password',
     name: 'ChangePassword',

+ 198 - 0
apps/factory-app/src/views/BackupView.vue

@@ -0,0 +1,198 @@
+<template>
+  <div class="p-4 md:p-8">
+    <!-- 头部工具栏 -->
+    <a-card class="mb-6 bg-gray-50 rounded-lg shadow-sm !border-0">
+      <a-space size="large" wrap>
+        <a-button type="primary" :loading="creating" @click="createBackup" class="shadow-sm hover:shadow-md transition-shadow">
+          <PlusOutlined class="mr-2" />
+          创建备份
+        </a-button>
+        <a-button @click="loadBackups" class="shadow-sm hover:shadow-md transition-shadow">
+          <ReloadOutlined class="mr-2" />
+          刷新
+        </a-button>
+      </a-space>
+    </a-card>
+
+    <!-- 桌面端:备份列表表格 -->
+    <a-card v-if="!layoutStore.isMobile" class="rounded-xl shadow-md !border-0" title="备份文件列表">
+      <a-table :columns="columns" :dataSource="backups" :loading="loading" rowKey="filename">
+        <template #size="{ record }">
+          {{ formatSize(record.size) }}
+        </template>
+        <template #action="{ record }">
+          <a-space>
+            <a-button size="small" @click="downloadBackup(record.filename)" class="hover:shadow-sm transition-shadow">
+              <DownloadOutlined class="mr-1" />
+              下载
+            </a-button>
+            <a-popconfirm
+              title="恢复备份将覆盖当前数据,恢复前会自动创建预恢复快照。确定恢复吗?"
+              ok-text="确定恢复"
+              cancel-text="取消"
+              @confirm="restoreBackup(record.filename)"
+            >
+              <a-button size="small" type="primary" :loading="restoring === record.filename" class="hover:shadow-sm transition-shadow">
+                <UndoOutlined class="mr-1" />
+                恢复
+              </a-button>
+            </a-popconfirm>
+            <a-popconfirm
+              title="确定删除此备份文件吗?此操作不可恢复。"
+              ok-text="确定删除"
+              cancel-text="取消"
+              @confirm="deleteBackup(record.filename)"
+            >
+              <a-button size="small" danger class="hover:shadow-sm transition-shadow">
+                <DeleteOutlined class="mr-1" />
+                删除
+              </a-button>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </a-table>
+    </a-card>
+
+    <!-- 移动端:备份卡片列表 -->
+    <div v-else class="space-y-3">
+      <a-spin :spinning="loading">
+        <a-empty v-if="!loading && backups.length === 0" description="暂无备份文件" class="py-8" />
+        <a-card
+          v-for="item in backups"
+          :key="item.filename"
+          class="rounded-xl shadow-sm !border-0"
+          size="small"
+        >
+          <div class="flex items-start justify-between gap-2 mb-2">
+            <span class="font-medium text-gray-900 break-all text-sm">{{ item.filename }}</span>
+          </div>
+          <div class="text-sm text-gray-500 space-y-1 mb-3">
+            <div>大小:{{ formatSize(item.size) }}</div>
+            <div>创建时间:{{ item.created_at }}</div>
+          </div>
+          <div class="flex items-center gap-2 pt-3 border-t border-gray-100">
+            <a-button size="small" @click="downloadBackup(item.filename)">
+              <DownloadOutlined class="mr-1" />
+              下载
+            </a-button>
+            <a-popconfirm title="确定恢复此备份?" @confirm="restoreBackup(item.filename)">
+              <a-button size="small" type="primary">
+                <UndoOutlined class="mr-1" />
+                恢复
+              </a-button>
+            </a-popconfirm>
+            <a-popconfirm title="确定删除此备份?" @confirm="deleteBackup(item.filename)">
+              <a-button size="small" danger>
+                <DeleteOutlined class="mr-1" />
+                删除
+              </a-button>
+            </a-popconfirm>
+          </div>
+        </a-card>
+      </a-spin>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { message } from 'ant-design-vue';
+import { PlusOutlined, ReloadOutlined, DownloadOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import { BackupApi, createApiClient } from '@smartcut/api-client';
+import { STORAGE_KEYS } from '@smartcut/shared-utils';
+import { getApiBaseUrl } from '@/apiConfig';
+import { useLayoutStore } from '@/stores/layout';
+import type { BackupFile } from '@smartcut/types';
+
+const apiClient = createApiClient({
+  baseURL: getApiBaseUrl(),
+  tokenKey: STORAGE_KEYS.FACTORY_TOKEN
+});
+
+const backupApi = new BackupApi(apiClient);
+
+const layoutStore = useLayoutStore();
+
+const backups = ref<BackupFile[]>([]);
+const loading = ref(false);
+const creating = ref(false);
+const restoring = ref<string | null>(null);
+
+const columns = [
+  { title: '文件名', dataIndex: 'filename', key: 'filename', width: 300 },
+  { title: '大小', key: 'size', width: 120, slots: { customRender: 'size' } },
+  { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 200 },
+  { title: '操作', key: 'action', width: 300, slots: { customRender: 'action' } }
+];
+
+function formatSize(bytes: number): string {
+  if (!bytes || bytes <= 0) return '-';
+  if (bytes < 1024) return bytes + ' B';
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
+}
+
+async function loadBackups() {
+  loading.value = true;
+  try {
+    backups.value = await backupApi.listBackups();
+  } catch (error) {
+    message.error('加载备份列表失败');
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function createBackup() {
+  creating.value = true;
+  try {
+    await backupApi.createBackup();
+    message.success('备份创建成功');
+    loadBackups();
+  } catch (error: any) {
+    message.error(error.response?.data?.msg || '创建备份失败');
+  } finally {
+    creating.value = false;
+  }
+}
+
+async function downloadBackup(filename: string) {
+  try {
+    const blob = await backupApi.downloadBackup(filename);
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    a.click();
+    URL.revokeObjectURL(url);
+  } catch (error) {
+    message.error('下载备份失败');
+  }
+}
+
+async function restoreBackup(filename: string) {
+  restoring.value = filename;
+  try {
+    await backupApi.restoreBackup(filename);
+    message.success('备份已恢复');
+  } catch (error: any) {
+    message.error(error.response?.data?.msg || '恢复备份失败');
+  } finally {
+    restoring.value = null;
+  }
+}
+
+async function deleteBackup(filename: string) {
+  try {
+    await backupApi.deleteBackup(filename);
+    message.success('备份已删除');
+    loadBackups();
+  } catch (error: any) {
+    message.error(error.response?.data?.msg || '删除备份失败');
+  }
+}
+
+onMounted(() => {
+  loadBackups();
+});
+</script>

+ 4 - 4
apps/factory-app/src/views/CutBatchesView.vue

@@ -321,7 +321,7 @@ async function showBundlesModal(record: CutBatch) {
   currentBatch.value = record;
   bundlesModalVisible.value = true;
   try {
-    const response = await apiClient.get(`/cut-batches/${record.id}/bundles`);
+    const response = await apiClient.get('/cut-bundles', { params: { batch_id: record.id } });
     bundles.value = response.data.data || [];
   } catch (error) {
     message.error('加载扎号列表失败');
@@ -358,7 +358,7 @@ async function handleAddBundle() {
 
 async function deleteBundle(id: number) {
   try {
-    await apiClient.delete(`/cut-batches/bundles/${id}`);
+    await apiClient.delete(`/cut-bundles/${id}`);
     message.success('扎号已删除');
     if (currentBatch.value) showBundlesModal(currentBatch.value);
   } catch (error) {
@@ -369,7 +369,7 @@ async function deleteBundle(id: number) {
 async function showBundleQRCode(bundle: CutBundle) {
   currentBundle.value = bundle;
   try {
-    const response = await apiClient.get(`/cut-batches/bundles/${bundle.id}/qrcode`, { responseType: 'blob' });
+    const response = await apiClient.get(`/cut-bundles/${bundle.id}/qrcode`, { responseType: 'blob' });
     bundleQRUrl.value = URL.createObjectURL(response.data);
     bundleQRVisible.value = true;
   } catch (error) {
@@ -380,7 +380,7 @@ async function showBundleQRCode(bundle: CutBundle) {
 async function generateAllQRCodes() {
   if (!currentBatch.value) return;
   try {
-    const response = await apiClient.get(`/cut-batches/${currentBatch.value.id}/qrcodes`, { responseType: 'blob' });
+    const response = await apiClient.get(`/cut-batches/${currentBatch.value.id}/bundles/qrcode`, { responseType: 'blob' });
     const url = URL.createObjectURL(response.data);
     const a = document.createElement('a');
     a.href = url;

+ 51 - 22
apps/factory-app/src/views/PricesView.vue

@@ -3,15 +3,30 @@
     <!-- 头部工具栏 -->
     <a-card class="mb-6 bg-gray-50 rounded-lg shadow-sm !border-0">
       <a-space size="large" wrap>
-        <a-select v-model:value="filterWorkOrderId" placeholder="筛选工单" class="w-full sm:w-64" allowClear @change="loadPrices">
-          <a-select-option v-for="wo in workOrders" :key="wo.id" :value="wo.id">
-            {{ wo.order_no }} - {{ wo.style_name }}
+        <a-select
+          v-model:value="filterStyle"
+          placeholder="筛选款号"
+          class="w-full sm:w-64"
+          allowClear
+          @change="loadPrices"
+        >
+          <a-select-option v-for="style in styleOptions" :key="style" :value="style">
+            {{ style }}
           </a-select-option>
         </a-select>
         <a-button type="primary" @click="showCreateModal" class="shadow-sm hover:shadow-md transition-shadow">
           <PlusOutlined class="mr-2" />
           设置工价
         </a-button>
+        <a-popconfirm
+          title="将根据当前工价模板补录所有工价缺失的计件记录,确定执行?"
+          @confirm="backfillPrices"
+        >
+          <a-button :loading="backfilling" class="shadow-sm hover:shadow-md transition-shadow">
+            <ThunderboltOutlined class="mr-2" />
+            补录工价
+          </a-button>
+        </a-popconfirm>
       </a-space>
     </a-card>
 
@@ -99,7 +114,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted, computed } from 'vue';
 import { message } from 'ant-design-vue';
-import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import { PlusOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons-vue';
 import { WorkOrderApi, ProcessApi, createApiClient } from '@smartcut/api-client';
 import { STORAGE_KEYS } from '@smartcut/shared-utils';
 import { getApiBaseUrl } from '@/apiConfig';
@@ -121,7 +136,15 @@ const prices = ref<PriceTemplate[]>([]);
 const workOrders = ref<WorkOrder[]>([]);
 const processes = ref<Process[]>([]);
 const loading = ref(false);
-const filterWorkOrderId = ref<number | null>(null);
+const filterStyle = ref<string | undefined>(undefined);
+const backfilling = ref(false);
+
+const styleOptions = computed(() => {
+  const styles = new Set<string>();
+  workOrders.value.forEach(wo => { if (wo.style) styles.add(wo.style); });
+  prices.value.forEach(p => { if (p.style) styles.add(p.style); });
+  return Array.from(styles);
+});
 
 const columns = [
   { title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
@@ -156,7 +179,7 @@ async function loadPrices() {
   loading.value = true;
   try {
     const params: any = {};
-    if (filterWorkOrderId.value) params.work_order_id = filterWorkOrderId.value;
+    if (filterStyle.value) params.style = filterStyle.value;
     const response = await apiClient.get('/prices', { params });
     prices.value = response.data.data || [];
   } catch (error) {
@@ -209,22 +232,14 @@ async function handleSubmit() {
   try {
     await formRef.value.validate();
     submitting.value = true;
-    if (isEdit.value && editingId.value) {
-      await apiClient.put(`/prices/${editingId.value}`, {
-        process_id: formState.process_id,
-        price: formState.price,
-        remark: formState.remark
-      });
-      message.success('工价已更新');
-    } else {
-      await apiClient.post('/prices', {
-        style: formState.style,
-        process_id: formState.process_id,
-        price: formState.price,
-        remark: formState.remark
-      });
-      message.success('工价已创建');
-    }
+    // 后端 POST /prices 为 create-or-update 语义:同款式同工序自动更新
+    await apiClient.post('/prices', {
+      style: formState.style,
+      process_id: formState.process_id,
+      price: formState.price,
+      remark: formState.remark
+    });
+    message.success(isEdit.value ? '工价已更新' : '工价已创建');
     modalVisible.value = false;
     loadPrices();
   } catch (error: any) {
@@ -234,6 +249,20 @@ async function handleSubmit() {
   }
 }
 
+async function backfillPrices() {
+  backfilling.value = true;
+  try {
+    const response = await apiClient.post('/prices/backfill');
+    const updated = response.data?.data?.updated ?? response.data?.data ?? 0;
+    message.success(`补录完成,共更新 ${updated} 条记录`);
+    loadPrices();
+  } catch (error: any) {
+    message.error(error.response?.data?.msg || '补录失败');
+  } finally {
+    backfilling.value = false;
+  }
+}
+
 async function deletePrice(id: number) {
   try {
     await apiClient.delete(`/prices/${id}`);

+ 217 - 0
apps/factory-app/src/views/StatsView.vue

@@ -0,0 +1,217 @@
+<template>
+  <div class="p-4 md:p-8">
+    <a-card class="rounded-xl shadow-md !border-0">
+      <a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
+        <!-- 生产进度 -->
+        <a-tab-pane key="production" tab="生产进度">
+          <a-input-search
+            v-model:value="prodKeyword"
+            placeholder="搜索款号或工单号"
+            class="w-full sm:w-80 mb-4"
+            @search="loadProduction"
+          />
+          <a-table
+            :columns="prodColumns"
+            :dataSource="productionData"
+            :loading="loadingProduction"
+            rowKey="work_order_id"
+          >
+            <template #progress="{ record }">
+              <a-progress :percent="Math.round(record.progress)" :status="record.progress >= 100 ? 'success' : 'active'" />
+            </template>
+          </a-table>
+        </a-tab-pane>
+
+        <!-- 员工排行 -->
+        <a-tab-pane key="ranking" tab="员工排行">
+          <a-space class="mb-4" wrap>
+            <a-select v-model:value="rankingPeriod" placeholder="统计周期" class="w-40" @change="loadRanking">
+              <a-select-option value="month">本月</a-select-option>
+              <a-select-option value="week">本周</a-select-option>
+              <a-select-option value="day">今日</a-select-option>
+            </a-select>
+          </a-space>
+          <a-table
+            :columns="rankColumns"
+            :dataSource="rankingData"
+            :loading="loadingRanking"
+            rowKey="user_id"
+          >
+            <template #rank="{ record }">
+              <a-tag :color="getRankColor(record.rank)" class="!rounded-full">
+                第 {{ record.rank }} 名
+              </a-tag>
+            </template>
+            <template #amount="{ record }">
+              ¥{{ record.total_amount.toFixed(2) }}
+            </template>
+          </a-table>
+        </a-tab-pane>
+
+        <!-- 进度矩阵 -->
+        <a-tab-pane key="matrix" tab="进度矩阵">
+          <a-space class="mb-4" wrap>
+            <a-select v-model:value="groupBy" placeholder="分组维度" class="w-40" @change="loadMatrix">
+              <a-select-option value="work_order">按工单</a-select-option>
+              <a-select-option value="style">按款号</a-select-option>
+              <a-select-option value="batch">按批次</a-select-option>
+              <a-select-option value="name">按员工</a-select-option>
+            </a-select>
+            <a-input-search
+              v-model:value="matrixKeyword"
+              placeholder="搜索关键词"
+              class="w-full sm:w-64"
+              @search="loadMatrix"
+            />
+          </a-space>
+          <a-table
+            :columns="matrixColumns"
+            :dataSource="matrixData"
+            :loading="loadingMatrix"
+            rowKey="group_key"
+            :scroll="{ x: 800 }"
+          >
+            <template #progress="{ record }">
+              <a-progress
+                :percent="record.total_qty > 0 ? Math.round((record.completed_qty / record.total_qty) * 100) : 0"
+                :status="record.completed_qty >= record.total_qty ? 'success' : 'active'"
+                size="small"
+              />
+            </template>
+          </a-table>
+        </a-tab-pane>
+      </a-tabs>
+    </a-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { message } from 'ant-design-vue';
+import { StatsApi, createApiClient } from '@smartcut/api-client';
+import { STORAGE_KEYS } from '@smartcut/shared-utils';
+import { getApiBaseUrl } from '@/apiConfig';
+import type { ProductionData, RankingData, ProgressMatrixData } from '@smartcut/types';
+
+const apiClient = createApiClient({
+  baseURL: getApiBaseUrl(),
+  tokenKey: STORAGE_KEYS.FACTORY_TOKEN
+});
+
+const statsApi = new StatsApi(apiClient);
+
+const activeTab = ref('production');
+
+// 生产进度
+const productionData = ref<ProductionData[]>([]);
+const loadingProduction = ref(false);
+const prodKeyword = ref('');
+
+const prodColumns = [
+  { title: '工单号', dataIndex: 'order_no', key: 'order_no', width: 150 },
+  { title: '款号', dataIndex: 'style', key: 'style', width: 120 },
+  { title: '款号名称', dataIndex: 'style_name', key: 'style_name', width: 200 },
+  { title: '总数量', dataIndex: 'total_qty', key: 'total_qty', width: 100 },
+  { title: '已完成', dataIndex: 'completed_qty', key: 'completed_qty', width: 100 },
+  { title: '进度', key: 'progress', width: 200, slots: { customRender: 'progress' } }
+];
+
+// 员工排行
+const rankingData = ref<RankingData[]>([]);
+const loadingRanking = ref(false);
+const rankingPeriod = ref('month');
+
+const rankColumns = [
+  { title: '排名', key: 'rank', width: 100, slots: { customRender: 'rank' } },
+  { title: '员工', dataIndex: 'user_name', key: 'user_name', width: 150 },
+  { title: '总数量', dataIndex: 'total_quantity', key: 'total_quantity', width: 120 },
+  { title: '总金额', key: 'amount', width: 150, slots: { customRender: 'amount' } }
+];
+
+// 进度矩阵
+const matrixData = ref<ProgressMatrixData[]>([]);
+const loadingMatrix = ref(false);
+const groupBy = ref('work_order');
+const matrixKeyword = ref('');
+
+const matrixColumns = ref<any[]>([
+  { title: '分组', dataIndex: 'group_name', key: 'group_name', width: 200 },
+  { title: '总数量', dataIndex: 'total_qty', key: 'total_qty', width: 100 },
+  { title: '已完成', dataIndex: 'completed_qty', key: 'completed_qty', width: 100 },
+  { title: '进度', key: 'progress', width: 200, slots: { customRender: 'progress' } }
+]);
+
+function getRankColor(rank: number): string {
+  if (rank === 1) return 'gold';
+  if (rank === 2) return 'silver';
+  if (rank === 3) return 'orange';
+  return 'default';
+}
+
+async function loadProduction() {
+  loadingProduction.value = true;
+  try {
+    productionData.value = await statsApi.getProduction();
+  } catch (error) {
+    message.error('加载生产进度失败');
+  } finally {
+    loadingProduction.value = false;
+  }
+}
+
+async function loadRanking() {
+  loadingRanking.value = true;
+  try {
+    rankingData.value = await statsApi.getRanking({ period: rankingPeriod.value });
+  } catch (error) {
+    message.error('加载员工排行失败');
+  } finally {
+    loadingRanking.value = false;
+  }
+}
+
+async function loadMatrix() {
+  loadingMatrix.value = true;
+  try {
+    matrixData.value = await statsApi.getProgressMatrix({
+      group_by: groupBy.value,
+      keyword: matrixKeyword.value || undefined
+    });
+    // 动态生成工序列
+    const processSet = new Set<string>();
+    matrixData.value.forEach(item => {
+      Object.keys(item.process_progress || {}).forEach(k => processSet.add(k));
+    });
+    const processCols = Array.from(processSet).map(pname => ({
+      title: pname,
+      key: `proc_${pname}`,
+      width: 100,
+      customRender: ({ record }: { record: ProgressMatrixData }) => {
+        const val = record.process_progress?.[pname];
+        return val != null ? val : '-';
+      }
+    }));
+    matrixColumns.value = [
+      { title: '分组', dataIndex: 'group_name', key: 'group_name', width: 200 },
+      { title: '总数量', dataIndex: 'total_qty', key: 'total_qty', width: 100 },
+      { title: '已完成', dataIndex: 'completed_qty', key: 'completed_qty', width: 100 },
+      ...processCols,
+      { title: '进度', key: 'progress', width: 200, slots: { customRender: 'progress' } }
+    ];
+  } catch (error) {
+    message.error('加载进度矩阵失败');
+  } finally {
+    loadingMatrix.value = false;
+  }
+}
+
+function handleTabChange(key: string) {
+  if (key === 'production' && productionData.value.length === 0) loadProduction();
+  if (key === 'ranking' && rankingData.value.length === 0) loadRanking();
+  if (key === 'matrix' && matrixData.value.length === 0) loadMatrix();
+}
+
+onMounted(() => {
+  loadProduction();
+});
+</script>

+ 3 - 2
apps/platform-app/src/App.vue

@@ -16,7 +16,7 @@ import { computed, onMounted, onUnmounted } from 'vue';
 import { useRouter, useRoute } from 'vue-router';
 import { message } from 'ant-design-vue';
 import zhCN from 'ant-design-vue/es/locale/zh_CN';
-import { DashboardOutlined, ShopOutlined, UserOutlined } from '@ant-design/icons-vue';
+import { DashboardOutlined, ShopOutlined, UserOutlined, FileSearchOutlined } from '@ant-design/icons-vue';
 import BasicLayout from '@/layouts/BasicLayout.vue';
 import { useAuthStore } from '@/stores/auth';
 import { useLayoutStore } from '@/stores/layout';
@@ -36,7 +36,8 @@ onMounted(() => {
     menuItems: [
       { key: 'dashboard', label: '主页看板', icon: DashboardOutlined, path: '/' },
       { key: 'factories', label: '工厂管理', icon: ShopOutlined, path: '/factories' },
-      { key: 'users', label: '系统用户', icon: UserOutlined, path: '/users' }
+      { key: 'users', label: '系统用户', icon: UserOutlined, path: '/users' },
+      { key: 'audit-logs', label: '审计日志', icon: FileSearchOutlined, path: '/audit-logs' }
     ]
   });
   handleResize();

+ 6 - 0
apps/platform-app/src/router/index.ts

@@ -28,6 +28,12 @@ const routes = [
     component: () => import('@/views/UsersView.vue'),
     meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '系统用户'] }
   },
+  {
+    path: '/audit-logs',
+    name: 'AuditLogs',
+    component: () => import('@/views/AuditLogsView.vue'),
+    meta: { requiresAuth: true, showLayout: true, breadcrumb: ['首页', '审计日志'] }
+  },
   {
     path: '/change-password',
     name: 'ChangePassword',

+ 176 - 0
apps/platform-app/src/views/AuditLogsView.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="p-4 md:p-8">
+    <!-- 头部筛选工具栏 -->
+    <a-card class="mb-6 bg-gray-50 rounded-lg shadow-sm !border-0">
+      <a-space size="large" wrap>
+        <a-input
+          v-model:value="filterAction"
+          placeholder="操作类型 (如 create_factory)"
+          class="w-full sm:w-56"
+          allowClear
+          @change="loadAuditLogs"
+        />
+        <a-input
+          v-model:value="filterTargetType"
+          placeholder="目标类型 (如 factory/user)"
+          class="w-full sm:w-48"
+          allowClear
+          @change="loadAuditLogs"
+        />
+        <a-input
+          v-model:value="filterFactoryId"
+          placeholder="工厂ID筛选"
+          class="w-full sm:w-40"
+          allowClear
+          @change="loadAuditLogs"
+        />
+        <a-button @click="loadAuditLogs" class="shadow-sm hover:shadow-md transition-shadow">
+          <ReloadOutlined class="mr-2" />
+          查询
+        </a-button>
+      </a-space>
+    </a-card>
+
+    <!-- 桌面端:审计日志表格 -->
+    <a-card v-if="!layoutStore.isMobile" class="rounded-xl shadow-md !border-0" title="审计日志">
+      <a-table
+        :columns="columns"
+        :dataSource="auditLogs"
+        :loading="loading"
+        :pagination="pagination"
+        @change="handleTableChange"
+        rowKey="id"
+        :scroll="{ x: 1000 }"
+      >
+        <template #success="{ record }">
+          <a-tag :color="record.success === 1 ? 'green' : 'red'" class="!rounded-full">
+            {{ record.success === 1 ? '成功' : '失败' }}
+          </a-tag>
+        </template>
+        <template #detail="{ record }">
+          <a-tooltip :title="record.detail">
+            <span class="text-gray-600 text-sm truncate inline-block max-w-xs">{{ record.detail }}</span>
+          </a-tooltip>
+        </template>
+      </a-table>
+    </a-card>
+
+    <!-- 移动端:审计日志卡片列表 -->
+    <div v-else class="space-y-3">
+      <a-spin :spinning="loading">
+        <a-empty v-if="!loading && auditLogs.length === 0" description="暂无审计日志" class="py-8" />
+        <a-card
+          v-for="item in auditLogs"
+          :key="item.id"
+          class="rounded-xl shadow-sm !border-0"
+          size="small"
+        >
+          <div class="flex items-start justify-between gap-2 mb-2">
+            <span class="font-medium text-gray-900 break-all text-sm">{{ item.action }}</span>
+            <a-tag :color="item.success === 1 ? 'green' : 'red'" class="!rounded-full !mr-0 flex-shrink-0">
+              {{ item.success === 1 ? '成功' : '失败' }}
+            </a-tag>
+          </div>
+          <div class="text-sm text-gray-500 space-y-1 mb-2">
+            <div>目标:{{ item.target_type }} #{{ item.target_id }}</div>
+            <div v-if="item.factory_id">工厂:{{ item.factory_id }}</div>
+            <div>IP:{{ item.client_ip }}</div>
+            <div>时间:{{ item.created_at }}</div>
+            <div v-if="item.detail" class="break-all">详情:{{ item.detail }}</div>
+          </div>
+        </a-card>
+      </a-spin>
+      <div class="flex justify-center mt-4">
+        <a-pagination
+          v-model:current="pagination.current"
+          v-model:pageSize="pagination.pageSize"
+          :total="pagination.total"
+          simple
+          size="small"
+          @change="handlePageChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import { message } from 'ant-design-vue';
+import { ReloadOutlined } from '@ant-design/icons-vue';
+import { AuditLogApi, createApiClient } from '@smartcut/api-client';
+import { STORAGE_KEYS } from '@smartcut/shared-utils';
+import { getApiBaseUrl } from '@/apiConfig';
+import { useLayoutStore } from '@/stores/layout';
+import type { AuditLog } from '@smartcut/types';
+
+const apiClient = createApiClient({
+  baseURL: getApiBaseUrl(),
+  tokenKey: STORAGE_KEYS.PLATFORM_TOKEN
+});
+
+const auditLogApi = new AuditLogApi(apiClient);
+
+const layoutStore = useLayoutStore();
+
+const auditLogs = ref<AuditLog[]>([]);
+const loading = ref(false);
+const filterAction = ref('');
+const filterTargetType = ref('');
+const filterFactoryId = ref('');
+
+const pagination = reactive({
+  current: 1,
+  pageSize: 20,
+  total: 0,
+  showSizeChanger: true,
+  showTotal: (total: number) => `共 ${total} 条`
+});
+
+const columns = [
+  { title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
+  { title: '操作', dataIndex: 'action', key: 'action', width: 180 },
+  { title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 120 },
+  { title: '目标ID', dataIndex: 'target_id', key: 'target_id', width: 100 },
+  { title: '工厂', dataIndex: 'factory_id', key: 'factory_id', width: 120 },
+  { title: '结果', dataIndex: 'success', key: 'success', width: 80, slots: { customRender: 'success' } },
+  { title: 'IP', dataIndex: 'client_ip', key: 'client_ip', width: 130 },
+  { title: '详情', key: 'detail', width: 200, slots: { customRender: 'detail' } },
+  { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 180 }
+];
+
+async function loadAuditLogs() {
+  loading.value = true;
+  try {
+    const result = await auditLogApi.getAuditLogs({
+      page: pagination.current,
+      page_size: pagination.pageSize,
+      action: filterAction.value || undefined,
+      target_type: filterTargetType.value || undefined,
+      factory_id: filterFactoryId.value || undefined
+    });
+    auditLogs.value = result.list;
+    pagination.total = result.total;
+  } catch (error) {
+    message.error('加载审计日志失败');
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleTableChange(pag: any) {
+  pagination.current = pag.current;
+  pagination.pageSize = pag.pageSize;
+  loadAuditLogs();
+}
+
+function handlePageChange(page: number, pageSize: number) {
+  pagination.current = page;
+  pagination.pageSize = pageSize;
+  loadAuditLogs();
+}
+
+onMounted(() => {
+  loadAuditLogs();
+});
+</script>

+ 51 - 0
packages/api-client/src/endpoints/backup.ts

@@ -0,0 +1,51 @@
+// 数据备份API端点
+
+import type { AxiosInstance } from 'axios';
+import type { BackupFile, ApiResponse } from '@smartcut/types';
+
+export class BackupApi {
+  constructor(private client: AxiosInstance) {}
+
+  /**
+   * 创建备份
+   */
+  async createBackup(): Promise<void> {
+    await this.client.post<ApiResponse>('/backup');
+  }
+
+  /**
+   * 获取备份列表
+   */
+  async listBackups(): Promise<BackupFile[]> {
+    const response = await this.client.get<ApiResponse<BackupFile[]>>('/backup');
+    return response.data.data;
+  }
+
+  /**
+   * 下载备份
+   */
+  async downloadBackup(filename: string): Promise<Blob> {
+    const response = await this.client.get(`/backup/download/${filename}`, {
+      responseType: 'blob'
+    });
+    return response.data;
+  }
+
+  /**
+   * 恢复备份(危险操作,需 X-Confirm 头)
+   */
+  async restoreBackup(filename: string): Promise<void> {
+    await this.client.post<ApiResponse>('/backup/restore', { file: filename }, {
+      headers: { 'X-Confirm': 'true' }
+    });
+  }
+
+  /**
+   * 删除备份(危险操作,需 X-Confirm 头)
+   */
+  async deleteBackup(filename: string): Promise<void> {
+    await this.client.delete<ApiResponse>(`/backup/${filename}`, {
+      headers: { 'X-Confirm': 'true' }
+    });
+  }
+}

+ 1 - 0
packages/api-client/src/index.ts

@@ -10,5 +10,6 @@ export { RecordApi } from './endpoints/record';
 export { SalaryApi } from './endpoints/salary';
 export { StatsApi } from './endpoints/stats';
 export { AuditLogApi } from './endpoints/auditLog';
+export { BackupApi } from './endpoints/backup';
 
 export type { ApiClientConfig } from './client';

+ 7 - 0
packages/types/src/models.ts

@@ -81,4 +81,11 @@ export interface AuditLog {
   client_ip: string;
   detail: string;
   created_at: string;
+}
+
+// 数据备份文件
+export interface BackupFile {
+  filename: string;
+  size: number;
+  created_at: string;
 }