zlwu 8 месяцев назад
Родитель
Сommit
e8ec1ff6fa
51 измененных файлов с 4196 добавлено и 151 удалено
  1. 1 1
      DEPLOYMENT.md
  2. 4 0
      art-design-pro/.env.development
  3. 5 1
      art-design-pro/.env.production
  4. 1 0
      art-design-pro/package.json
  5. 10 2
      art-design-pro/src/api/inventoryApi.ts
  6. 44 0
      art-design-pro/src/api/masterDataApi.ts
  7. 2 0
      art-design-pro/src/api/plansApi.ts
  8. 17 3
      art-design-pro/src/components/Table/ArtTable.vue
  9. 5 5
      art-design-pro/src/directives/highlight.ts
  10. 1 1
      art-design-pro/src/router/modules/routesAlias.ts
  11. 71 3
      art-design-pro/src/utils/utils.ts
  12. 456 42
      art-design-pro/src/views/inventory/index.vue
  13. 213 1
      art-design-pro/src/views/master-data/index.vue
  14. 79 15
      art-design-pro/src/views/plans/index.vue
  15. 46 7
      art-design-pro/src/views/weighing/index.vue
  16. 1 1
      art-design-pro/vite.config.ts
  17. 1 1
      backend/.env.production
  18. 2 0
      backend/app/api/routes/__init__.py
  19. 112 20
      backend/app/api/routes/inventory.py
  20. 113 5
      backend/app/api/routes/plans.py
  21. 132 0
      backend/app/api/routes/vehicles.py
  22. 1 0
      backend/app/db/session.py
  23. 2 1
      backend/app/models/__init__.py
  24. 11 0
      backend/app/models/master_data.py
  25. 25 0
      backend/app/schemas/master_data.py
  26. 5 0
      backend/app/schemas/plans.py
  27. 1 1
      backend/env.production.example
  28. 100 0
      backend/scripts/init_vehicles_from_history.py
  29. 1 1
      backend/tests/conftest.py
  30. 136 0
      dibang-test.sql
  31. 223 0
      docs/art-design-pro-changelog-today.md
  32. 38 0
      docs/cargos_product_name_unify.sql
  33. 92 0
      docs/mobile-h5-alignment-checklist.md
  34. 53 0
      docs/product_name_unify.sql
  35. 91 0
      docs/web-h5-differences.md
  36. 155 0
      docs/车牌号管理表与历史数据导入.md
  37. 1 0
      mobile-h5/package.json
  38. 9 2
      mobile-h5/src/api/inventoryApi.ts
  39. 49 0
      mobile-h5/src/api/masterDataApi.ts
  40. 14 0
      mobile-h5/src/api/plansApi.ts
  41. 24 0
      mobile-h5/src/router/index.ts
  42. 70 3
      mobile-h5/src/utils/utils.ts
  43. 156 13
      mobile-h5/src/views/inventory/detail.vue
  44. 314 0
      mobile-h5/src/views/inventory/fill.vue
  45. 205 6
      mobile-h5/src/views/inventory/index.vue
  46. 791 0
      mobile-h5/src/views/inventory/plan.vue
  47. 139 3
      mobile-h5/src/views/master-data/index.vue
  48. 127 0
      mobile-h5/src/views/master-data/vehicle-detail.vue
  49. 17 9
      mobile-h5/src/views/plans/index.vue
  50. 29 3
      mobile-h5/src/views/weighing/index.vue
  51. 1 1
      mobile-h5/vite.config.ts

+ 1 - 1
DEPLOYMENT.md

@@ -154,7 +154,7 @@ ENV=prod
 # ============================================
 # 数据库配置
 # ============================================
-DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang?charset=utf8mb4
+DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang-cs?charset=utf8mb4
 
 # ============================================
 # JWT 配置

+ 4 - 0
art-design-pro/.env.development

@@ -8,5 +8,9 @@ VITE_BASE_URL = /
 # 如果后端运行在 3003 端口,保持 http://localhost:3003
 VITE_API_URL = http://localhost:8000
 
+# 计划管理「生成链接交三方填写」时,链接指向的 H5 根地址(本机 Web 与 H5 端口不同,必须配置)
+# 示例:H5 跑在 5174 端口填 http://localhost:5174
+VITE_H5_BASE_URL = http://localhost:5174
+
 # Delete console
 VITE_DROP_CONSOLE = false

+ 5 - 1
art-design-pro/.env.production

@@ -2,4 +2,8 @@
 
 VITE_API_URL=/api
 
-VITE_VERSION=1.0.0
+VITE_VERSION=1.0.0
+
+# 计划管理「生成链接交三方填写」时,链接指向的 H5 根地址(生产环境 Web 与 H5 域名不同,必须配置)
+# 示例:H5 域名为 https://h5.xxx.com 则填 https://h5.xxx.com
+VITE_H5_BASE_URL=http://dbapp.shanxiachem.com/

+ 1 - 0
art-design-pro/package.json

@@ -54,6 +54,7 @@
     "element-plus": "^2.8.0",
     "file-saver": "^2.0.5",
     "highlight.js": "^11.10.0",
+    "html2canvas": "^1.4.1",
     "md-editor-v3": "^4.17.0",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",

+ 10 - 2
art-design-pro/src/api/inventoryApi.ts

@@ -52,7 +52,7 @@ export interface InventoryUpdate {
   batchNo?: string
   vehicleNo?: string
   status?: string
-  remarks?: string
+  remarks?: string | null
   purchasePlanId?: number
 }
 
@@ -71,7 +71,8 @@ export const inventoryApi = {
   }): Promise<InventoryRecord[]> => {
     return api.get<InventoryRecord[]>({
       url: '/inventory',
-      params
+      params,
+      timeout: 60000 // 列表可能数据较多,60 秒超时避免被取消
     })
   },
   get: async (id: number): Promise<InventoryRecord> => {
@@ -100,6 +101,13 @@ export const inventoryApi = {
     return api.del({
       url: `/inventory/${id}`
     })
+  },
+  /** 生成链接交三方填写:预生成出入库单(日期、数量、车牌号为空),返回该记录用于生成 H5 填写链接 */
+  createLinkForPlan: async (purchasePlanId: number, remarks?: string): Promise<InventoryRecord> => {
+    return api.post<InventoryRecord>({
+      url: '/inventory/create-link-for-plan',
+      data: { purchasePlanId, remarks }
+    })
   }
 }
 

+ 44 - 0
art-design-pro/src/api/masterDataApi.ts

@@ -88,3 +88,47 @@ export const cargoApi = {
   }
 }
 
+// ==================== 车牌号 API ====================
+
+export interface Vehicle {
+  id: number
+  vehicleNo: string
+  remarks?: string | null
+}
+
+export interface VehicleCreate {
+  vehicleNo: string
+  remarks?: string | null
+}
+
+export interface VehicleUpdate {
+  vehicleNo?: string
+  remarks?: string | null
+}
+
+export const vehicleApi = {
+  list: async (q?: string): Promise<Vehicle[]> => {
+    return api.get<Vehicle[]>({
+      url: '/master-data/vehicles',
+      params: q ? { q } : undefined
+    })
+  },
+  create: async (payload: VehicleCreate): Promise<Vehicle> => {
+    return api.post<Vehicle>({
+      url: '/master-data/vehicles',
+      data: payload
+    })
+  },
+  update: async (id: number, payload: VehicleUpdate): Promise<Vehicle> => {
+    return api.put<Vehicle>({
+      url: `/master-data/vehicles/${id}`,
+      data: payload
+    })
+  },
+  delete: async (id: number): Promise<void> => {
+    return api.del({
+      url: `/master-data/vehicles/${id}`
+    })
+  }
+}
+

+ 2 - 0
art-design-pro/src/api/plansApi.ts

@@ -23,6 +23,8 @@ export interface Plan {
   executedQuantity?: number
   isDeleted: number
   createdAt: string
+  /** 生成链接交三方填写时对应的出入库单 id,有则显示「复制URL链接」 */
+  fillLinkInventoryId?: number
 }
 
 export interface PlanCreate {

+ 17 - 3
art-design-pro/src/components/Table/ArtTable.vue

@@ -14,18 +14,21 @@
       :table-layout="tableLayout"
       @selection-change="handleSelectionChange"
       @row-click="handleRowClick"
+      @sort-change="handleSortChange"
     >
       <!-- 选择列 -->
       <el-table-column v-if="selection" type="selection" width="55" align="center" fixed="left" />
 
-      <!-- 序号列 -->
+      <!-- 序号列:min-width 与 white-space 避免横向滚动时表头「序号」被压窄换行 -->
       <el-table-column
         v-if="index"
         type="index"
-        width="60"
+        width="72"
+        min-width="72"
         label="序号"
         align="center"
         fixed="left"
+        class-name="art-table-index-column"
       />
 
       <!-- 动态列 -->
@@ -137,7 +140,8 @@
     'selection-change',
     'row-click',
     'size-change',
-    'current-change'
+    'current-change',
+    'sort-change'
   ])
 
   // 表格数据
@@ -165,6 +169,11 @@
     emit('row-click', row, column, event)
   }
 
+  // 排序变化(用于 sortable="custom" 时由父组件处理)
+  const handleSortChange = (payload: { prop: string; order: string | null }) => {
+    emit('sort-change', payload)
+  }
+
   // 每页条数改变
   const handleSizeChange = (val: number) => {
     emit('size-change', val)
@@ -207,6 +216,11 @@
       th.el-table__cell {
         padding: 16px 0; // 设置表格单元格高度
       }
+
+      // 序号列表头不换行,避免横向滚动时「序号」变成上下两行
+      th.art-table-index-column .cell {
+        white-space: nowrap;
+      }
     }
 
     &.header-background {

+ 5 - 5
art-design-pro/src/directives/highlight.ts

@@ -1,6 +1,7 @@
 import { App, Directive } from 'vue'
 import hljs from 'highlight.js'
 import { ElMessage } from 'element-plus'
+import { copyToClipboard } from '@/utils/utils'
 
 /**
  * 高亮代码
@@ -27,12 +28,11 @@ function addCopyButton(block: HTMLElement) {
   const copyButton = document.createElement('i')
   copyButton.className = 'copy-button iconfont-sys'
   copyButton.innerHTML = '&#xe7b2;'
-  copyButton.onclick = () => {
-    // 过滤掉行号,只复制代码内容
+  copyButton.onclick = async () => {
     const codeContent = block.innerText.replace(/^\d+\s+/gm, '')
-    navigator.clipboard.writeText(codeContent).then(() => {
-      ElMessage.success('复制成功')
-    })
+    const ok = await copyToClipboard(codeContent)
+    if (ok) ElMessage.success('复制成功')
+    else ElMessage.warning('当前环境不支持一键复制')
   }
 
   const preElement = block.parentElement

+ 1 - 1
art-design-pro/src/router/modules/routesAlias.ts

@@ -42,7 +42,7 @@ export enum RoutesAlias {
   Account = '/user/Account', // 账户
   Department = '/user/Department', // 部门
   Role = '/user/Role', // 角色
-  UserCenter = '/user/user', // 用户管理
+  UserCenter = '/user/UserManagement', // 用户管理(对应 views/user/UserManagement.vue)
   Menu = '/menu/Menu', // 菜单
   Permission = '/menu/Permission', // 权限
   NestedMenu1 = '/menu/nested/Menu1', // 嵌套菜单1

+ 71 - 3
art-design-pro/src/utils/utils.ts

@@ -105,13 +105,81 @@ export function getURLParameters(url: string): Record<string, string> {
   return Object.fromEntries(new URLSearchParams(url.split('?')[1]).entries())
 }
 
-// 复制文本
+// 复制文本(同步,仅使用 clipboard API,非安全上下文会失败)
 export function copy(str: string): boolean {
   try {
-    navigator.clipboard.writeText(str)
-    return true
+    if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+      navigator.clipboard.writeText(str)
+      return true
+    }
+    return fallbackCopy(str)
   } catch (err) {
     console.error('Copy failed:', err)
+    return fallbackCopy(str)
+  }
+}
+
+// 兼容非 HTTPS 等环境:使用 execCommand 复制
+function fallbackCopy(str: string): boolean {
+  try {
+    const textarea = document.createElement('textarea')
+    textarea.value = str
+    textarea.style.position = 'fixed'
+    textarea.style.left = '-9999px'
+    textarea.setAttribute('readonly', '')
+    document.body.appendChild(textarea)
+    textarea.select()
+    const ok = document.execCommand('copy')
+    document.body.removeChild(textarea)
+    return ok
+  } catch {
+    return false
+  }
+}
+
+/**
+ * 异步复制到剪贴板,生产环境安全:优先 clipboard API,不可用时回退到 execCommand。
+ * 用于非 HTTPS 或受限上下文下避免 "Cannot read properties of undefined (reading 'writeText')"。
+ */
+export async function copyToClipboard(str: string): Promise<boolean> {
+  try {
+    if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+      await navigator.clipboard.writeText(str)
+      return true
+    }
+    return fallbackCopy(str)
+  } catch {
+    return fallbackCopy(str)
+  }
+}
+
+/**
+ * 从图片 URL 拉取图片并复制到剪贴板(需支持 ClipboardItem,通常为 HTTPS 环境)。
+ */
+export async function copyImageFromUrl(imageUrl: string): Promise<boolean> {
+  try {
+    if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
+    const res = await fetch(imageUrl, { mode: 'cors' })
+    if (!res.ok) return false
+    const blob = await res.blob()
+    const type = blob.type || 'image/png'
+    await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * 将 Blob 图片写入剪贴板(用于 html2canvas 等生成的图片)。
+ */
+export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
+  try {
+    if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
+    const type = blob.type || 'image/png'
+    await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
+    return true
+  } catch {
     return false
   }
 }

+ 456 - 42
art-design-pro/src/views/inventory/index.vue

@@ -59,6 +59,12 @@
           >
             今日
           </el-button>
+          <el-button
+            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+            @click="setDateFilter('tomorrow')"
+          >
+            明日
+          </el-button>
           <el-button
             :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
             @click="setDateFilter('yesterday')"
@@ -86,19 +92,21 @@
         v-model:page-size="pageSize"
         @current-change="handlePageChange"
         @size-change="handleSizeChange"
+        @sort-change="handleSortChange"
       >
-        <el-table-column prop="planNo" label="计划号" min-width="200" />
-        <el-table-column prop="batchNo" label="出入库单号" min-width="140" />
-        <el-table-column prop="date" label="日期" min-width="120" />
-        <el-table-column prop="partnerCompany" label="往来单位" min-width="150" />
-        <el-table-column prop="businessType" label="业务类型" min-width="100">
+        <el-table-column prop="id" label="id" min-width="80" sortable="custom" :sort-order="getSortOrder('id')" />
+        <el-table-column prop="planNo" label="计划号" min-width="200" sortable="custom" :sort-order="getSortOrder('planNo')" />
+        <el-table-column prop="batchNo" label="出入库单号" min-width="140" sortable="custom" :sort-order="getSortOrder('batchNo')" />
+        <el-table-column prop="date" label="日期" min-width="120" sortable="custom" :sort-order="getSortOrder('date')" />
+        <el-table-column prop="partnerCompany" label="往来单位" min-width="150" sortable="custom" :sort-order="getSortOrder('partnerCompany')" />
+        <el-table-column prop="businessType" label="业务类型" min-width="100" sortable="custom" :sort-order="getSortOrder('businessType')">
           <template #default="{ row }">
             <el-tag :type="row.businessType === '入库' ? 'success' : 'warning'">
               {{ row.businessType }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="status" label="状态" min-width="100">
+        <el-table-column prop="status" label="状态" min-width="100" sortable="custom" :sort-order="getSortOrder('status')">
           <template #default="{ row }">
             <el-tag
               :type="
@@ -113,41 +121,41 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="product" label="产品" min-width="120" />
-        <el-table-column prop="vehicleNo" label="车牌号" min-width="120" />
-        <el-table-column prop="quantity" label="数量(吨)" min-width="120" />
-        <el-table-column prop="netWeight" label="净重(吨)" min-width="120">
+        <el-table-column prop="product" label="产品" min-width="120" sortable="custom" :sort-order="getSortOrder('product')" />
+        <el-table-column prop="vehicleNo" label="车牌号" min-width="120" sortable="custom" :sort-order="getSortOrder('vehicleNo')" />
+        <el-table-column prop="quantity" label="数量(吨)" min-width="120" sortable="custom" :sort-order="getSortOrder('quantity')" />
+        <el-table-column prop="netWeight" label="净重(吨)" min-width="120" sortable="custom" :sort-order="getSortOrder('netWeight')">
           <template #default="{ row }">
             {{ row.netWeight !== undefined && row.netWeight !== null ? (row.netWeight / 1000).toFixed(2) : '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="grossWeight" label="毛重(吨)" min-width="120">
+        <el-table-column prop="grossWeight" label="毛重(吨)" min-width="120" sortable="custom" :sort-order="getSortOrder('grossWeight')">
           <template #default="{ row }">
             {{ row.grossWeight !== undefined && row.grossWeight !== null ? (row.grossWeight / 1000).toFixed(2) : '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="grossWeightTime" label="毛重时间" min-width="160">
+        <el-table-column prop="grossWeightTime" label="毛重时间" min-width="160" sortable="custom" :sort-order="getSortOrder('grossWeightTime')">
           <template #default="{ row }">
             {{ row.grossWeightTime || '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="tareWeight" label="皮重(吨)" min-width="120">
+        <el-table-column prop="tareWeight" label="皮重(吨)" min-width="120" sortable="custom" :sort-order="getSortOrder('tareWeight')">
           <template #default="{ row }">
             {{ row.tareWeight !== undefined && row.tareWeight !== null ? (row.tareWeight / 1000).toFixed(2) : '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="tareWeightTime" label="皮重时间" min-width="160">
+        <el-table-column prop="tareWeightTime" label="皮重时间" min-width="160" sortable="custom" :sort-order="getSortOrder('tareWeightTime')">
           <template #default="{ row }">
             {{ row.tareWeightTime || '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="deduction" label="扣杂(吨)" min-width="120">
+        <el-table-column prop="deduction" label="扣杂(吨)" min-width="120" sortable="custom" :sort-order="getSortOrder('deduction')">
           <template #default="{ row }">
             {{ row.deduction !== undefined && row.deduction !== null ? (row.deduction / 1000).toFixed(2) : '--' }}
           </template>
         </el-table-column>
-        <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip />
-        <el-table-column label="操作" width="360" fixed="right">
+        <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip sortable="custom" :sort-order="getSortOrder('remarks')" />
+        <el-table-column label="操作" width="420" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">
               <el-icon><View /></el-icon>
@@ -166,7 +174,21 @@
               <el-icon><Plus /></el-icon>
               {{ row.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
             </el-button>
-            <el-button link type="danger" @click="handleDelete(row.id)">
+            <el-button
+              link
+              type="primary"
+              @click="handleCopyFillLink(row)"
+            >
+              <el-icon><Link /></el-icon>
+              磅单二维码
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              :disabled="!isRecordToday(row)"
+              :title="isRecordToday(row) ? '' : '仅支持删除当天的记录'"
+              @click="handleDelete(row)"
+            >
               <el-icon><Delete /></el-icon>
               删除
             </el-button>
@@ -220,9 +242,6 @@
         <el-form-item label="产品" prop="product">
           <el-select
             v-model="formData.product"
-            filterable
-            remote
-            :remote-method="loadCargos"
             placeholder="请选择产品"
             style="width: 100%"
             @focus="loadCargos('')"
@@ -235,7 +254,7 @@
             />
           </el-select>
         </el-form-item>
-        <el-form-item label="数量(吨)" prop="quantity">
+        <el-form-item label="数量(吨)" prop="quantity" class="quantity-red">
           <el-input-number
             v-model="formData.quantity"
             :min="0"
@@ -244,7 +263,24 @@
           />
         </el-form-item>
         <el-form-item label="车牌号" prop="vehicleNo">
-          <el-input v-model="formData.vehicleNo" placeholder="请输入车牌号" />
+          <el-select
+            v-model="formData.vehicleNo"
+            filterable
+            remote
+            allow-create
+            default-first-option
+            :remote-method="loadVehicles"
+            placeholder="请选择或输入车牌号"
+            style="width: 100%"
+            @focus="loadVehicles('')"
+          >
+            <el-option
+              v-for="item in vehicleOptions"
+              :key="item.id"
+              :label="item.vehicleNo"
+              :value="item.vehicleNo"
+            />
+          </el-select>
         </el-form-item>
         <el-form-item label="计划号" prop="planNo">
           <el-input v-model="formData.planNo" placeholder="请输入计划号" :readonly="isPlanNoReadonly" />
@@ -323,17 +359,48 @@
         <el-button @click="relatedModalOpen = false">关闭</el-button>
       </template>
     </el-dialog>
+
+    <!-- 磅单二维码弹窗:供截图保存,不依赖 HTTPS;遮罩深黑避免截到背后内容 -->
+    <el-dialog
+      v-model="qrcodeModalOpen"
+      title="山峡出入库磅单二维码"
+      width="400px"
+      class="qrcode-dialog"
+      modal-class="qrcode-dialog-overlay"
+      @close="handleQrcodeDialogClose"
+    >
+      <div v-if="captureRow" class="qrcode-dialog-body pound-slip-capture-inline">
+        <div class="capture-card">
+          <div class="capture-title">出入库单信息(已锁定)</div>
+          <div class="capture-row"><span class="label">日期:</span><span class="value">{{ captureRow.date }}</span></div>
+          <div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ captureRow.partnerCompany }}</span></div>
+          <div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ captureRow.businessType }}</span></div>
+          <div class="capture-row"><span class="label">产品:</span><span class="value">{{ captureRow.product }}</span></div>
+          <div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ captureRow.quantity }}</span></div>
+          <div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ captureRow.vehicleNo }}</span></div>
+          <div v-if="captureRow.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ captureRow.remarks }}</span></div>
+        </div>
+        <div class="capture-card capture-qrcode">
+          <div class="capture-id">{{ captureRow.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ captureRow.id }}</div>
+          <div class="capture-qr-wrap">
+            <img v-if="captureQrObjectUrl" :src="captureQrObjectUrl" alt="二维码" class="capture-qr-img" />
+          </div>
+          <div class="capture-desc">出入库单标识(扫码核验)</div>
+        </div>
+        <div class="qrcode-dialog-hint">请截图保存上方二维码及磅单信息</div>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { ref, reactive, computed, onMounted } from 'vue'
+  import { ref, reactive, computed, onMounted, nextTick } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Document, Search, Edit, Delete, View } from '@element-plus/icons-vue'
+  import { Plus, Document, Search, Edit, Delete, View, Link } from '@element-plus/icons-vue'
   import { inventoryApi, type InventoryRecord, type InventoryCreate } from '@/api/inventoryApi'
   import { planApi, type Plan } from '@/api/plansApi'
   import { weighingApi, type WeighingRecord } from '@/api/weighingApi'
-  import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
+  import { companyApi, cargoApi, vehicleApi, type Company, type Cargo, type Vehicle } from '@/api/masterDataApi'
   import { useRoute, useRouter } from 'vue-router'
   import dayjs from 'dayjs'
   import ArtTable from '@/components/Table/ArtTable.vue'
@@ -344,11 +411,14 @@
   const loading = ref(false)
   const saving = ref(false)
   const tableData = ref<InventoryRecord[]>([])
+  const allRecords = ref<InventoryRecord[]>([]) // 全量数据,用于排序后分页
   const total = ref(0)
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
+  const sortProp = ref<string>('date')
+  const sortOrder = ref<'ascending' | 'descending' | null>('descending')
 
   // 日期范围筛选
   const dateRange = ref<[string, string]>([
@@ -360,6 +430,7 @@
   const editingRecord = ref<InventoryRecord | null>(null)
   const companyOptions = ref<Company[]>([])
   const cargoOptions = ref<Cargo[]>([])
+  const vehicleOptions = ref<Vehicle[]>([])
   const relatedModalOpen = ref(false)
   const loadingRelated = ref(false)
   const relatedData = ref<{ plan: Plan | null, weighing: WeighingRecord | null }>({ plan: null, weighing: null })
@@ -367,6 +438,11 @@
   // 标志位:是否是从计划页面跳转过来的(用于控制是否预填表单)
   const isFromPlanJump = ref(false)
 
+  // 磅单二维码弹窗:当前展示的记录与二维码图(Object URL)
+  const qrcodeModalOpen = ref(false)
+  const captureRow = ref<InventoryRecord | null>(null)
+  const captureQrObjectUrl = ref<string>('')
+
   const formRef = ref()
   // formData初始化时保持干净,不依赖路由参数
   const formData = reactive<InventoryCreate>({
@@ -374,7 +450,7 @@
     partnerCompany: '',
     businessType: '入库',
     product: '',
-    quantity: 0,
+    quantity: 32,
     planNo: '',
     vehicleNo: '',
     remarks: '',
@@ -409,8 +485,72 @@
     }
   }
 
+  // 加载车牌号列表(模糊检索)
+  const loadVehicles = async (keyword: string) => {
+    try {
+      vehicleOptions.value = await vehicleApi.list(keyword || undefined)
+    } catch (error) {
+      console.error('加载车牌号列表失败', error)
+    }
+  }
+
+  // 保存前确保车牌号已写入车牌号表(仅当车牌号不在基础资料中时才创建,避免重复插入)
+  const ensureVehicleExists = async (vehicleNo: string) => {
+    if (!vehicleNo?.trim()) return
+    const trimmed = vehicleNo.trim()
+    const existsInOptions = vehicleOptions.value.some(
+      (v) => v.vehicleNo.trim().toLowerCase() === trimmed.toLowerCase()
+    )
+    if (existsInOptions) return // 下拉选中的车牌已存在,无需插入
+    try {
+      await vehicleApi.create({ vehicleNo: trimmed, remarks: null })
+    } catch (err: any) {
+      if (err.response?.status !== 409) throw err
+      // 409 表示已存在,忽略
+    }
+  }
+
   // 从路由参数填充表单的逻辑已移至handleAdd函数中,由isFromPlanJump标志位控制
 
+  const getSortOrder = (prop: string): 'ascending' | 'descending' | null => {
+    return sortProp.value === prop ? sortOrder.value : null
+  }
+
+  const compare = (a: InventoryRecord, b: InventoryRecord, prop: string, order: 'ascending' | 'descending'): number => {
+    const av = (a as any)[prop]
+    const bv = (b as any)[prop]
+    const aEmpty = av === undefined || av === null
+    const bEmpty = bv === undefined || bv === null
+    const isNum = typeof av === 'number' || typeof bv === 'number'
+    const aVal = aEmpty ? (isNum ? Infinity : '') : av
+    const bVal = bEmpty ? (isNum ? Infinity : '') : bv
+    let cmp = 0
+    if (typeof aVal === 'number' && typeof bVal === 'number') {
+      cmp = aVal - bVal
+    } else {
+      cmp = String(aVal).localeCompare(String(bVal), 'zh-CN')
+    }
+    return order === 'ascending' ? cmp : -cmp
+  }
+
+  const applySortAndPage = () => {
+    let list = [...allRecords.value]
+    if (sortProp.value && sortOrder.value) {
+      list.sort((a, b) => compare(a, b, sortProp.value, sortOrder.value!))
+    }
+    total.value = list.length
+    const start = (currentPage.value - 1) * pageSize.value
+    const end = start + pageSize.value
+    tableData.value = list.slice(start, end)
+  }
+
+  const handleSortChange = (payload: { prop: string; order: string | null }) => {
+    sortProp.value = payload.prop || ''
+    sortOrder.value = payload.order === 'ascending' || payload.order === 'descending' ? payload.order : null
+    currentPage.value = 1
+    applySortAndPage()
+  }
+
   const loadData = async () => {
     loading.value = true
     try {
@@ -429,6 +569,10 @@
       if (dateFilterType.value === 'today') {
         params.dateFrom = today
         params.dateTo = today
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = dayjs().add(1, 'day').format('YYYY-MM-DD')
+        params.dateFrom = t
+        params.dateTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
         params.dateFrom = y
@@ -444,11 +588,8 @@
       }
 
       const records = await inventoryApi.list(params)
-      total.value = records.length
-      // 根据当前页和每页大小进行数据切片
-      const start = (currentPage.value - 1) * pageSize.value
-      const end = start + pageSize.value
-      tableData.value = records.slice(start, end)
+      allRecords.value = records
+      applySortAndPage()
     } catch (error) {
       ElMessage.error('加载数据失败')
     } finally {
@@ -461,7 +602,7 @@
     loadData()
   }
 
-  const setDateFilter = (type: 'all' | 'today' | 'yesterday' | 'week') => {
+  const setDateFilter = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') => {
     dateFilterType.value = type
     currentPage.value = 1
     loadData()
@@ -475,13 +616,13 @@
 
   const handlePageChange = (page: number) => {
     currentPage.value = page
-    loadData()
+    applySortAndPage()
   }
 
   const handleSizeChange = (size: number) => {
     pageSize.value = size
     currentPage.value = 1
-    loadData()
+    applySortAndPage()
   }
 
   const handleAdd = () => {
@@ -495,7 +636,7 @@
       partnerCompany: useRouteParams ? (route.query.customerName as string) || '' : '',
       businessType: useRouteParams ? (route.query.businessType as '入库' | '出库') || '入库' : '入库',
       product: useRouteParams ? (route.query.product as string) || '' : '',
-      quantity: 0,
+      quantity: 32,
       planNo: useRouteParams ? (route.query.planNo as string) || '' : '',
       vehicleNo: '',
       remarks: '',
@@ -508,6 +649,7 @@
     // 加载下拉框数据
     loadCompanies('')
     loadCargos('')
+    loadVehicles('')
     modalOpen.value = true
   }
 
@@ -540,17 +682,28 @@
     // 加载下拉框数据
     loadCompanies('')
     loadCargos('')
+    loadVehicles('')
     modalOpen.value = true
   }
 
-  const handleDelete = (id: number) => {
+  /** 判断是否为当天记录(仅当天可删除) */
+  const isRecordToday = (row: InventoryRecord) => {
+    const dateStr = row.date || ''
+    return dateStr === dayjs().format('YYYY-MM-DD')
+  }
+
+  const handleDelete = (row: InventoryRecord) => {
+    if (!isRecordToday(row)) {
+      ElMessage.warning('仅支持删除当天的记录,历史数据不可删除')
+      return
+    }
     ElMessageBox.confirm('确定要删除这条记录吗?', '确认删除', {
       confirmButtonText: '确定',
       cancelButtonText: '取消',
       type: 'warning'
     })
       .then(async () => {
-        await inventoryApi.delete(id)
+        await inventoryApi.delete(row.id)
         ElMessage.success('删除成功')
         loadData()
       })
@@ -582,6 +735,45 @@
     })
   }
 
+  const getH5FillBaseUrl = () => {
+    const envBase = import.meta.env.VITE_H5_BASE_URL
+    if (envBase) return envBase.replace(/\/$/, '')
+    return window.location.origin + (window.location.pathname || '')
+  }
+
+  const getFillLinkUrl = (inventoryId: number) => `${getH5FillBaseUrl()}/#/inventory/fill/${inventoryId}`
+
+  /**
+   * 生成二维码图片地址:内容为出入库单 ID(与 H5 填写页保持一致)
+   */
+  const getQrImageUrl = (inventoryId: number) =>
+    `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(String(inventoryId))}`
+
+  /** 打开磅单二维码弹窗,供用户截图保存(不依赖 HTTPS) */
+  const handleCopyFillLink = async (row: InventoryRecord) => {
+    const qrImageUrl = getQrImageUrl(row.id)
+    try {
+      captureRow.value = row
+      captureQrObjectUrl.value = ''
+      qrcodeModalOpen.value = true
+      await nextTick()
+      const res = await fetch(qrImageUrl, { mode: 'cors' })
+      if (!res.ok) throw new Error('二维码图片加载失败')
+      const blob = await res.blob()
+      captureQrObjectUrl.value = URL.createObjectURL(blob)
+    } catch (e: any) {
+      ElMessage.error(e?.message || '加载二维码失败')
+      qrcodeModalOpen.value = false
+      captureRow.value = null
+    }
+  }
+
+  const handleQrcodeDialogClose = () => {
+    if (captureQrObjectUrl.value) URL.revokeObjectURL(captureQrObjectUrl.value)
+    captureRow.value = null
+    captureQrObjectUrl.value = ''
+  }
+
   const handleSubmit = async () => {
     if (!formRef.value) return
 
@@ -589,6 +781,11 @@
       if (valid) {
         saving.value = true
         try {
+          // 新车牌号先写入车牌号表
+          if (formData.vehicleNo?.trim()) {
+            await ensureVehicleExists(formData.vehicleNo.trim())
+          }
+
           // 清理数据:将空字符串转换为 undefined,移除 undefined 字段
           const cleanData = (data: any) => {
             const cleaned: any = {}
@@ -605,8 +802,16 @@
 
           if (editingRecord.value) {
             const updateData = cleanData(formData)
+            // 备注清空时也要提交,否则后端不会更新该字段(exclude_unset 会忽略未传字段)
+            updateData.remarks = formData.remarks === '' ? null : (formData.remarks || undefined)
             await inventoryApi.update(editingRecord.value.id, updateData)
             ElMessage.success('更新成功')
+            modalOpen.value = false
+            if (route.query.from === 'plan') {
+              router.replace('/plans')
+            } else {
+              loadData()
+            }
           } else {
             // 创建时,必需字段不能为空
             const createData: InventoryCreate = {
@@ -622,9 +827,13 @@
             }
             await inventoryApi.create(createData)
             ElMessage.success('保存成功')
+            modalOpen.value = false
+            if (route.query.from === 'plan') {
+              router.replace('/plans')
+            } else {
+              loadData()
+            }
           }
-          modalOpen.value = false
-          loadData()
         } catch (error: any) {
           const errorMessage = error.response?.data?.detail || error.response?.data?.message || '操作失败'
           ElMessage.error(errorMessage)
@@ -641,6 +850,10 @@
     // 确保标志位被重置,避免下次新增时意外使用预填数据
     isFromPlanJump.value = false
     formRef.value?.resetFields()
+    // 若本次是从计划管理跳转过来的,关闭弹窗后返回计划管理
+    if (route.query.from === 'plan') {
+      router.replace('/plans')
+    }
   }
 
   const handleExport = async () => {
@@ -661,6 +874,10 @@
       if (dateFilterType.value === 'today') {
         params.dateFrom = today
         params.dateTo = today
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = dayjs().add(1, 'day').format('YYYY-MM-DD')
+        params.dateFrom = t
+        params.dateTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
         params.dateFrom = y
@@ -680,10 +897,14 @@
         ElMessage.warning('没有数据可导出')
         return
       }
+      let list = [...records]
+      if (sortProp.value && sortOrder.value) {
+        list.sort((a, b) => compare(a, b, sortProp.value, sortOrder.value!))
+      }
       const headers = ['日期', '往来单位', '业务类型', '产品', '数量(吨)', '车牌号', '计划号', '备注']
       const csvContent = [
         headers.join(','),
-        ...records.map((r) =>
+        ...list.map((r) =>
           [
             r.date,
             r.partnerCompany,
@@ -783,5 +1004,198 @@
   .dialog-content::-webkit-scrollbar-thumb:hover {
     background: #a8a8a8;
   }
+
+  // 新增/编辑弹窗中数量字段红色字体
+  :deep(.quantity-red .el-form-item__label) {
+    color: var(--el-color-danger);
+  }
+  :deep(.quantity-red .el-input__inner),
+  :deep(.quantity-red .el-input-number__increase),
+  :deep(.quantity-red .el-input-number__decrease) {
+    color: var(--el-color-danger);
+  }
+
+  /* 磅单二维码弹窗:顶部/底部/内容区上下边距缩小 */
+  :deep(.qrcode-dialog.el-dialog) {
+    .el-dialog__header {
+      padding: 8px 16px 6px;
+    }
+    .el-dialog__body {
+      padding: 6px 16px;
+    }
+    .el-dialog__footer {
+      padding: 6px 16px 8px;
+    }
+  }
+  .qrcode-dialog-body {
+    max-height: 70vh;
+    overflow-y: auto;
+  }
+  .qrcode-dialog-hint {
+    margin-top: 6px;
+    padding: 5px 10px;
+    font-size: 12px;
+    color: #6b7280;
+    text-align: center;
+    background: #f3f4f6;
+    border-radius: 6px;
+  }
+  .pound-slip-capture-inline {
+    background: #f5f7fa;
+    padding: 6px 10px;
+    font-family: inherit;
+  }
+  .pound-slip-capture-inline .capture-header {
+    font-size: 15px;
+    font-weight: 600;
+    color: #111827;
+    padding: 4px 10px;
+    background: #fff;
+    text-align: center;
+    border-radius: 8px;
+    margin-bottom: 8px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+  }
+  .pound-slip-capture-inline .capture-card {
+    background: #fff;
+    border-radius: 8px;
+    margin-bottom: 8px;
+    padding: 8px 10px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+  }
+  .pound-slip-capture-inline .capture-title {
+    font-size: 13px;
+    font-weight: 600;
+    color: #374151;
+    margin-bottom: 4px;
+  }
+  .pound-slip-capture-inline .capture-row {
+    display: flex;
+    align-items: center;
+    margin-bottom: 4px;
+    font-size: 13px;
+  }
+  .pound-slip-capture-inline .capture-row .label {
+    color: #6b7280;
+    min-width: 82px;
+  }
+  .pound-slip-capture-inline .capture-row .value {
+    color: #111827;
+    flex: 1;
+  }
+  .pound-slip-capture-inline .capture-row .vehicle-no {
+    font-weight: 700;
+    color: #111827;
+    font-size: 15px;
+  }
+  .pound-slip-capture-inline .capture-qrcode {
+    text-align: center;
+  }
+  .pound-slip-capture-inline .capture-id {
+    font-size: 16px;
+    font-weight: 700;
+    color: #111827;
+    margin-bottom: 6px;
+  }
+  .pound-slip-capture-inline .capture-qr-wrap {
+    padding: 8px 0;
+    min-height: 168px;
+  }
+  .pound-slip-capture-inline .capture-qr-img {
+    width: 168px;
+    height: 168px;
+    display: inline-block;
+  }
+  .pound-slip-capture-inline .capture-desc {
+    font-size: 13px;
+    font-weight: 600;
+    color: #374151;
+    margin-top: 4px;
+  }
+
+  /* 保留原屏外截图样式(若有其他地方引用可删) */
+  .pound-slip-capture {
+    position: fixed;
+    left: -9999px;
+    top: 0;
+    width: 360px;
+    background: #f5f7fa;
+    padding: 16px;
+    font-family: inherit;
+    .capture-header {
+      font-size: 18px;
+      font-weight: 600;
+      color: #111827;
+      padding: 16px;
+      background: #fff;
+      text-align: center;
+      border-radius: 12px;
+      margin-bottom: 16px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+    }
+    .capture-card {
+      background: #fff;
+      border-radius: 12px;
+      margin-bottom: 16px;
+      padding: 16px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+    }
+    .capture-title {
+      font-size: 15px;
+      font-weight: 600;
+      color: #374151;
+      margin-bottom: 12px;
+    }
+    .capture-row {
+      display: flex;
+      align-items: center;
+      margin-bottom: 10px;
+      font-size: 14px;
+      .label {
+        color: #6b7280;
+        min-width: 90px;
+      }
+      .value {
+        color: #111827;
+        flex: 1;
+      }
+      .vehicle-no {
+        font-weight: 700;
+        color: #ef4444;
+      }
+    }
+    .capture-qrcode {
+      text-align: center;
+    }
+    .capture-id {
+      font-size: 20px;
+      font-weight: 700;
+      color: #111827;
+      margin-bottom: 12px;
+    }
+    .capture-qr-wrap {
+      padding: 16px 0;
+      min-height: 200px;
+    }
+    .capture-qr-img {
+      width: 200px;
+      height: 200px;
+      display: inline-block;
+    }
+    .capture-desc {
+      font-size: 15px;
+      font-weight: 600;
+      color: #374151;
+      margin-top: 8px;
+    }
+  }
+
+</style>
+
+<!-- 磅单二维码弹窗遮罩深黑(遮罩在 body 下,需全局样式) -->
+<style>
+.qrcode-dialog-overlay {
+  background: rgba(0, 0, 0, 0.92) !important;
+}
 </style>
 

+ 213 - 1
art-design-pro/src/views/master-data/index.vue

@@ -63,6 +63,61 @@
           </art-table>
         </el-tab-pane>
 
+        <!-- 车牌号管理 -->
+        <el-tab-pane label="车牌号" name="vehicle">
+          <div class="toolbar">
+            <div class="search-bar">
+              <el-input
+                v-model="vehicleSearchKeyword"
+                placeholder="搜索车牌号 / 备注"
+                clearable
+                @clear="handleVehicleSearch"
+                @keyup.enter="handleVehicleSearch"
+              >
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+                <template #append>
+                  <el-button @click="handleVehicleSearch">搜索</el-button>
+                </template>
+              </el-input>
+            </div>
+            <el-button type="primary" @click="handleVehicleAdd">
+              <el-icon><Plus /></el-icon>
+              新增车牌号
+            </el-button>
+          </div>
+
+          <art-table
+            :data="vehicleData"
+            :loading="vehicleLoading"
+            :pagination="true"
+            :total="vehicleTotal"
+            :index="true"
+            :border="true"
+            table-layout="fixed"
+            v-model:current-page="vehiclePage"
+            v-model:page-size="vehiclePageSize"
+            @current-change="handleVehiclePageChange"
+            @size-change="handleVehicleSizeChange"
+          >
+            <el-table-column prop="vehicleNo" label="车牌号" min-width="120" />
+            <el-table-column prop="remarks" label="备注" />
+            <el-table-column label="操作" width="200" fixed="right">
+              <template #default="{ row }">
+                <el-button link type="primary" @click="handleVehicleEdit(row)">
+                  <el-icon><Edit /></el-icon>
+                  编辑
+                </el-button>
+                <el-button link type="danger" @click="handleVehicleDelete(row.id)">
+                  <el-icon><Delete /></el-icon>
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </art-table>
+        </el-tab-pane>
+
         <!-- 货物管理 -->
         <el-tab-pane label="货物" name="cargo">
           <div class="toolbar">
@@ -173,6 +228,34 @@
         <el-button type="primary" :loading="cargoSaving" @click="handleCargoSubmit">确定</el-button>
       </template>
     </el-dialog>
+
+    <!-- 车牌号新增/编辑对话框 -->
+    <el-dialog
+      v-model="vehicleModalOpen"
+      :title="editingVehicle ? '修改车牌号' : '新增车牌号'"
+      width="500px"
+      @close="handleVehicleModalClose"
+    >
+      <el-form
+        ref="vehicleFormRef"
+        :model="vehicleFormData"
+        :rules="vehicleFormRules"
+        label-width="100px"
+      >
+        <el-form-item label="车牌号" prop="vehicleNo">
+          <el-input v-model="vehicleFormData.vehicleNo" placeholder="请输入车牌号" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remarks">
+          <el-input v-model="vehicleFormData.remarks" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="handleVehicleModalClose">取消</el-button>
+        <el-button type="primary" :loading="vehicleSaving" @click="handleVehicleSubmit">
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -183,10 +266,13 @@
   import {
     companyApi,
     cargoApi,
+    vehicleApi,
     type Company,
     type CompanyCreate,
     type Cargo,
-    type CargoCreate
+    type CargoCreate,
+    type Vehicle,
+    type VehicleCreate
   } from '@/api/masterDataApi'
   import ArtTable from '@/components/Table/ArtTable.vue'
 
@@ -232,6 +318,26 @@
   }
   const cargoSearchKeyword = ref('')
 
+  // 车牌号相关
+  const vehicleLoading = ref(false)
+  const vehicleSaving = ref(false)
+  const vehicleAllData = ref<Vehicle[]>([])
+  const vehicleData = ref<Vehicle[]>([])
+  const vehicleTotal = ref(0)
+  const vehiclePage = ref(1)
+  const vehiclePageSize = ref(10)
+  const vehicleModalOpen = ref(false)
+  const editingVehicle = ref<Vehicle | null>(null)
+  const vehicleFormRef = ref()
+  const vehicleFormData = reactive<VehicleCreate>({
+    vehicleNo: '',
+    remarks: ''
+  })
+  const vehicleFormRules = {
+    vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
+  }
+  const vehicleSearchKeyword = ref('')
+
   const loadCompanies = async () => {
     companyLoading.value = true
     try {
@@ -258,6 +364,19 @@
     }
   }
 
+  const loadVehicles = async () => {
+    vehicleLoading.value = true
+    try {
+      const data = await vehicleApi.list()
+      vehicleAllData.value = data
+      filterVehicleData()
+    } catch (error) {
+      ElMessage.error('加载数据失败')
+    } finally {
+      vehicleLoading.value = false
+    }
+  }
+
   const filterCompanyData = () => {
     if (!companySearchKeyword.value.trim()) {
       companyData.value = companyAllData.value
@@ -288,6 +407,21 @@
     cargoPage.value = 1
   }
 
+  const filterVehicleData = () => {
+    if (!vehicleSearchKeyword.value.trim()) {
+      vehicleData.value = vehicleAllData.value
+    } else {
+      const keyword = vehicleSearchKeyword.value.trim().toLowerCase()
+      vehicleData.value = vehicleAllData.value.filter(
+        (item) =>
+          item.vehicleNo.toLowerCase().includes(keyword) ||
+          (item.remarks && item.remarks.toLowerCase().includes(keyword))
+      )
+    }
+    vehicleTotal.value = vehicleData.value.length
+    vehiclePage.value = 1
+  }
+
   const handleCompanySearch = () => {
     filterCompanyData()
   }
@@ -296,12 +430,18 @@
     filterCargoData()
   }
 
+  const handleVehicleSearch = () => {
+    filterVehicleData()
+  }
+
   const handleTabChange = (tab: string | number) => {
     const tabName = String(tab)
     if (tabName === 'company') {
       loadCompanies()
     } else if (tabName === 'cargo') {
       loadCargos()
+    } else if (tabName === 'vehicle') {
+      loadVehicles()
     }
   }
 
@@ -327,6 +467,17 @@
     loadCargos()
   }
 
+  const handleVehiclePageChange = (page: number) => {
+    vehiclePage.value = page
+    loadVehicles()
+  }
+
+  const handleVehicleSizeChange = (size: number) => {
+    vehiclePageSize.value = size
+    vehiclePage.value = 1
+    loadVehicles()
+  }
+
   const handleCompanyAdd = () => {
     editingCompany.value = null
     Object.assign(companyFormData, { name: '', remarks: '' })
@@ -447,9 +598,70 @@
     cargoFormRef.value?.resetFields()
   }
 
+  const handleVehicleAdd = () => {
+    editingVehicle.value = null
+    Object.assign(vehicleFormData, { vehicleNo: '', remarks: '' })
+    vehicleModalOpen.value = true
+  }
+
+  const handleVehicleEdit = (record: Vehicle) => {
+    editingVehicle.value = record
+    Object.assign(vehicleFormData, {
+      vehicleNo: record.vehicleNo,
+      remarks: record.remarks || ''
+    })
+    vehicleModalOpen.value = true
+  }
+
+  const handleVehicleDelete = (id: number) => {
+    ElMessageBox.confirm('确定要删除该车牌号吗?', '确认删除', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    })
+      .then(async () => {
+        await vehicleApi.delete(id)
+        ElMessage.success('删除成功')
+        loadVehicles()
+      })
+      .catch(() => {})
+  }
+
+  const handleVehicleSubmit = async () => {
+    if (!vehicleFormRef.value) return
+
+    await vehicleFormRef.value.validate(async (valid: boolean) => {
+      if (valid) {
+        vehicleSaving.value = true
+        try {
+          if (editingVehicle.value) {
+            await vehicleApi.update(editingVehicle.value.id, vehicleFormData)
+            ElMessage.success('修改成功')
+          } else {
+            await vehicleApi.create(vehicleFormData)
+            ElMessage.success('新增成功')
+          }
+          vehicleModalOpen.value = false
+          loadVehicles()
+        } catch (error) {
+          ElMessage.error('操作失败')
+        } finally {
+          vehicleSaving.value = false
+        }
+      }
+    })
+  }
+
+  const handleVehicleModalClose = () => {
+    vehicleModalOpen.value = false
+    editingVehicle.value = null
+    vehicleFormRef.value?.resetFields()
+  }
+
   onMounted(() => {
     loadCompanies()
     loadCargos()
+    loadVehicles()
   })
 </script>
 

+ 79 - 15
art-design-pro/src/views/plans/index.vue

@@ -71,6 +71,12 @@
           >
             今日
           </el-button>
+          <el-button
+            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+            @click="setDateFilter('tomorrow')"
+          >
+            明日
+          </el-button>
           <el-button
             :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
             @click="setDateFilter('yesterday')"
@@ -128,7 +134,7 @@
           </template>
         </el-table-column>
         <el-table-column prop="remarks" label="备注" min-width="150" show-overflow-tooltip />
-        <el-table-column label="操作" width="400" fixed="right">
+        <el-table-column label="操作" width="360" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">
               <el-icon><View /></el-icon>
@@ -205,9 +211,6 @@
             <el-form-item label="产品" prop="product">
               <el-select
                 v-model="formData.product"
-                filterable
-                remote
-                :remote-method="loadCargos"
                 placeholder="请选择产品"
                 style="width: 100%"
                 @focus="loadCargos('')"
@@ -354,12 +357,14 @@
 <script setup lang="ts">
   import { ref, reactive, computed, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
-  import { Plus, Upload, Document, Search, View, Edit, Delete, DocumentAdd } from '@element-plus/icons-vue'
+  import { Plus, Upload, Document, Search, View, Edit, Delete, DocumentAdd, Link } from '@element-plus/icons-vue'
   import { planApi, type Plan, type PlanCreate } from '@/api/plansApi'
   import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
+  import { inventoryApi } from '@/api/inventoryApi'
   import { useRouter } from 'vue-router'
   import dayjs from 'dayjs'
   import ArtTable from '@/components/Table/ArtTable.vue'
+  import { copyToClipboard } from '@/utils/utils'
 
   const router = useRouter()
 
@@ -370,7 +375,7 @@
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
 
   // 日期范围筛选
   const dateRange = ref<[string, string]>([
@@ -467,6 +472,10 @@
       if (dateFilterType.value === 'today') {
         params.createdFrom = today
         params.createdTo = today
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = dayjs().add(1, 'day').format('YYYY-MM-DD')
+        params.createdFrom = t
+        params.createdTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
         params.createdFrom = y
@@ -518,7 +527,7 @@
     loadData()
   }
 
-  const setDateFilter = (type: 'all' | 'today' | 'yesterday' | 'week') => {
+  const setDateFilter = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') => {
     dateFilterType.value = type
     currentPage.value = 1
     loadData()
@@ -647,18 +656,69 @@
       .catch(() => {})
   }
 
-  // 生成出入库单
+  // 生成出入库单(带 from=plan,在出入库页取消/确定后会回到计划管理)
   const handleGenerateInventory = (record: Plan) => {
-    router.push(
-      `/inventory?planNo=${encodeURIComponent(record.planNo || '')}&product=${encodeURIComponent(
-        record.product || ''
-      )}&businessType=${encodeURIComponent(record.businessType || '')}&customerName=${encodeURIComponent(
-        record.customerName || ''
-      )}&purchasePlanId=${record.id}`
-    )
+    router.push({
+      path: '/inventory',
+      query: {
+        planNo: record.planNo || '',
+        product: record.product || '',
+        businessType: record.businessType || '',
+        customerName: record.customerName || '',
+        purchasePlanId: String(record.id),
+        from: 'plan'
+      }
+    })
     ElMessage.success('已跳转到出入库页面,请填写详细信息')
   }
 
+  // 三方填写链接统一指向 H5 页面。Web 与 H5 不同源(本机端口不同、生产域名不同),
+  // 必须配置 VITE_H5_BASE_URL 为 H5 的访问根地址,否则生成的链接会错误指向 Web 自己。
+  const getH5FillBaseUrl = () => {
+    const envBase = import.meta.env.VITE_H5_BASE_URL
+    if (envBase) return envBase.replace(/\/$/, '')
+    return window.location.origin + (window.location.pathname || '')
+  }
+
+  const getFillLinkUrl = (inventoryId: number) => {
+    return `${getH5FillBaseUrl()}/#/inventory/fill/${inventoryId}`
+  }
+
+  const handleFillLink = async (record: Plan) => {
+    try {
+      const inv = await inventoryApi.createLinkForPlan(record.id)
+      const url = getFillLinkUrl(inv.id)
+      const text = `产品:${record.product || ''}\n二维码地址: ${url}`
+      const ok = await copyToClipboard(text)
+      if (ok) {
+        ElMessage.success('内容已复制到剪贴板')
+      } else {
+        ElMessage.warning('当前环境不支持一键复制,请手动复制链接')
+      }
+    } catch (e: any) {
+      ElMessage.error(e?.response?.data?.detail || e?.message || '操作失败')
+    }
+  }
+
+  const getPlanInventoryPageUrl = (planId: number) => {
+    return `${getH5FillBaseUrl()}/#/inventory/plan/${planId}`
+  }
+
+  const handlePlanInventoryLink = async (record: Plan) => {
+    try {
+      const url = getPlanInventoryPageUrl(record.id)
+      const text = `往来单位:${record.customerName || ''}\n产品:${record.product || ''}\n计划有效期:${record.startDate || '--'} 至 ${record.endDate || '--'}\n出入库单链接:${url}`
+      const ok = await copyToClipboard(text)
+      if (ok) {
+        ElMessage.success('出入库单链接已复制到剪贴板')
+      } else {
+        ElMessage.warning('当前环境不支持一键复制,请手动复制链接')
+      }
+    } catch (e: any) {
+      ElMessage.error(e?.message || '复制链接失败')
+    }
+  }
+
   // 提交表单
   const handleSubmit = async () => {
     if (!formRef.value) return
@@ -712,6 +772,10 @@
       if (dateFilterType.value === 'today') {
         params.createdFrom = today
         params.createdTo = today
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = dayjs().add(1, 'day').format('YYYY-MM-DD')
+        params.createdFrom = t
+        params.createdTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
         params.createdFrom = y

+ 46 - 7
art-design-pro/src/views/weighing/index.vue

@@ -56,6 +56,12 @@
           >
             今日
           </el-button>
+          <el-button
+            :type="dateFilterType === 'tomorrow' ? 'primary' : 'default'"
+            @click="setDateFilter('tomorrow')"
+          >
+            明日
+          </el-button>
           <el-button
             :type="dateFilterType === 'yesterday' ? 'primary' : 'default'"
             @click="setDateFilter('yesterday')"
@@ -135,7 +141,9 @@
               v-if="row.isVoided === 0"
               link
               type="warning"
-              @click="handleVoid(row.id)"
+              :disabled="!isWeighingRecordToday(row)"
+              :title="isWeighingRecordToday(row) ? '' : '仅支持作废当天的地磅单'"
+              @click="handleVoid(row)"
             >
               <el-icon><Close /></el-icon>
               作废
@@ -432,7 +440,7 @@
   const currentPage = ref(1)
   const pageSize = ref(10)
   const searchKeyword = ref('')
-  const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+  const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
 
   // 时间范围筛选
   const timeRange = ref<[string, string]>([
@@ -543,6 +551,11 @@
       return { dateFrom: today, dateTo: today }
     }
 
+    if (dateFilterType.value === 'tomorrow') {
+      const t = dayjs().add(1, 'day').format('YYYY-MM-DD')
+      return { dateFrom: t, dateTo: t }
+    }
+
     if (dateFilterType.value === 'yesterday') {
       const y = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
       return { dateFrom: y, dateTo: y }
@@ -560,7 +573,7 @@
     return { dateFrom: undefined, dateTo: undefined }
   })
 
-  const setDateFilter = (type: 'all' | 'today' | 'yesterday' | 'week') => {
+  const setDateFilter = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') => {
     dateFilterType.value = type
     currentPage.value = 1
     loadData()
@@ -595,8 +608,14 @@
       }
 
       const records = await weighingApi.list(params)
-      allData.value = records
-      total.value = records.length
+      // 按日期倒序排列(日期取 inventoryDate | 毛重日期 | 创建日期)
+      const getRecordDate = (r: WeighingRecord) =>
+        r.inventoryDate ||
+        (r.grossWeightTime ? r.grossWeightTime.split(' ')[0] : '') ||
+        (r.createdAt ? r.createdAt.split(' ')[0] : '') ||
+        ''
+      allData.value = records.slice().sort((a, b) => getRecordDate(b).localeCompare(getRecordDate(a)))
+      total.value = allData.value.length
       // 如果当前页超出范围,重置到第一页
       const maxPage = Math.ceil(total.value / pageSize.value) || 1
       if (currentPage.value > maxPage) {
@@ -690,6 +709,22 @@
     modalOpen.value = true
   }
 
+  /** 获取地磅单记录日期(inventoryDate | 毛重日期 | 创建日期) */
+  const getWeighingRecordDate = (row: WeighingRecord) => {
+    return (
+      row.inventoryDate ||
+      (row.grossWeightTime ? row.grossWeightTime.split(' ')[0] : '') ||
+      (row.createdAt ? row.createdAt.split(' ')[0] : '') ||
+      ''
+    )
+  }
+
+  /** 判断是否为当天记录(仅当天可作废) */
+  const isWeighingRecordToday = (row: WeighingRecord) => {
+    const dateStr = getWeighingRecordDate(row)
+    return dateStr === dayjs().format('YYYY-MM-DD')
+  }
+
   const handleDelete = (id: number) => {
     ElMessageBox.confirm('确定要删除这条记录吗?', '确认删除', {
       confirmButtonText: '确定',
@@ -704,14 +739,18 @@
       .catch(() => {})
   }
 
-  const handleVoid = (id: number) => {
+  const handleVoid = (row: WeighingRecord) => {
+    if (!isWeighingRecordToday(row)) {
+      ElMessage.warning('仅支持作废当天的地磅单,历史数据不可作废')
+      return
+    }
     ElMessageBox.confirm('确定要作废这条地磅单吗?', '确认作废', {
       confirmButtonText: '确定',
       cancelButtonText: '取消',
       type: 'warning'
     })
       .then(async () => {
-        await weighingApi.void(id)
+        await weighingApi.void(row.id)
         ElMessage.success('作废成功')
         loadData()
       })

+ 1 - 1
art-design-pro/vite.config.ts

@@ -64,7 +64,7 @@ export default ({ mode }) => {
                 if (pathParts.length === 3 && pathParts[0] === 'api' && pathParts[1] === 'master-data') {
                   const resource = pathParts[2]
                   // 基础资料模块的列表接口:companies, cargos
-                  const masterDataResources = ['companies', 'cargos']
+                  const masterDataResources = ['companies', 'cargos', 'vehicles']
                   if (masterDataResources.includes(resource)) {
                     const newPath = path + '/' + (query ? '?' + query : '')
                     proxyReq.path = newPath

+ 1 - 1
backend/.env.production

@@ -19,7 +19,7 @@ ENV=prod
 # ============================================
 # MySQL 数据库连接字符串
 # 格式: mysql+asyncmy://用户名:密码@主机:端口/数据库名?charset=utf8mb4
-DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang?charset=utf8mb4
+DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang-cs?charset=utf8mb4
 
 # ============================================
 # JWT 配置

+ 2 - 0
backend/app/api/routes/__init__.py

@@ -9,6 +9,7 @@ from app.api.routes import (
     inventory,
     plans,
     users,
+    vehicles,
     weighing,
 )
 
@@ -17,6 +18,7 @@ api_router.include_router(auth.router)
 api_router.include_router(health.router, prefix="/system")
 api_router.include_router(companies.router)
 api_router.include_router(cargos.router)
+api_router.include_router(vehicles.router)
 api_router.include_router(dashboard.router)
 api_router.include_router(inventory.router)
 api_router.include_router(plans.router)

+ 112 - 20
backend/app/api/routes/inventory.py

@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
 
 from app.db.session import get_async_session
 from app.models.inventory import InboundOutbound
+from app.models.master_data import Vehicle
 from app.models.weighing import WeighingSlip
 from app.models.plans import PurchasePlan
 from app.schemas.inventory import (
@@ -19,7 +20,7 @@ from app.schemas.inventory import (
 )
 from app.schemas.plans import PurchasePlanRead
 from app.schemas.weighing import WeighingSlipRead
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 
@@ -90,23 +91,25 @@ async def list_inventory(
 
     result = await session.exec(query)
     inventories = result.all()
-    
-    # 为每个出入库单查询关联的地磅单数据(一对一关系)
-    result_list = []
-    for inventory in inventories:
-        # 查询该出入库单关联的未作废的地磅单(一对一关系,只取第一个)
+
+    # 批量查询所有出入库单关联的地磅单(避免 N+1)
+    inventory_ids = [inv.id for inv in inventories if inv.id is not None]
+    weighing_map: dict[int, WeighingSlip] = {}
+    if inventory_ids:
         weighing_result = await session.exec(
             select(WeighingSlip).where(
-                WeighingSlip.inbound_outbound_id == inventory.id,
+                WeighingSlip.inbound_outbound_id.in_(inventory_ids),
                 WeighingSlip.is_voided == 0,
-            ).limit(1)
+            ).order_by(WeighingSlip.id.asc())
         )
-        weighing = weighing_result.first()
-        
-        # 将出入库单转换为字典,然后添加地磅单数据
+        for w in weighing_result.all():
+            if w.inbound_outbound_id is not None and w.inbound_outbound_id not in weighing_map:
+                weighing_map[w.inbound_outbound_id] = w
+
+    result_list = []
+    for inventory in inventories:
+        weighing = weighing_map.get(inventory.id) if inventory.id else None
         inventory_dict = inventory.model_dump()
-        
-        # 如果有关联的地磅单,直接使用其数据
         if weighing:
             inventory_dict['gross_weight'] = weighing.gross_weight if weighing.gross_weight is not None else None
             inventory_dict['tare_weight'] = weighing.tare_weight if weighing.tare_weight is not None else None
@@ -114,20 +117,20 @@ async def list_inventory(
             inventory_dict['net_weight'] = weighing.net_weight if weighing.net_weight is not None else None
             inventory_dict['gross_weight_time'] = weighing.gross_weight_time if weighing.gross_weight_time and weighing.gross_weight_time.strip() else None
             inventory_dict['tare_weight_time'] = weighing.tare_weight_time if weighing.tare_weight_time and weighing.tare_weight_time.strip() else None
-            inventory_dict['has_weighing_slip'] = True  # 已有关联的地磅单
+            inventory_dict['has_weighing_slip'] = True
         else:
-            # 如果没有关联的地磅单,设置为None
             inventory_dict['gross_weight'] = None
             inventory_dict['tare_weight'] = None
             inventory_dict['deduction'] = None
             inventory_dict['net_weight'] = None
             inventory_dict['gross_weight_time'] = None
             inventory_dict['tare_weight_time'] = None
-            inventory_dict['has_weighing_slip'] = False  # 没有关联的地磅单
-        
-        # 使用 schema 验证并返回
-        result_list.append(InboundOutboundRead.model_validate(inventory_dict))
-    
+            inventory_dict['has_weighing_slip'] = False
+        try:
+            result_list.append(InboundOutboundRead.model_validate(inventory_dict))
+        except Exception:
+            # 单条数据校验失败不影响整表返回,跳过该条
+            continue
     return result_list
 
 
@@ -169,6 +172,60 @@ async def create_inventory(
     return inventory
 
 
+class CreateLinkForPlanPayload(BaseModel):
+    purchase_plan_id: int = Field(alias="purchasePlanId")
+    remarks: str | None = None
+
+    model_config = {"populate_by_name": True}
+
+
+def _status_third_party_fill() -> str:
+    """三方填写待完善状态的 status 值"""
+    return "待三方填写"
+
+
+# 必须放在 /{id} 之前,否则 create-link-for-plan 会被匹配成 id 导致 405
+@router.post("/create-link-for-plan", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
+async def create_link_for_plan(
+    payload: CreateLinkForPlanPayload,
+    session: AsyncSession = Depends(get_async_session),
+) -> InboundOutbound:
+    """根据计划预生成出入库单(日期、数量、车牌号为空),供三方通过链接填写。同一计划可多次点击,每次生成一条新出入库单。"""
+    plan_result = await session.exec(
+        select(PurchasePlan).where(
+            PurchasePlan.id == payload.purchase_plan_id,
+            PurchasePlan.is_deleted == 0,
+        )
+    )
+    plan = plan_result.one_or_none()
+    if not plan:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Plan not found",
+        )
+
+    batch_no = await _generate_batch_no(session)
+    inventory = InboundOutbound(
+        date="",
+        partner_company=(plan.customer_name or "").strip() or "待填写",
+        business_type=plan.business_type,
+        product=(plan.product or "").strip(),
+        quantity=0.0,
+        plan_no=(plan.plan_no or "").strip() or None,
+        batch_no=batch_no,
+        vehicle_no="",
+        status=_status_third_party_fill(),
+        remarks=payload.remarks,
+        purchase_plan_id=plan.id,
+        is_deleted=0,
+        created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+    )
+    session.add(inventory)
+    await session.commit()
+    await session.refresh(inventory)
+    return inventory
+
+
 @router.get("/{id}", response_model=InboundOutboundRead)
 async def get_inventory(
     id: int,
@@ -177,6 +234,29 @@ async def get_inventory(
     return await _get_inventory(id, session)
 
 
+async def _ensure_vehicle_exists(vehicle_no: str, session: AsyncSession) -> None:
+    if not (vehicle_no and vehicle_no.strip()):
+        return
+    from sqlalchemy import func
+    v = vehicle_no.strip()
+    result = await session.exec(
+        select(Vehicle).where(
+            Vehicle.is_deleted == 0,
+            func.lower(Vehicle.vehicle_no) == v.lower(),
+        ).limit(1)
+    )
+    if result.first():
+        return
+    from datetime import datetime as dt
+    vehicle = Vehicle(
+        vehicle_no=v,
+        remarks=None,
+        is_deleted=0,
+        created_at=dt.now().isoformat(),
+    )
+    session.add(vehicle)
+
+
 @router.patch("/{id}", response_model=InboundOutboundRead)
 async def update_inventory(
     id: int,
@@ -186,11 +266,23 @@ async def update_inventory(
     inventory = await _get_inventory(id, session)
     payload_dict = payload.model_dump(exclude_unset=True)
 
+    vehicle_no_updated = payload_dict.get("vehicle_no")
+    if vehicle_no_updated is not None and str(vehicle_no_updated).strip():
+        await _ensure_vehicle_exists(str(vehicle_no_updated).strip(), session)
+
     for key, value in payload_dict.items():
         if isinstance(value, str):
             value = value.strip()
         setattr(inventory, key, value)
 
+    # 三方填写链接保存后:若当前为「待三方填写」且日期、数量、车牌号均已填写,则改为待入库/待出库
+    if inventory.status == _status_third_party_fill():
+        has_date = bool(inventory.date and inventory.date.strip())
+        has_qty = inventory.quantity is not None and inventory.quantity != 0
+        has_vehicle = bool(inventory.vehicle_no and inventory.vehicle_no.strip())
+        if has_date and has_qty and has_vehicle:
+            inventory.status = "待入库" if inventory.business_type == "入库" else "待出库"
+
     await session.commit()
     await session.refresh(inventory)
     return inventory

+ 113 - 5
backend/app/api/routes/plans.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime
+from datetime import datetime, date
 
 from fastapi import APIRouter, Depends, HTTPException, Query, status
 from sqlalchemy import func
@@ -11,7 +11,7 @@ from app.db.session import get_async_session
 from app.models.plans import PurchasePlan
 from app.models.inventory import InboundOutbound
 from app.models.weighing import WeighingSlip
-from app.schemas.plans import PurchasePlanCreate, PurchasePlanRead, PurchasePlanUpdate
+from app.schemas.plans import PurchasePlanCreate, PurchasePlanRead, PurchasePlanUpdate, PurchasePlanWithFillLink
 from app.schemas.inventory import InboundOutboundRead
 from app.schemas.weighing import WeighingSlipRead
 from pydantic import BaseModel
@@ -23,7 +23,10 @@ def _base_query():
     return select(PurchasePlan).where(PurchasePlan.is_deleted == 0)
 
 
-@router.get("/", response_model=list[PurchasePlanRead])
+STATUS_THIRD_PARTY_FILL = "待三方填写"
+
+
+@router.get("/", response_model=list[PurchasePlanWithFillLink])
 async def list_plans(
     q: str | None = Query(default=None, description="模糊搜索往来单位或产品"),
     business_type: str | None = Query(default=None, alias="businessType", description="业务类型"),
@@ -33,7 +36,7 @@ async def list_plans(
     start_date_from: str | None = Query(default=None, alias="startDateFrom", description="计划起始日期开始"),
     start_date_to: str | None = Query(default=None, alias="startDateTo", description="计划起始日期结束"),
     session: AsyncSession = Depends(get_async_session),
-) -> list[PurchasePlan]:
+) -> list[PurchasePlanWithFillLink]:
     # 默认按创建时间倒序
     query = _base_query().order_by(PurchasePlan.created_at.desc())
 
@@ -65,7 +68,27 @@ async def list_plans(
         query = query.where(PurchasePlan.start_date <= start_date_to)
 
     result = await session.exec(query)
-    return result.all()
+    plans = result.all()
+    if not plans:
+        return []
+
+    plan_ids = [p.id for p in plans]
+    fill_link_result = await session.exec(
+        select(InboundOutbound).where(
+            InboundOutbound.purchase_plan_id.in_(plan_ids),
+            InboundOutbound.is_deleted == 0,
+            InboundOutbound.status == STATUS_THIRD_PARTY_FILL,
+        )
+    )
+    fill_links = fill_link_result.all()
+    plan_id_to_inv_id = {inv.purchase_plan_id: inv.id for inv in fill_links if inv.purchase_plan_id}
+
+    return [
+        PurchasePlanWithFillLink(
+            **{**p.model_dump(), "fill_link_inventory_id": plan_id_to_inv_id.get(p.id)},
+        )
+        for p in plans
+    ]
 
 
 def _generate_plan_no() -> str:
@@ -203,3 +226,88 @@ async def get_plan_related(
         inventories=[InboundOutboundRead.model_validate(inv.model_dump()) for inv in inventories],
         weighings=[WeighingSlipRead.model_validate(w.model_dump()) for w in weighings]
     )
+
+
+class ThirdPartyPlanPageData(BaseModel):
+    """三方填写 H5 页面数据:计划信息 + 当天出入库单列表 + 本月历史出入库单列表"""
+
+    plan: PurchasePlanRead
+    today_inventories: list[InboundOutboundRead]
+    month_inventories: list[InboundOutboundRead]
+
+
+def _parse_date_str(d: str | None) -> date | None:
+    if not d:
+        return None
+    try:
+        return datetime.strptime(d, "%Y-%m-%d").date()
+    except Exception:
+        return None
+
+
+@router.get("/{id}/third-party-page", response_model=ThirdPartyPlanPageData)
+async def get_third_party_plan_page(
+    id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> ThirdPartyPlanPageData:
+    """
+    提供给三方使用的 H5 页面初始化数据:
+    - 校验当前日期是否在计划有效期内(start_date/end_date)
+    - 返回计划基本信息
+    - 返回该计划“当天”的出入库单列表(按日期+id 倒序)
+    """
+    plan = await _get_plan(id, session)
+
+    today = date.today()
+    start = _parse_date_str(plan.start_date)
+    end = _parse_date_str(plan.end_date)
+
+    # 仅在计划有效期内允许填写:如果配置了开始/结束日期,则进行约束
+    if start and today < start:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="计划尚未生效,当前日期不允许填写出入库单",
+        )
+    if end and today > end:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="计划已过期,当前日期不允许填写出入库单",
+        )
+
+    # 查询该计划当天的出入库单(基于 date 字段)
+    today_str = today.strftime("%Y-%m-%d")
+    inventory_result = await session.exec(
+        select(InboundOutbound)
+        .where(
+            InboundOutbound.purchase_plan_id == plan.id,
+            InboundOutbound.is_deleted == 0,
+            InboundOutbound.date == today_str,
+        )
+        .order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
+    )
+    inventories = inventory_result.all()
+
+    # 查询该计划本月(不含今天)的历史出入库单
+    month_start_str = today.replace(day=1).strftime("%Y-%m-%d")
+    month_history_result = await session.exec(
+        select(InboundOutbound)
+        .where(
+            InboundOutbound.purchase_plan_id == plan.id,
+            InboundOutbound.is_deleted == 0,
+            InboundOutbound.date >= month_start_str,
+            InboundOutbound.date < today_str,
+        )
+        .order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
+    )
+    month_history_inventories = month_history_result.all()
+
+    return ThirdPartyPlanPageData(
+        plan=PurchasePlanRead.model_validate(plan.model_dump()),
+        today_inventories=[
+            InboundOutboundRead.model_validate(inv.model_dump()) for inv in inventories
+        ],
+        month_inventories=[
+            InboundOutboundRead.model_validate(inv.model_dump())
+            for inv in month_history_inventories
+        ],
+    )

+ 132 - 0
backend/app/api/routes/vehicles.py

@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import func
+from sqlmodel import select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from app.db.session import get_async_session
+from app.models.master_data import Vehicle
+from app.schemas.master_data import (
+    VehicleCreate,
+    VehicleRead,
+    VehicleUpdate,
+)
+
+router = APIRouter(prefix="/master-data/vehicles", tags=["vehicles"])
+
+
+def _base_query():
+    return select(Vehicle).where(Vehicle.is_deleted == 0)
+
+
+@router.get("/", response_model=list[VehicleRead])
+async def list_vehicles(
+    q: str | None = Query(default=None, description="模糊搜索车牌号"),
+    session: AsyncSession = Depends(get_async_session),
+) -> list[Vehicle]:
+    query = _base_query().order_by(Vehicle.created_at.desc())
+    if q:
+        query = query.where(func.lower(Vehicle.vehicle_no).contains(q.lower()))
+    result = await session.exec(query)
+    return result.all()
+
+
+@router.get("/{vehicle_id}", response_model=VehicleRead)
+async def get_vehicle(
+    vehicle_id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> Vehicle:
+    vehicle = await _get_vehicle(vehicle_id, session)
+    return vehicle
+
+
+@router.post("/", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
+async def create_vehicle(
+    payload: VehicleCreate,
+    session: AsyncSession = Depends(get_async_session),
+) -> Vehicle:
+    from datetime import datetime
+
+    await _ensure_unique_vehicle_no(payload.vehicle_no, session)
+    vehicle = Vehicle(
+        vehicle_no=payload.vehicle_no.strip(),
+        remarks=payload.remarks,
+        created_at=datetime.now().isoformat(),
+    )
+    session.add(vehicle)
+    await session.commit()
+    await session.refresh(vehicle)
+    return vehicle
+
+
+@router.put("/{vehicle_id}", response_model=VehicleRead)
+async def update_vehicle(
+    vehicle_id: int,
+    payload: VehicleUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> Vehicle:
+    vehicle = await _get_vehicle(vehicle_id, session)
+    if payload.vehicle_no and payload.vehicle_no.strip().lower() != vehicle.vehicle_no.lower():
+        await _ensure_unique_vehicle_no(payload.vehicle_no, session)
+        vehicle.vehicle_no = payload.vehicle_no.strip()
+    if payload.remarks is not None:
+        vehicle.remarks = payload.remarks
+    await session.commit()
+    await session.refresh(vehicle)
+    return vehicle
+
+
+@router.patch("/{vehicle_id}", response_model=VehicleRead)
+async def patch_vehicle(
+    vehicle_id: int,
+    payload: VehicleUpdate,
+    session: AsyncSession = Depends(get_async_session),
+) -> Vehicle:
+    vehicle = await _get_vehicle(vehicle_id, session)
+    if payload.vehicle_no and payload.vehicle_no.strip().lower() != vehicle.vehicle_no.lower():
+        await _ensure_unique_vehicle_no(payload.vehicle_no, session)
+        vehicle.vehicle_no = payload.vehicle_no.strip()
+    if payload.remarks is not None:
+        vehicle.remarks = payload.remarks
+    await session.commit()
+    await session.refresh(vehicle)
+    return vehicle
+
+
+@router.delete(
+    "/{vehicle_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_vehicle(
+    vehicle_id: int,
+    session: AsyncSession = Depends(get_async_session),
+) -> None:
+    vehicle = await _get_vehicle(vehicle_id, session)
+    vehicle.is_deleted = 1
+    await session.commit()
+
+
+async def _get_vehicle(vehicle_id: int, session: AsyncSession) -> Vehicle:
+    result = await session.exec(
+        _base_query().where(Vehicle.id == vehicle_id),
+    )
+    vehicle = result.one_or_none()
+    if vehicle is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"Vehicle {vehicle_id} not found",
+        )
+    return vehicle
+
+
+async def _ensure_unique_vehicle_no(vehicle_no: str, session: AsyncSession) -> None:
+    normalized = vehicle_no.strip().lower()
+    result = await session.exec(
+        _base_query().where(func.lower(Vehicle.vehicle_no) == normalized),
+    )
+    if result.first():
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail="Vehicle number already exists",
+        )

+ 1 - 0
backend/app/db/session.py

@@ -13,6 +13,7 @@ from app.models import (  # noqa: F401
     Inventory,
     Plan,
     User,
+    Vehicle,
     Weighing,
 )
 

+ 2 - 1
backend/app/models/__init__.py

@@ -1,5 +1,5 @@
 from app.models.inventory import InboundOutbound, Inventory
-from app.models.master_data import Cargo, Company
+from app.models.master_data import Cargo, Company, Vehicle
 from app.models.plans import Plan, PurchasePlan
 from app.models.users import OperationLog, User
 from app.models.weighing import Weighing, WeighingSlip
@@ -7,6 +7,7 @@ from app.models.weighing import Weighing, WeighingSlip
 __all__ = [
     "Cargo",
     "Company",
+    "Vehicle",
     "InboundOutbound",
     "Inventory",
     "OperationLog",

+ 11 - 0
backend/app/models/master_data.py

@@ -21,3 +21,14 @@ class Cargo(SQLModel, table=True):
     remarks: str | None = Field(default=None)
     is_deleted: int = Field(default=0, index=True)
     created_at: str = Field(max_length=255)
+
+
+class Vehicle(SQLModel, table=True):
+    """车牌号基础资料表"""
+    __tablename__ = "vehicles"
+
+    id: int | None = Field(default=None, primary_key=True)
+    vehicle_no: str = Field(index=True, unique=True, max_length=255)  # 车牌号
+    remarks: str | None = Field(default=None)
+    is_deleted: int = Field(default=0, index=True)
+    created_at: str = Field(max_length=255)

+ 25 - 0
backend/app/schemas/master_data.py

@@ -41,3 +41,28 @@ class CargoUpdate(BaseModel):
 
 class CargoRead(CargoBase):
     id: int
+
+
+# ==================== 车牌号 ====================
+
+
+class VehicleBase(BaseModel):
+    vehicle_no: str = Field(min_length=1, max_length=255, alias="vehicleNo")
+    remarks: str | None = Field(default=None)
+
+    model_config = {"from_attributes": True, "populate_by_name": True}
+
+
+class VehicleCreate(VehicleBase):
+    pass
+
+
+class VehicleUpdate(BaseModel):
+    vehicle_no: str | None = Field(default=None, min_length=1, max_length=255, alias="vehicleNo")
+    remarks: str | None = Field(default=None)
+
+    model_config = {"populate_by_name": True}
+
+
+class VehicleRead(VehicleBase):
+    id: int

+ 5 - 0
backend/app/schemas/plans.py

@@ -66,6 +66,11 @@ class PurchasePlanRead(PurchasePlanBase):
     created_at: str = Field(alias="createdAt")
 
 
+class PurchasePlanWithFillLink(PurchasePlanRead):
+    """计划列表项,带「生成链接交三方填写」对应的出入库单 id"""
+    fill_link_inventory_id: int | None = Field(default=None, alias="fillLinkInventoryId")
+
+
 # 兼容别名
 PlanBase = PurchasePlanBase
 PlanCreate = PurchasePlanCreate

+ 1 - 1
backend/env.production.example

@@ -19,7 +19,7 @@ ENV=prod
 # ============================================
 # MySQL 数据库连接字符串
 # 格式: mysql+asyncmy://用户名:密码@主机:端口/数据库名?charset=utf8mb4
-DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang?charset=utf8mb4
+DATABASE_URL=mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang-cs?charset=utf8mb4
 
 # ============================================
 # JWT 配置

+ 100 - 0
backend/scripts/init_vehicles_from_history.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""
+从历史出入库表(inbound_outbound)、地磅单表(weighing_slips)中提取不重复的车牌号,
+初始化写入车牌号基础资料表(vehicles)。
+
+使用方式(在 backend 目录下执行):
+  python -m scripts.init_vehicles_from_history
+或:
+  uv run python -m scripts.init_vehicles_from_history
+"""
+from __future__ import annotations
+
+import asyncio
+import sys
+from pathlib import Path
+
+# 将 backend 根目录加入 path,以便 import app
+backend_root = Path(__file__).resolve().parent.parent
+if str(backend_root) not in sys.path:
+    sys.path.insert(0, str(backend_root))
+
+
+async def main() -> None:
+    from sqlmodel import select
+    from sqlmodel.ext.asyncio.session import AsyncSession
+
+    from app.db.session import init_models, AsyncSessionLocal
+    from app.models.inventory import InboundOutbound
+    from app.models.master_data import Vehicle
+    from app.models.weighing import WeighingSlip
+
+    # 1. 确保所有表已创建(含 vehicles)
+    await init_models()
+
+    # 2. 从出入库表、地磅单表收集不重复车牌号(去空、去重,按原始大小写保留一个)
+    seen_normalized: set[str] = set()
+    vehicle_nos: list[str] = []
+
+    async with AsyncSessionLocal() as session:
+        # 出入库表:未删除记录的车牌号
+        q_inv = select(InboundOutbound.vehicle_no).where(InboundOutbound.is_deleted == 0)
+        result_inv = await session.exec(q_inv)
+        for (v,) in result_inv.all():
+            v = (v or "").strip()
+            if not v:
+                continue
+            key = v.lower()
+            if key not in seen_normalized:
+                seen_normalized.add(key)
+                vehicle_nos.append(v)
+
+        # 地磅单表:未作废记录的车牌号
+        q_ws = select(WeighingSlip.vehicle_no).where(WeighingSlip.is_voided == 0)
+        result_ws = await session.exec(q_ws)
+        for (v,) in result_ws.all():
+            v = (v or "").strip()
+            if not v:
+                continue
+            key = v.lower()
+            if key not in seen_normalized:
+                seen_normalized.add(key)
+                vehicle_nos.append(v)
+
+    if not vehicle_nos:
+        print("未从出入库/地磅单表中发现任何车牌号,无需导入。")
+        return
+
+    # 3. 写入 vehicles 表(仅插入尚未存在的车牌号)
+    from datetime import datetime
+
+    added = 0
+    skipped = 0
+    async with AsyncSessionLocal() as session:
+        existing = await session.exec(
+            select(Vehicle.vehicle_no).where(Vehicle.is_deleted == 0)
+        )
+        existing_set = {row.lower() for row in existing.all()}
+
+        for v in vehicle_nos:
+            if v.lower() in existing_set:
+                skipped += 1
+                continue
+            vehicle = Vehicle(
+                vehicle_no=v,
+                remarks=None,
+                is_deleted=0,
+                created_at=datetime.now().isoformat(),
+            )
+            session.add(vehicle)
+            existing_set.add(v.lower())
+            added += 1
+
+        await session.commit()
+
+    print(f"历史车牌号统计:共发现 {len(vehicle_nos)} 个不重复车牌号。")
+    print(f"本次新增写入 vehicles 表:{added} 条;已存在跳过:{skipped} 条。")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 1 - 1
backend/tests/conftest.py

@@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
 
 # Use MySQL test database from environment or default
 os.environ.setdefault(
-    "DATABASE_URL", "mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang?charset=utf8mb4"
+    "DATABASE_URL", "mysql+asyncmy://root:hn02le.34lkdLKD@121.43.228.102:3306/dibang-cs?charset=utf8mb4"
 )
 os.environ.setdefault("ENV", "test")
 

+ 136 - 0
dibang-test.sql

@@ -0,0 +1,136 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 121.43.228.102 hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 121.43.228.102:3306
+ Source Schema         : dibang-test
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 23/02/2026 22:34:34
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for cargos
+-- ----------------------------
+DROP TABLE IF EXISTS `cargos`;
+CREATE TABLE `cargos` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物名称',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除(0=否,1=是)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`),
+  KEY `idx_name` (`name`),
+  KEY `idx_cargo_is_deleted` (`is_deleted`)
+) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物名称表';
+
+-- ----------------------------
+-- Table structure for inbound_outbound
+-- ----------------------------
+DROP TABLE IF EXISTS `inbound_outbound`;
+CREATE TABLE `inbound_outbound` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `date` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '日期',
+  `partner_company` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '往来单位',
+  `business_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型(入库/出库)',
+  `product` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品',
+  `quantity` double NOT NULL COMMENT '本次量(t)',
+  `plan_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计划号(关联计划)',
+  `batch_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '批次号',
+  `vehicle_no` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号',
+  `status` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '状态(待入库/待出库,已入库/已出库)',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `purchase_plan_id` int(11) DEFAULT NULL COMMENT '关联的计划ID',
+  `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除(0=否,1=是)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_plan_no` (`plan_no`),
+  KEY `idx_purchase_plan_id` (`purchase_plan_id`),
+  KEY `idx_date` (`date`),
+  KEY `idx_is_deleted` (`is_deleted`),
+  KEY `idx_status` (`status`),
+  KEY `idx_vehicle_no` (`vehicle_no`)
+) ENGINE=InnoDB AUTO_INCREMENT=897 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出入库单表';
+
+-- ----------------------------
+-- Table structure for purchase_plans
+-- ----------------------------
+DROP TABLE IF EXISTS `purchase_plans`;
+CREATE TABLE `purchase_plans` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `plan_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计划号(自动生成,格式:SX-YYYYMMDDMMHHSS001)',
+  `plan_date` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计划日期(兼容字段)',
+  `start_date` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '开始时间',
+  `end_date` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结束时间',
+  `business_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型(入库/出库)',
+  `product` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品名称',
+  `specification` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '规格',
+  `planned_quantity` double NOT NULL COMMENT '计划数量(t)',
+  `vehicle_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '',
+  `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '往来单位',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除(0=否,1=是)',
+  `execution_status` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '执行状态(待入库/待出库,已入库/已出库)',
+  `executed_at` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '执行时间',
+  `last_weighing_slip_id` int(11) DEFAULT NULL COMMENT '最后关联的地磅单ID',
+  `executed_quantity` double DEFAULT '0' COMMENT '已执行量(kg)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_plan_no` (`plan_no`),
+  KEY `idx_plan_date` (`plan_date`),
+  KEY `idx_start_date` (`start_date`),
+  KEY `idx_end_date` (`end_date`),
+  KEY `idx_business_type` (`business_type`),
+  KEY `idx_product` (`product`),
+  KEY `idx_is_deleted` (`is_deleted`),
+  KEY `idx_execution_status` (`execution_status`)
+) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计划表';
+
+-- ----------------------------
+-- Table structure for weighing_slips
+-- ----------------------------
+DROP TABLE IF EXISTS `weighing_slips`;
+CREATE TABLE `weighing_slips` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `vehicle_no` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车号',
+  `plan_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计划单号',
+  `cargo_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物名称',
+  `business_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型(入库/出库)',
+  `batch_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '批次号',
+  `supplier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供货单位',
+  `consignee` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货单位',
+  `gross_weight` double NOT NULL COMMENT '毛重(kg)',
+  `gross_weight_time` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '毛重时间',
+  `tare_weight` double NOT NULL COMMENT '皮重(kg)',
+  `tare_weight_time` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '皮重时间',
+  `deduction` double DEFAULT '0' COMMENT '扣杂(kg)',
+  `net_weight` double NOT NULL COMMENT '净重(kg)',
+  `planned_quantity` double DEFAULT NULL COMMENT '计划数量(kg)',
+  `executed_quantity` double DEFAULT NULL COMMENT '执行数量(kg)',
+  `purchase_plan_id` int(11) DEFAULT NULL COMMENT '关联的计划ID',
+  `inbound_outbound_id` int(11) DEFAULT NULL COMMENT '关联的出入库单ID',
+  `weighing_point` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '山峡地磅' COMMENT '称重点',
+  `remarks` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
+  `current_quantity` double DEFAULT NULL COMMENT '本次量(kg)',
+  `is_voided` int(11) DEFAULT '0' COMMENT '是否作废(0=否, 1=是)',
+  `created_at` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_vehicle_no` (`vehicle_no`),
+  KEY `idx_plan_no` (`plan_no`),
+  KEY `idx_cargo_name` (`cargo_name`),
+  KEY `idx_gross_weight_time` (`gross_weight_time`),
+  KEY `idx_is_voided` (`is_voided`),
+  KEY `idx_purchase_plan_id` (`purchase_plan_id`),
+  KEY `idx_inbound_outbound_id` (`inbound_outbound_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=675 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='地磅单表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 223 - 0
docs/art-design-pro-changelog-today.md

@@ -0,0 +1,223 @@
+# art-design-pro 今日变更明细
+
+> 基于当前工作区未提交修改整理(共 12 个文件,+560 / -62 行)
+
+---
+
+## 一、环境与构建
+
+### 1. `art-design-pro/.env.development`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 变量 `VITE_H5_BASE_URL = http://localhost:5174` |
+| 注释 | 计划管理「生成链接交三方填写」时,链接指向的 H5 根地址(本机 Web 与 H5 端口不同需配置);示例:H5 跑在 5174 端口填 `http://localhost:5174` |
+
+### 2. `art-design-pro/.env.production`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 变量 `VITE_H5_BASE_URL=`(空,需部署时填写) |
+| 注释 | 计划管理「生成链接交三方填写」时,链接指向的 H5 根地址(生产环境 Web 与 H5 域名不同需配置);示例:H5 域名为 `https://h5.xxx.com` 则填该地址 |
+| 其它 | 文件末尾补全换行 |
+
+### 3. `art-design-pro/vite.config.ts`
+
+| 类型 | 内容 |
+|------|------|
+| 修改 | 代理配置中 `masterDataResources` 由 `['companies', 'cargos']` 改为 `['companies', 'cargos', 'vehicles']`,即增加对 `vehicles` 的代理转发 |
+
+---
+
+## 二、API 层
+
+### 4. `art-design-pro/src/api/inventoryApi.ts`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 方法 `createLinkForPlan(purchasePlanId: number, remarks?: string): Promise<InventoryRecord>` |
+| 说明 | 生成链接交三方填写:预生成出入库单(日期、数量、车牌号为空),请求 `POST /inventory/create-link-for-plan`,返回该记录用于生成 H5 填写链接 |
+
+### 5. `art-design-pro/src/api/masterDataApi.ts`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 接口 `Vehicle`:`id`, `vehicleNo`, `remarks?` |
+| 新增 | 接口 `VehicleCreate`:`vehicleNo`, `remarks?` |
+| 新增 | 接口 `VehicleUpdate`:`vehicleNo?`, `remarks?` |
+| 新增 | `vehicleApi` 对象:`list(q?)`, `create(payload)`, `update(id, payload)`, `delete(id)`,对应 `/master-data/vehicles` 的 GET/POST/PUT/DELETE |
+
+### 6. `art-design-pro/src/api/plansApi.ts`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | `Plan` 接口字段 `fillLinkInventoryId?: number`,注释:生成链接交三方填写时对应的出入库单 id,有则显示「复制URL链接」 |
+
+---
+
+## 三、组件与路由
+
+### 7. `art-design-pro/src/components/Table/ArtTable.vue`
+
+| 类型 | 内容 |
+|------|------|
+| 模板 | 表格增加 `@sort-change="handleSortChange"` |
+| 模板 | 序号列:`width` 60→72,增加 `min-width="72"`、`class-name="art-table-index-column"`,注释说明用于避免横向滚动时表头「序号」被压窄换行 |
+| script | `emits` 增加 `'sort-change'` |
+| script | 新增 `handleSortChange`,将 `{ prop, order }` 通过 `emit('sort-change', payload)` 抛出,供父组件在 `sortable="custom"` 时使用 |
+| style | 新增 `th.art-table-index-column .cell { white-space: nowrap; }`,序号列表头不换行 |
+
+### 8. `art-design-pro/src/router/modules/routesAlias.ts`
+
+| 类型 | 内容 |
+|------|------|
+| 修改 | `UserCenter` 从 `'/user/user'` 改为 `'/user/UserManagement'`,注释:用户管理(对应 `views/user/UserManagement.vue`) |
+
+---
+
+## 四、业务页面
+
+### 9. `art-design-pro/src/views/inventory/index.vue`
+
+#### 9.1 筛选与表格
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 日期快捷按钮「明日」,`setDateFilter('tomorrow')`;`dateFilterType` 类型增加 `'tomorrow'` |
+| 新增 | 表格列「id」,`min-width="80"`,`sortable="custom"`,`:sort-order="getSortOrder('id')"` |
+| 修改 | 以下列均增加 `sortable="custom"` 与 `:sort-order="getSortOrder(prop)"`:planNo、batchNo、date、partnerCompany、businessType、status、product、vehicleNo、quantity、netWeight、grossWeight、grossWeightTime、tareWeight、tareWeightTime、deduction、remarks |
+| 修改 | 表格绑定 `@sort-change="handleSortChange"`;操作列宽度 360→420 |
+
+#### 9.2 操作列
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 当 `row.status === '待三方填写'` 时显示按钮「复制URL链接」,点击 `handleCopyFillLink(row)` |
+
+#### 9.3 表单
+
+| 类型 | 内容 |
+|------|------|
+| 修改 | 产品:去掉 `filterable`、`remote`、`:remote-method="loadCargos"`,保留 `@focus="loadCargos('')"` |
+| 修改 | 数量(吨):表单项增加 `class="quantity-red"`;默认值由 0 改为 32(含 `formData` 与新增时的初始值) |
+| 修改 | 车牌号:由 `el-input` 改为 `el-select`,`filterable`、`remote`、`allow-create`、`default-first-option`,`:remote-method="loadVehicles"`,`@focus="loadVehicles('')"`,选项来自 `vehicleOptions`,展示 `item.vehicleNo` |
+
+#### 9.4 脚本逻辑
+
+| 类型 | 内容 |
+|------|------|
+| 引入 | 图标增加 `Link`;`masterDataApi` 增加 `vehicleApi`、`Vehicle` |
+| 状态 | 增加 `allRecords`(全量数据)、`sortProp`、`sortOrder`、`vehicleOptions` |
+| 新增 | `loadVehicles(keyword)`:调用 `vehicleApi.list` 填充 `vehicleOptions` |
+| 新增 | `ensureVehicleExists(vehicleNo)`:若车牌号不在当前选项中则 `vehicleApi.create`,409 忽略 |
+| 新增 | `getSortOrder(prop)`、`compare(a,b,prop,order)`、`applySortAndPage()`、`handleSortChange(payload)`:前端排序 + 分页 |
+| 修改 | `loadData`:请求后写入 `allRecords` 并调用 `applySortAndPage()`,不再直接 `tableData = records.slice` |
+| 修改 | 请求参数:增加 `dateFilterType === 'tomorrow'` 时用明日日期作为 `dateFrom`/`dateTo` |
+| 修改 | `handlePageChange`、`handleSizeChange`:由 `loadData()` 改为 `applySortAndPage()` |
+| 修改 | `handleAdd`、`handleEdit` 打开弹窗前增加 `loadVehicles('')` |
+| 新增 | `getH5FillBaseUrl()`:读 `VITE_H5_BASE_URL`,无则用当前 origin |
+| 新增 | `getFillLinkUrl(inventoryId)`:`${getH5FillBaseUrl()}/#/inventory/fill/${inventoryId}` |
+| 新增 | `handleCopyFillLink(row)`:复制填写链接到剪贴板并提示 |
+| 修改 | `handleSubmit`:提交前若 `formData.vehicleNo` 非空则 `ensureVehicleExists`;成功后若 `route.query.from === 'plan'` 则 `router.replace('/plans')`,否则 `loadData()`;先关弹窗再跳转/刷新 |
+| 修改 | `handleModalClose`:若 `route.query.from === 'plan'` 则 `router.replace('/plans')` |
+| 修改 | `handleExport`:请求参数增加「明日」分支;导出前按当前 `sortProp/sortOrder` 对 `records` 排序后再生成 CSV |
+
+#### 9.5 样式
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | `.quantity-red`:表单项 label、输入框及增减按钮使用 `var(--el-color-danger)` 红色 |
+
+---
+
+### 10. `art-design-pro/src/views/master-data/index.vue`
+
+#### 10.1 车牌号 Tab(新增)
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | Tab「车牌号」`name="vehicle"` |
+| 新增 | 工具栏:搜索框(placeholder「搜索车牌号 / 备注」、`vehicleSearchKeyword`、clear/enter 触发搜索)、按钮「新增车牌号」 |
+| 新增 | `art-table`:`vehicleData`、`vehicleLoading`、分页、序号、边框、列「车牌号」「备注」、操作列「编辑」「删除」 |
+| 新增 | 车牌号弹窗:标题「修改车牌号」/「新增车牌号」,表单项「车牌号」「备注」,确定/取消;校验:车牌号必填 |
+
+#### 10.2 脚本
+
+| 类型 | 内容 |
+|------|------|
+| 引入 | `vehicleApi`,类型 `Vehicle`、`VehicleCreate` |
+| 状态 | `vehicleLoading`、`vehicleSaving`、`vehicleAllData`、`vehicleData`、`vehicleTotal`、`vehiclePage`、`vehiclePageSize`、`vehicleModalOpen`、`editingVehicle`、`vehicleFormRef`、`vehicleFormData`、`vehicleFormRules`、`vehicleSearchKeyword` |
+| 新增 | `loadVehicles()`、`filterVehicleData()`、`handleVehicleSearch()`、`handleVehiclePageChange`、`handleVehicleSizeChange` |
+| 修改 | `handleTabChange`:tab 为 `'vehicle'` 时调用 `loadVehicles()` |
+| 新增 | `handleVehicleAdd`、`handleVehicleEdit(record)`、`handleVehicleDelete(id)`、`handleVehicleSubmit`、`handleVehicleModalClose` |
+| 修改 | `onMounted` 中增加 `loadVehicles()` |
+
+---
+
+### 11. `art-design-pro/src/views/plans/index.vue`
+
+#### 11.1 筛选与表格
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 日期快捷按钮「明日」;`dateFilterType` 类型增加 `'tomorrow'` |
+| 修改 | 操作列宽度 400→480 |
+| 新增 | 操作列按钮「生成链接交三方填写」,点击 `handleFillLink(row)` |
+
+#### 11.2 表单
+
+| 类型 | 内容 |
+|------|------|
+| 修改 | 产品下拉:去掉 `filterable`、`remote`、`:remote-method="loadCargos"`,保留 `@focus="loadCargos('')"` |
+
+#### 11.3 脚本
+
+| 类型 | 内容 |
+|------|------|
+| 引入 | 图标增加 `Link`;增加 `inventoryApi` |
+| 修改 | 列表请求参数:增加 `dateFilterType === 'tomorrow'` 时 `createdFrom`/`createdTo` 为明日 |
+| 修改 | `setDateFilter` 类型增加 `'tomorrow'` |
+| 修改 | `handleGenerateInventory`:改为 `router.push({ path: '/inventory', query: { ..., from: 'plan' } })`,便于出入库页返回计划管理 |
+| 新增 | `getH5FillBaseUrl()`、`getFillLinkUrl(inventoryId)` |
+| 新增 | `handleFillLink(record)`:调用 `inventoryApi.createLinkForPlan(record.id)` 得到出入库单,拼 H5 填写 URL 并复制到剪贴板 |
+| 修改 | 导出请求参数:增加「明日」日期分支 |
+
+---
+
+### 12. `art-design-pro/src/views/weighing/index.vue`
+
+| 类型 | 内容 |
+|------|------|
+| 新增 | 日期快捷按钮「明日」;`dateFilterType` 类型增加 `'tomorrow'` |
+| 修改 | 计算请求参数时增加 `dateFilterType === 'tomorrow'` 分支,`dateFrom`/`dateTo` 为明日 |
+| 修改 | `setDateFilter` 类型增加 `'tomorrow'` |
+
+---
+
+## 五、变更统计(按文件)
+
+| 文件 | 新增行 | 删除行 |
+|------|--------|--------|
+| .env.development | 4 | 0 |
+| .env.production | 6 | 1 |
+| src/api/inventoryApi.ts | 7 | 0 |
+| src/api/masterDataApi.ts | 44 | 0 |
+| src/api/plansApi.ts | 2 | 0 |
+| src/components/Table/ArtTable.vue | 20 | 5 |
+| src/router/modules/routesAlias.ts | 1 | 1 |
+| src/views/inventory/index.vue | 233 | 约 30 |
+| src/views/master-data/index.vue | 214 | 0 |
+| src/views/plans/index.vue | 73 | 约 10 |
+| src/views/weighing/index.vue | 15 | 2 |
+| vite.config.ts | 1 | 1 |
+| **合计** | **560** | **62** |
+
+---
+
+## 六、功能小结
+
+1. **生成链接交三方填写**:计划侧可预生成出入库单并复制 H5 填写链接;库存侧对「待三方填写」可复制链接;依赖 `VITE_H5_BASE_URL`(开发/生产分别配置)。
+2. **车牌号主数据**:基础资料新增「车牌号」Tab,CRUD + 搜索/分页;库存表单车牌号改为可搜索、可新建的下拉,新车牌自动写入主数据;Vite 代理与后端 `/master-data/vehicles` 打通。
+3. **库存列表**:明日筛选、多列自定义排序(前端排序+分页)、序号列不换行、操作列「复制URL链接」、数量默认 32 且红色强调、从计划跳转时带 `from=plan` 且提交/关闭后返回计划。
+4. **计划与称重**:计划/称重列表均增加「明日」筛选;计划增加「生成链接交三方填写」与跳转出入库时带 `from=plan`。
+5. **路由与表格组件**:用户管理路由改为 `/user/UserManagement`;ArtTable 支持 `sort-change` 与序号列样式优化。

+ 38 - 0
docs/cargos_product_name_unify.sql

@@ -0,0 +1,38 @@
+-- ============================================================
+-- 产品表 cargos 名称统一 + 去重 + 硫酸虚拟删除
+-- 执行前请先备份数据库;建议在测试环境先执行并核对结果
+-- 表结构:cargos(id, name, remarks, is_deleted, created_at),name 有 UNIQUE 约束
+-- ============================================================
+
+-- ----------------------------------------
+-- 一、产品名称统一(注意:多对一时需先更新一个,其余同目标名的行做物理删除以免违反 UNIQUE)
+-- ----------------------------------------
+
+-- 1. 32%液碱、氢氧化钠 32% → 32% 烧碱(两源名指向同一目标,需防重复)
+UPDATE cargos SET name = '32% 烧碱' WHERE name = '32%液碱';
+UPDATE cargos SET name = '32% 烧碱' WHERE name = '氢氧化钠 32%' AND NOT EXISTS (SELECT 1 FROM (SELECT name FROM cargos WHERE name = '32% 烧碱' LIMIT 1) c2);
+DELETE FROM cargos WHERE name = '氢氧化钠 32%';
+
+-- 2. 50%硫酸:删除(表中已有「工业硫酸 50%」则不再保留本条)
+DELETE FROM cargos WHERE name = '50%硫酸';
+
+-- 3. 92%硫酸 → 工业硫酸 92.5%
+UPDATE cargos SET name = '工业硫酸 92.5%' WHERE name = '92%硫酸';
+
+-- 4. 98%硫酸 → 工业硫酸 98%;98% 工业硫酸:删除
+UPDATE cargos SET name = '工业硫酸 98%' WHERE name = '98%硫酸';
+DELETE FROM cargos WHERE name = '98% 工业硫酸';
+
+-- 5. 硫磺酸 → 硫磺酸 98%
+UPDATE cargos SET name = '硫磺酸 98%' WHERE name = '硫磺酸';
+
+-- ----------------------------------------
+-- 二、删除 name 重复的记录(同一 name 只保留 id 最小的一条)
+-- ----------------------------------------
+DELETE c1 FROM cargos c1
+INNER JOIN cargos c2 ON c1.name = c2.name AND c1.id > c2.id;
+
+-- ----------------------------------------
+-- 三、硫酸:虚拟删除
+-- ----------------------------------------
+UPDATE cargos SET is_deleted = 1 WHERE name = '硫酸';

+ 92 - 0
docs/mobile-h5-alignment-checklist.md

@@ -0,0 +1,92 @@
+# H5 端与 Web 端功能对齐清单
+
+> 为保障 mobile-h5 与 art-design-pro(Web)功能一致,基于 Web 端今日变更整理出的 H5 需做调整项。  
+> 已对齐的部分会标注「已对齐」,仅列出需补充或修改的项。
+
+---
+
+## 一、已对齐部分(无需改)
+
+| 模块 | 说明 |
+|------|------|
+| **API** | `inventoryApi.createLinkForPlan`、`plansApi.Plan.fillLinkInventoryId`、`masterDataApi.vehicleApi`(Vehicle/VehicleCreate/VehicleUpdate、list/create/update/delete)均已存在 |
+| **路由** | `/inventory/fill/:id`(填写页)、`/master-data/vehicle/detail`、车牌号详情页已配置 |
+| **Vite 代理** | `masterDataResources` 已包含 `vehicles` |
+| **计划管理** | 仅展示「生成链接交三方填写」按钮(不展示「复制URL链接」);点击后若无链接则先创建再复制,若有则直接复制;`handleFillLink`、`getFillLinkUrl` 已实现 |
+| **基础资料** | 车牌号 Tab(列表、搜索、编辑、删除、新增跳详情)已实现 |
+| **出入库详情** | 车牌号选择器(vehicleApi 列表 + 手动输入)、提交前 `ensureVehicleExists` 已实现 |
+| **填写页 fill.vue** | 日期/数量/车牌号/备注填写、已填写只读+二维码、未填写可编辑并提交,逻辑完整 |
+
+---
+
+## 二、需在 H5 端做的调整
+
+### 1. 计划管理 `mobile-h5/src/views/plans/index.vue`
+
+| 项 | 说明 | 建议修改 |
+|----|------|----------|
+| **明日筛选** | Web 端有「明日」快捷筛选,H5 暂无 | 在 `dateFilterOptions` 中增加 `{ label: '明日', value: 'tomorrow' }`(放在「今日」与「昨日」之间);`dateFilterType` 类型增加 `'tomorrow'`;在 `loadData` 的请求参数中增加 `dateFilterType === 'tomorrow'` 分支,设置 `createdFrom`/`createdTo` 为明日日期(`dayjs().add(1, 'day').format('YYYY-MM-DD')`) |
+| **from 参数统一** | Web 跳转出入库使用 `from=plan`,H5 当前使用 `from=plans`,为与 Web 一致且便于 Web 生成的链接/跳转被 H5 识别 | `handleGenerateInventory` 里将 query 中的 `from=plans` 改为 `from=plan` |
+
+---
+
+### 2. 出入库列表 `mobile-h5/src/views/inventory/index.vue`
+
+| 项 | 说明 | 建议修改 |
+|----|------|----------|
+| **明日筛选** | 与 Web 一致 | `dateFilterOptions` 增加「明日」;`dateFilterType` 类型增加 `'tomorrow'`;`loadData` 请求参数中增加 `dateFilterType === 'tomorrow'` 时 `dateFrom`/`dateTo` 为明日 |
+| **返回来源** | Web 与后端约定为 `from=plan` | `handleBack` 中判断 `route.query.from === 'plans'` 改为 `from === 'plan'`(建议同时兼容 `from === 'plans'`,避免旧链接失效) |
+| **复制填写链接** | Web 端在状态为「待三方填写」时可复制 H5 填写链接,H5 列表暂无 | 在卡片操作区(如「生成地磅单」旁)增加:当 `item.status === '待三方填写'` 时显示按钮「复制URL链接」;点击时生成当前 H5 的填写页 URL(`window.location.origin + (pathname || '') + '#/inventory/fill/' + item.id`),复制到剪贴板并 toast 成功/失败 |
+
+---
+
+### 3. 出入库详情/新增 `mobile-h5/src/views/inventory/detail.vue`
+
+| 项 | 说明 | 建议修改 |
+|----|------|----------|
+| **数量默认值** | Web 端新增时数量默认为 32 | 新增模式下 `formData.quantity` 初始值由 `0` 改为 `32`(与 Web 一致) |
+| **from 参数统一** | 与 Web、列表、计划保持一致 | `handleBack`、`handleSubmit` 中判断 `from === 'plans'` 改为以 `from === 'plan'` 为主(可同时兼容 `from === 'plans'`),满足时返回 `/plans` |
+
+---
+
+### 4. 地磅单列表 `mobile-h5/src/views/weighing/index.vue`
+
+| 项 | 说明 | 建议修改 |
+|----|------|----------|
+| **明日筛选** | 与 Web 一致 | `dateFilterOptions` 增加「明日」;`dateFilterType` 类型增加 `'tomorrow'`;在组装请求参数(如 `getDateRangeParams` 或等价逻辑)中增加 `dateFilterType === 'tomorrow'` 时 `dateFrom`/`dateTo` 为明日 |
+
+---
+
+### 5. 填写页 `mobile-h5/src/views/inventory/fill.vue`(可选)
+
+| 项 | 说明 | 建议修改 |
+|----|------|----------|
+| **新车牌写入主数据** | Web 端在出入库表单提交前会 `ensureVehicleExists`,填写页为三方使用,若输入新车牌可一并写入主数据,便于后续选择 | 在 `handleSubmit` 中,在调用 `inventoryApi.update` 之前,若 `formData.vehicleNo` 非空,调用与 detail 页一致的 `ensureVehicleExists`(需在该页引入 `vehicleApi` 并实现或复用 ensureVehicleExists),409 冲突忽略。非必须,可按业务需要再做 |
+
+---
+
+## 三、修改汇总表
+
+| 文件 | 修改点 |
+|------|--------|
+| `mobile-h5/src/views/plans/index.vue` | 增加「明日」筛选;`from=plans` → `from=plan` |
+| `mobile-h5/src/views/inventory/index.vue` | 增加「明日」筛选;`handleBack` 使用 `from=plan`(兼容 `plans`);状态为「待三方填写」时增加「复制URL链接」按钮 |
+| `mobile-h5/src/views/inventory/detail.vue` | 新增时数量默认 32;`handleBack`/`handleSubmit` 使用 `from=plan`(兼容 `plans`) |
+| `mobile-h5/src/views/weighing/index.vue` | 增加「明日」筛选 |
+| `mobile-h5/src/views/inventory/fill.vue` | (可选)提交前对新车牌调用 `ensureVehicleExists` |
+
+---
+
+## 四、产品/交互约定(Web 与 H5 一致)
+
+- **计划页「生成链接交三方填写」**:该按钮**始终展示**,文案固定为「生成链接交三方填写」。**不展示**「复制URL链接」按钮。点击行为:若该计划尚未生成填写链接(无 `fillLinkInventoryId`),则先调用接口创建出入库单并得到链接,再复制到剪贴板;若已有链接,则直接复制当前链接。Web 与 H5 均按此实现。
+
+---
+
+## 五、与 Web 的差异说明(无需改 H5)
+
+- **H5 填写链接生成**:在 H5 内复制「填写链接」时,应使用当前 H5 的 origin + pathname + hash(即当前页的 `window.location.origin + (pathname || '') + '#/inventory/fill/' + id`),无需在 H5 配置 `VITE_H5_BASE_URL`。`VITE_H5_BASE_URL` 仅在 **Web 端** 生成「交给三方填写的链接」时使用,以便链接指向正确的 H5 部署地址。
+- **排序、表格**:Web 端表格多列排序、序号列样式等为 PC 表格能力,H5 列表为卡片流,无需一一对应。
+- **用户管理路由**:Web 的 `UserCenter` 路由调整为 `/user/UserManagement` 仅影响 Web 菜单,H5 无对应菜单则无需改。
+
+完成上述调整后,H5 在「明日」筛选、计划→出入库来源统一、计划页仅展示「生成链接交三方填写」、出入库列表复制填写链接、新增默认数量及返回逻辑、地磅单明日筛选上与 Web 保持一致,功能对齐即可保障。

+ 53 - 0
docs/product_name_unify.sql

@@ -0,0 +1,53 @@
+-- ============================================================
+-- 产品名称统一化 SQL
+-- 执行前请先备份数据库;建议在测试环境先执行并核对结果
+-- ============================================================
+
+-- 1. 32% 液碱 → 32% 烧碱
+UPDATE purchase_plans       SET product = '32% 烧碱' WHERE product = '32% 液碱';
+UPDATE inbound_outbound     SET product = '32% 烧碱' WHERE product = '32% 液碱';
+UPDATE weighing_slips       SET cargo_name = '32% 烧碱' WHERE cargo_name = '32% 液碱';
+
+-- 2. 氢氧化钠 32% → 32% 烧碱
+UPDATE purchase_plans       SET product = '32% 烧碱' WHERE product = '氢氧化钠 32%';
+UPDATE inbound_outbound     SET product = '32% 烧碱' WHERE product = '氢氧化钠 32%';
+UPDATE weighing_slips       SET cargo_name = '32% 烧碱' WHERE cargo_name = '氢氧化钠 32%';
+
+-- 3. 50%硫酸 → 工业硫酸 50%
+UPDATE purchase_plans       SET product = '工业硫酸 50%' WHERE product = '50%硫酸';
+UPDATE inbound_outbound     SET product = '工业硫酸 50%' WHERE product = '50%硫酸';
+UPDATE weighing_slips       SET cargo_name = '工业硫酸 50%' WHERE cargo_name = '50%硫酸';
+
+-- 4. 92%硫酸 → 工业硫酸 92.5%
+UPDATE purchase_plans       SET product = '工业硫酸 92.5%' WHERE product = '92%硫酸';
+UPDATE inbound_outbound     SET product = '工业硫酸 92.5%' WHERE product = '92%硫酸';
+UPDATE weighing_slips       SET cargo_name = '工业硫酸 92.5%' WHERE cargo_name = '92%硫酸';
+
+-- 5. 98%硫酸 → 工业硫酸 98%
+UPDATE purchase_plans       SET product = '工业硫酸 98%' WHERE product = '98%硫酸';
+UPDATE inbound_outbound     SET product = '工业硫酸 98%' WHERE product = '98%硫酸';
+UPDATE weighing_slips       SET cargo_name = '工业硫酸 98%' WHERE cargo_name = '98%硫酸';
+
+-- 6. 98% 工业硫酸 → 工业硫酸 98%
+UPDATE purchase_plans       SET product = '工业硫酸 98%' WHERE product = '98% 工业硫酸';
+UPDATE inbound_outbound     SET product = '工业硫酸 98%' WHERE product = '98% 工业硫酸';
+UPDATE weighing_slips       SET cargo_name = '工业硫酸 98%' WHERE cargo_name = '98% 工业硫酸';
+
+-- 7. 硫磺酸 → 硫磺酸 98%
+UPDATE purchase_plans       SET product = '硫磺酸 98%' WHERE product = '硫磺酸';
+UPDATE inbound_outbound     SET product = '硫磺酸 98%' WHERE product = '硫磺酸';
+UPDATE weighing_slips       SET cargo_name = '硫磺酸 98%' WHERE cargo_name = '硫磺酸';
+
+-- ============================================================
+-- 可选:若货物名称表 cargos 也需与上述规则一致,可执行下面语句
+-- 注意:cargos.name 有 UNIQUE 约束,若新名称已存在会冲突,需先处理重复再改
+-- ============================================================
+/*
+UPDATE cargos SET name = '32% 烧碱' WHERE name = '32% 液碱';
+UPDATE cargos SET name = '32% 烧碱' WHERE name = '氢氧化钠 32%';
+UPDATE cargos SET name = '工业硫酸 50%' WHERE name = '50%硫酸';
+UPDATE cargos SET name = '工业硫酸 92.5%' WHERE name = '92%硫酸';
+UPDATE cargos SET name = '工业硫酸 98%' WHERE name = '98%硫酸';
+UPDATE cargos SET name = '工业硫酸 98%' WHERE name = '98% 工业硫酸';
+UPDATE cargos SET name = '硫磺酸 98%' WHERE name = '硫磺酸';
+*/

+ 91 - 0
docs/web-h5-differences.md

@@ -0,0 +1,91 @@
+# Web 端(art-design-pro)与 H5 端(mobile-h5)功能差异分析
+
+> 基于当前代码逐项对比,仅列出**存在差异**的项,便于后续对齐或确认产品取舍。
+
+---
+
+## 一、计划管理(Plans)
+
+| 差异项 | Web 端 | H5 端 | 说明 |
+|--------|--------|--------|------|
+| **列表日期筛选** | 有「日期范围选择器」+ 快捷芯片(全部/今日/明日/昨日/本周);请求时同时支持 `dateRange`(映射为 `startDateFrom`/`startDateTo`)和快捷筛选(`createdFrom`/`createdTo`) | 仅有快捷芯片,无日期范围选择器;请求只用 `createdFrom`/`createdTo` | H5 缺少按「计划开始/结束日期」的自定义区间筛选 |
+| **计划列表 API 参数类型** | `planApi.list` 的 params 类型包含 `startDateFrom`、`startDateTo` | `planApi.list` 的 params 类型只有 `createdFrom`、`createdTo`,无 `startDateFrom`/`startDateTo` | 若 H5 要支持按计划日期区间筛选,需在类型和请求里加上这两项 |
+| **计划表单字段** | formData 中有 `specification`、`vehicleNo`,并在导出等逻辑中使用 | 计划详情页表单仅有:计划号、业务类型、往来单位、产品、开始/结束时间、计划量、备注;**无规格(specification)、车牌号(vehicleNo)** | 若产品需要计划维度的规格/车牌,H5 需在计划详情表单和 PlanCreate/PlanUpdate 中补齐 |
+| **计划列表返回** | 计划列表页无「返回」按钮(作为主导航页) | 有返回按钮 → 回到 `/dashboard` | 属交互差异,可按产品保留 |
+
+---
+
+## 二、出入库管理(Inventory)
+
+| 差异项 | Web 端 | H5 端 | 说明 |
+|--------|--------|--------|------|
+| **列表日期筛选** | 有「日期范围选择器」+ 搜索 + 快捷芯片(全部/今日/明日/昨日/本周) | 仅有搜索 + 快捷芯片,**无日期范围选择器** | H5 无法按自定义日期区间筛出入库列表 |
+| **「复制URL链接」展示条件** | 仅当 `row.status === '待三方填写'` 时显示该按钮 | **每条记录都显示**「复制URL链接」按钮 | 若需与 Web 一致,H5 应改为仅当 `item.status === '待三方填写'` 时显示 |
+| **填写页(三方填写)** | 无此页面(填写链接指向 H5) | 有 `/inventory/fill/:id`,免登可填,提交前 `ensureVehicleExists` | 设计上由 H5 承载填写页,无需在 Web 复制 |
+
+---
+
+## 三、地磅单(Weighing)
+
+| 差异项 | Web 端 | H5 端 | 说明 |
+|--------|--------|--------|------|
+| **列表日期筛选** | 有「时间范围选择器」+ 搜索 + 快捷芯片(全部/今日/明日/昨日/本周) | 仅有搜索 + 快捷芯片,**无时间/日期范围选择器** | H5 无法按自定义时间区间筛地磅单列表 |
+
+---
+
+## 四、基础资料(Master Data)
+
+| 差异项 | Web 端 | H5 端 | 说明 |
+|--------|--------|--------|------|
+| **新增入口** | 每个 Tab(往来单位/车牌号/货物)各自有「新增」按钮,弹窗内新增/编辑 | 共用一个浮动按钮(FAB),根据当前 Tab 跳转到对应详情页(如 `/master-data/company/detail`)新增 | 交互不同:Web 弹窗 vs H5 独立详情页,功能等价 |
+| **详情/编辑** | 弹窗内编辑 | 独立页面(如 `/master-data/company/detail/:id?mode=edit`) | 同上,属 UI 形态差异 |
+| **API** | companyApi/cargoApi/vehicleApi 无 `get(id)` | 三者均有 `get(id)`,供详情页加载单条 | H5 因使用详情页而需要 get;Web 弹窗用列表数据即可,无功能冲突 |
+
+---
+
+## 五、API 与类型
+
+| 差异项 | Web 端 | H5 端 | 说明 |
+|--------|--------|--------|------|
+| **plansApi.list 参数** | 类型含 `createdFrom`/`createdTo`、`startDateFrom`/`startDateTo` 等 | 类型仅含 `createdFrom`/`createdTo` | 若 H5 要做「按计划日期区间」筛选,需在类型与请求中增加 `startDateFrom`/`startDateTo` |
+| **masterDataApi** | 无 `companyApi.get`/`cargoApi.get`/`vehicleApi.get` | 有 get(id) | 因 H5 详情页需要,属合理差异 |
+
+---
+
+## 六、已对齐或无需对齐的部分
+
+- 计划「生成链接交三方填写」:两端均为每次点击调 `createLinkForPlan` 并复制新链接。
+- 跳转出入库与返回:`from=plan`(及 H5 兼容 `from=plans`)时返回 `/plans`,行为一致。
+- 出入库表单:数量默认 32、车牌号选择/手动输入、提交前 `ensureVehicleExists`、H5 数量默认值红色提示,已对齐。
+- 填写页(H5):仅 H5 存在,与产品设计一致。
+- 明日/今日/昨日/本周快捷筛选:计划、出入库、地磅单列表在两端均已支持。
+
+---
+
+## 七、建议对齐的差异(按优先级)
+
+1. **出入库列表「复制URL链接」**  
+   - 若产品要求与 Web 一致:H5 改为仅当 `item.status === '待三方填写'` 时显示「复制URL链接」按钮。
+
+2. **计划/出入库/地磅单列表「日期范围」**  
+   - 若需要在 H5 上按自定义日期区间筛选:在对应列表页增加日期范围选择器,并在请求中传入 `dateFrom`/`dateTo` 或 `startDateFrom`/`startDateTo`(计划用后者时需同步 plansApi 类型)。
+
+3. **计划表单字段**  
+   - 若产品需要在计划中维护「规格」「车牌号」:在 H5 计划详情页表单与接口类型中增加 `specification`、`vehicleNo`,并与后端一致。
+
+4. **plansApi.list 类型**  
+   - 若 H5 实现「按计划日期区间」筛选:在 `plansApi.ts` 的 list 的 params 类型中增加 `startDateFrom?`、`startDateTo?`。
+
+---
+
+## 八、差异汇总表(仅差异)
+
+| 模块 | 差异简述 |
+|------|----------|
+| 计划管理 | Web 有日期范围筛选与 startDateFrom/To;计划 formData 有 specification、vehicleNo(H5 表单无);plansApi 类型 Web 多 startDateFrom/To |
+| 出入库管理 | Web 有日期范围筛选;Web 仅「待三方填写」显示复制链接,H5 全部显示 |
+| 地磅单 | Web 有时间/日期范围筛选,H5 无 |
+| 基础资料 | 新增/编辑:Web 弹窗 vs H5 详情页;H5 多 get(id) |
+| 导航 | 计划列表:H5 有返回 dashboard,Web 无返回(主站差异,可保留) |
+
+如需对某一条做具体改法(例如只改「复制URL链接」条件),可以指定条目我按该方案改代码。

+ 155 - 0
docs/车牌号管理表与历史数据导入.md

@@ -0,0 +1,155 @@
+# 车牌号管理表与历史数据导入
+
+## 一、车牌号表(vehicles)结构
+
+### 1.1 表说明
+
+| 表名 | 说明 |
+|------|------|
+| `vehicles` | 基础资料-车牌号表,用于集中管理往来车牌号 |
+
+### 1.2 字段定义
+
+| 字段名 | 类型 | 约束 | 说明 |
+|--------|------|------|------|
+| id | INTEGER | PRIMARY KEY, 自增 | 主键 |
+| vehicle_no | VARCHAR(255) | NOT NULL, UNIQUE, INDEX | 车牌号 |
+| remarks | TEXT | 可空 | 备注 |
+| is_deleted | INTEGER | NOT NULL, DEFAULT 0, INDEX | 软删标记(0=否,1=是) |
+| created_at | VARCHAR(255) | NOT NULL | 创建时间(ISO 字符串) |
+
+### 1.3 DDL 参考(SQLite)
+
+```sql
+CREATE TABLE IF NOT EXISTS vehicles (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  vehicle_no VARCHAR(255) NOT NULL,
+  remarks TEXT,
+  is_deleted INTEGER NOT NULL DEFAULT 0,
+  created_at VARCHAR(255) NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ix_vehicles_vehicle_no ON vehicles(vehicle_no);
+CREATE INDEX IF NOT EXISTS ix_vehicles_is_deleted ON vehicles(is_deleted);
+```
+
+### 1.4 DDL 参考(MySQL / PostgreSQL 等)
+
+```sql
+CREATE TABLE IF NOT EXISTS vehicles (
+  id SERIAL PRIMARY KEY,
+  vehicle_no VARCHAR(255) NOT NULL,
+  remarks TEXT,
+  is_deleted INTEGER NOT NULL DEFAULT 0,
+  created_at VARCHAR(255) NOT NULL,
+  CONSTRAINT uq_vehicles_vehicle_no UNIQUE (vehicle_no)
+);
+
+CREATE INDEX ix_vehicles_vehicle_no ON vehicles(vehicle_no);
+CREATE INDEX ix_vehicles_is_deleted ON vehicles(is_deleted);
+```
+
+说明:项目使用 SQLModel,表由 `init_models()` 通过 `SQLModel.metadata.create_all` 自动创建,无需手动执行上述 DDL;上表仅作结构说明与手工建表参考。
+
+---
+
+## 二、历史数据导入:从出入库/地磅单初始化车牌号
+
+### 2.1 数据来源
+
+- **出入库表** `inbound_outbound`:字段 `vehicle_no`,条件 `is_deleted = 0`
+- **地磅单表** `weighing_slips`:字段 `vehicle_no`,条件 `is_voided = 0`
+
+从上述两表中抽取所有非空且去空格后非空的车牌号,**去重(不区分大小写)** 后写入 `vehicles` 表。
+
+### 2.2 导入脚本
+
+- 脚本路径:`backend/scripts/init_vehicles_from_history.py`
+- 作用:
+  1. 调用 `init_models()`,确保包含 `vehicles` 在内的所有表已创建
+  2. 从 `inbound_outbound`、`weighing_slips` 中读取不重复的车牌号(去空、去重)
+  3. 将尚未存在于 `vehicles` 中的车牌号插入 `vehicles`(已存在则跳过)
+
+### 2.3 执行方式
+
+在 **backend 目录** 下执行:
+
+```bash
+# 若使用 uv
+uv run python -m scripts.init_vehicles_from_history
+
+# 或直接使用 Python(需已安装依赖)
+python -m scripts.init_vehicles_from_history
+```
+
+执行前请确保:
+
+- 环境变量或 `.env` 中数据库连接正确(与主应用一致)
+- 已安装项目依赖(如 `sqlmodel`、数据库驱动等)
+
+### 2.4 执行结果示例
+
+脚本会输出类似:
+
+```
+历史车牌号统计:共发现 N 个不重复车牌号。
+本次新增写入 vehicles 表:M 条;已存在跳过:K 条。
+```
+
+可多次执行:已存在的车牌号不会重复插入。
+
+### 2.5 纯 SQL 导入(MySQL)
+
+若在 MySQL 中直接执行 SQL 导入,需在比较时统一排序规则,否则可能报错:`Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT)`。下面写法在比较两侧显式指定相同 `COLLATE`:
+
+**仅从出入库表导入:**
+
+```sql
+INSERT INTO vehicles (vehicle_no, remarks, is_deleted, created_at)
+SELECT DISTINCT
+  trim(i.vehicle_no) AS vehicle_no,
+  NULL AS remarks,
+  0 AS is_deleted,
+  now() AS created_at
+FROM inbound_outbound i
+WHERE i.is_deleted = 0
+  AND trim(ifnull(i.vehicle_no, '')) <> ''
+  AND NOT EXISTS (
+    SELECT 1 FROM vehicles v
+    WHERE v.is_deleted = 0
+      AND lower(trim(v.vehicle_no)) COLLATE utf8mb4_unicode_ci = lower(trim(i.vehicle_no)) COLLATE utf8mb4_unicode_ci
+  );
+```
+
+**从出入库表 + 地磅单表导入(两表合并去重):**
+
+```sql
+INSERT INTO vehicles (vehicle_no, remarks, is_deleted, created_at)
+SELECT DISTINCT t.vehicle_no, NULL, 0, now()
+FROM (
+  SELECT trim(vehicle_no) AS vehicle_no
+  FROM inbound_outbound
+  WHERE is_deleted = 0 AND trim(ifnull(vehicle_no, '')) <> ''
+  UNION
+  SELECT trim(vehicle_no) AS vehicle_no
+  FROM weighing_slips
+  WHERE is_voided = 0 AND trim(ifnull(vehicle_no, '')) <> ''
+) t
+WHERE NOT EXISTS (
+  SELECT 1 FROM vehicles v
+  WHERE v.is_deleted = 0
+    AND lower(trim(v.vehicle_no)) COLLATE utf8mb4_unicode_ci = lower(t.vehicle_no) COLLATE utf8mb4_unicode_ci
+);
+```
+
+若库默认是 `utf8mb4_general_ci`,把上面两处 `utf8mb4_unicode_ci` 改成 `utf8mb4_general_ci` 即可。
+
+---
+
+## 三、API 与前端
+
+- **接口前缀**:`/api/master-data/vehicles`
+- **功能**:列表(支持按车牌号模糊搜索)、详情、新增、修改、软删除
+- **前端**:
+  - **art-design-pro**:基础资料 →「车牌号」Tab,表格 + 弹窗增删改
+  - **mobile-h5**:基础资料 →「车牌号」Tab,列表 + 详情页增删改

+ 1 - 0
mobile-h5/package.json

@@ -10,6 +10,7 @@
   "dependencies": {
     "axios": "^1.7.5",
     "dayjs": "^1.11.19",
+    "html2canvas": "^1.4.1",
     "mitt": "^3.0.1",
     "pinia": "^2.2.2",
     "vant": "^4.9.21",

+ 9 - 2
mobile-h5/src/api/inventoryApi.ts

@@ -52,7 +52,7 @@ export interface InventoryUpdate {
   batchNo?: string
   vehicleNo?: string
   status?: string
-  remarks?: string
+  remarks?: string | null
   purchasePlanId?: number
 }
 
@@ -71,7 +71,8 @@ export const inventoryApi = {
   }): Promise<InventoryRecord[]> => {
     return api.get<InventoryRecord[]>({
       url: '/inventory',
-      params
+      params,
+      timeout: 60000 // 列表可能数据较多,60 秒超时避免被取消
     })
   },
   get: async (id: number): Promise<InventoryRecord> => {
@@ -100,6 +101,12 @@ export const inventoryApi = {
     return api.del({
       url: `/inventory/${id}`
     })
+  },
+  createLinkForPlan: async (purchasePlanId: number, remarks?: string): Promise<InventoryRecord> => {
+    return api.post<InventoryRecord>({
+      url: '/inventory/create-link-for-plan',
+      data: { purchasePlanId, remarks }
+    })
   }
 }
 

+ 49 - 0
mobile-h5/src/api/masterDataApi.ts

@@ -98,3 +98,52 @@ export const cargoApi = {
   }
 }
 
+// ==================== 车牌号 API ====================
+
+export interface Vehicle {
+  id: number
+  vehicleNo: string
+  remarks?: string | null
+}
+
+export interface VehicleCreate {
+  vehicleNo: string
+  remarks?: string | null
+}
+
+export interface VehicleUpdate {
+  vehicleNo?: string
+  remarks?: string | null
+}
+
+export const vehicleApi = {
+  list: async (q?: string): Promise<Vehicle[]> => {
+    return api.get<Vehicle[]>({
+      url: '/master-data/vehicles',
+      params: q ? { q } : undefined
+    })
+  },
+  get: async (id: number): Promise<Vehicle> => {
+    return api.get<Vehicle>({
+      url: `/master-data/vehicles/${id}`
+    })
+  },
+  create: async (payload: VehicleCreate): Promise<Vehicle> => {
+    return api.post<Vehicle>({
+      url: '/master-data/vehicles',
+      data: payload
+    })
+  },
+  update: async (id: number, payload: VehicleUpdate): Promise<Vehicle> => {
+    return api.put<Vehicle>({
+      url: `/master-data/vehicles/${id}`,
+      data: payload
+    })
+  },
+  delete: async (id: number): Promise<void> => {
+    return api.del({
+      url: `/master-data/vehicles/${id}`
+    })
+  }
+}
+

+ 14 - 0
mobile-h5/src/api/plansApi.ts

@@ -23,6 +23,8 @@ export interface Plan {
   executedQuantity?: number
   isDeleted: number
   createdAt: string
+  /** 生成链接交三方填写时对应的出入库单 id */
+  fillLinkInventoryId?: number
 }
 
 export interface PlanCreate {
@@ -63,6 +65,13 @@ export interface PlanRelatedData {
   weighings: WeighingRecord[]
 }
 
+export interface ThirdPartyPlanPageData {
+  plan: Plan
+  today_inventories: InventoryRecord[]
+  /** 本月历史出入库单(不含今天),按日期倒序 */
+  month_inventories: InventoryRecord[]
+}
+
 export const planApi = {
   list: async (params?: {
     q?: string
@@ -86,6 +95,11 @@ export const planApi = {
       url: `/plans/${id}/related`
     })
   },
+  getThirdPartyPage: async (id: number): Promise<ThirdPartyPlanPageData> => {
+    return api.get<ThirdPartyPlanPageData>({
+      url: `/plans/${id}/third-party-page`
+    })
+  },
   create: async (payload: PlanCreate): Promise<Plan> => {
     return api.post<Plan>({
       url: '/plans',

+ 24 - 0
mobile-h5/src/router/index.ts

@@ -59,6 +59,18 @@ const routes: RouteRecordRaw[] = [
     component: () => import('@/views/inventory/detail.vue'),
     meta: { title: '出入库详情' }
   },
+  {
+    path: '/inventory/fill/:id',
+    name: 'InventoryFill',
+    component: () => import('@/views/inventory/fill.vue'),
+    meta: { title: '山峡出入库信息', noAuth: true }
+  },
+  {
+    path: '/inventory/plan/:id',
+    name: 'InventoryPlanThirdParty',
+    component: () => import('@/views/inventory/plan.vue'),
+    meta: { title: '计划出入库填写', noAuth: true }
+  },
   {
     path: '/weighing',
     name: 'Weighing',
@@ -106,6 +118,18 @@ const routes: RouteRecordRaw[] = [
     name: 'CargoDetailEdit',
     component: () => import('@/views/master-data/cargo-detail.vue'),
     meta: { title: '货物详情' }
+  },
+  {
+    path: '/master-data/vehicle/detail',
+    name: 'VehicleDetail',
+    component: () => import('@/views/master-data/vehicle-detail.vue'),
+    meta: { title: '车牌号详情' }
+  },
+  {
+    path: '/master-data/vehicle/detail/:id',
+    name: 'VehicleDetailEdit',
+    component: () => import('@/views/master-data/vehicle-detail.vue'),
+    meta: { title: '车牌号详情' }
   }
 ]
 

+ 70 - 3
mobile-h5/src/utils/utils.ts

@@ -105,13 +105,80 @@ export function getURLParameters(url: string): Record<string, string> {
   return Object.fromEntries(new URLSearchParams(url.split('?')[1]).entries())
 }
 
-// 复制文本
+// 兼容非 HTTPS 等环境:使用 execCommand 复制
+function fallbackCopy(str: string): boolean {
+  try {
+    const textarea = document.createElement('textarea')
+    textarea.value = str
+    textarea.style.position = 'fixed'
+    textarea.style.left = '-9999px'
+    textarea.setAttribute('readonly', '')
+    document.body.appendChild(textarea)
+    textarea.select()
+    const ok = document.execCommand('copy')
+    document.body.removeChild(textarea)
+    return ok
+  } catch {
+    return false
+  }
+}
+
+// 复制文本(同步,优先 clipboard API,不可用时回退 execCommand)
 export function copy(str: string): boolean {
   try {
-    navigator.clipboard.writeText(str)
-    return true
+    if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+      navigator.clipboard.writeText(str)
+      return true
+    }
+    return fallbackCopy(str)
   } catch (err) {
     console.error('Copy failed:', err)
+    return fallbackCopy(str)
+  }
+}
+
+/**
+ * 异步复制到剪贴板,生产环境安全:优先 clipboard API,不可用时回退 execCommand。
+ */
+export async function copyToClipboard(str: string): Promise<boolean> {
+  try {
+    if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+      await navigator.clipboard.writeText(str)
+      return true
+    }
+    return fallbackCopy(str)
+  } catch {
+    return fallbackCopy(str)
+  }
+}
+
+/**
+ * 从图片 URL 拉取图片并复制到剪贴板(需支持 ClipboardItem,通常为 HTTPS 环境)。
+ */
+export async function copyImageFromUrl(imageUrl: string): Promise<boolean> {
+  try {
+    if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
+    const res = await fetch(imageUrl, { mode: 'cors' })
+    if (!res.ok) return false
+    const blob = await res.blob()
+    const type = blob.type || 'image/png'
+    await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * 将 Blob 图片写入剪贴板(用于 html2canvas 等生成的图片)。
+ */
+export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
+  try {
+    if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
+    const type = blob.type || 'image/png'
+    await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
+    return true
+  } catch {
     return false
   }
 }

+ 156 - 13
mobile-h5/src/views/inventory/detail.vue

@@ -59,7 +59,7 @@
             </div>
           </div>
 
-          <div class="form-item">
+          <div class="form-item" :class="{ 'quantity-default-red': formData.quantity === 32 }">
             <div class="form-label">数量(吨) <span class="required">*</span></div>
             <div v-if="isView" class="form-input">
               {{ formData.quantity || '--' }}
@@ -68,7 +68,7 @@
               v-else
               v-model.number="formData.quantity"
               type="number"
-              placeholder="请输入数量"
+              placeholder="请输入数量(默认32,请核对)"
             />
           </div>
 
@@ -77,11 +77,12 @@
             <div v-if="isView" class="form-input">
               {{ formData.vehicleNo || '--' }}
             </div>
-            <van-field
-              v-else
-              v-model="formData.vehicleNo"
-              placeholder="请输入车牌号"
-            />
+            <div v-else class="form-input" @click="showVehiclePicker = true">
+              <span :class="{ placeholder: !formData.vehicleNo }">
+                {{ formData.vehicleNo || '请选择或输入车牌号' }}
+              </span>
+              <van-icon name="arrow" class="arrow-icon" />
+            </div>
           </div>
 
           <div class="form-item">
@@ -161,6 +162,52 @@
       v-model="dateSelected"
       @confirm="onDateConfirm"
     />
+
+    <!-- 车牌号选择器:支持搜索 + 手动输入 -->
+    <van-popup v-model:show="showVehiclePicker" round position="bottom" :style="{ maxHeight: '70vh' }">
+      <div class="vehicle-picker-container">
+        <div class="search-container">
+          <van-search
+            v-model="vehicleSearchKeyword"
+            placeholder="搜索车牌号"
+            @search="handleVehicleSearch"
+            @clear="handleVehicleSearch"
+            @update:model-value="handleVehicleSearch"
+            shape="round"
+          />
+        </div>
+        <div class="vehicle-list">
+          <van-cell
+            v-for="item in filteredVehicleOptions"
+            :key="item.vehicleNo"
+            :title="item.vehicleNo"
+            clickable
+            @click="onVehicleSelect(item.vehicleNo)"
+          />
+          <van-cell v-if="filteredVehicleOptions.length === 0 && !vehicleLoading" title="暂无匹配车牌号,可点击下方手动输入" />
+        </div>
+        <div class="manual-input-row">
+          <van-button block plain type="primary" @click="showManualVehicleInput = true">
+            手动输入新车牌号
+          </van-button>
+        </div>
+      </div>
+    </van-popup>
+
+    <!-- 手动输入车牌号弹层 -->
+    <van-dialog
+      v-model:show="showManualVehicleInput"
+      title="输入车牌号"
+      show-cancel-button
+      @confirm="onManualVehicleConfirm"
+    >
+      <van-field
+        v-model="manualVehicleNo"
+        placeholder="请输入车牌号"
+        clearable
+        class="dialog-field"
+      />
+    </van-dialog>
   </div>
 </template>
 
@@ -169,7 +216,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { showToast } from 'vant'
 import { inventoryApi, type InventoryRecord, type InventoryCreate } from '@/api/inventoryApi'
-import { companyApi, cargoApi } from '@/api/masterDataApi'
+import { companyApi, cargoApi, vehicleApi } from '@/api/masterDataApi'
 import dayjs from 'dayjs'
 
 const router = useRouter()
@@ -185,10 +232,16 @@ const saving = ref(false)
 const editingRecord = ref<InventoryRecord | null>(null)
 const showCompanyPicker = ref(false)
 const showCargoPicker = ref(false)
+const showVehiclePicker = ref(false)
+const showManualVehicleInput = ref(false)
 const showDateCalendar = ref(false)
 const dateSelected = ref<Date | null>(null)
 const companyOptions = ref<{ text: string; value: string }[]>([])
 const cargoOptions = ref<{ text: string; value: string }[]>([])
+const vehicleOptions = ref<{ vehicleNo: string }[]>([])
+const vehicleSearchKeyword = ref('')
+const vehicleLoading = ref(false)
+const manualVehicleNo = ref('')
 
 // 公司搜索相关
 const companySearchKeyword = ref('')
@@ -203,6 +256,14 @@ const filteredCompanyOptions = computed(() => {
   return [filtered]
 })
 
+const filteredVehicleOptions = computed(() => {
+  if (!vehicleSearchKeyword.value) return vehicleOptions.value
+  const kw = vehicleSearchKeyword.value.toLowerCase()
+  return vehicleOptions.value.filter(item =>
+    item.vehicleNo.toLowerCase().includes(kw)
+  )
+})
+
 // 如果是从计划管理跳转过来的(有planNo参数),日期为空;否则使用当前日期
 const initialDate = route.query.planNo ? '' : dayjs().format('YYYY-MM-DD')
 const purchasePlanIdFromRoute = route.query.purchasePlanId ? Number(route.query.purchasePlanId) : undefined
@@ -212,7 +273,7 @@ const formData = reactive<InventoryCreate>({
   partnerCompany: (route.query.customerName as string) || '',
   businessType: (route.query.businessType as '入库' | '出库') || '入库',
   product: (route.query.product as string) || '',
-  quantity: 0,
+  quantity: 32,
   vehicleNo: '',
   planNo: (route.query.planNo as string) || '',
   remarks: '',
@@ -251,6 +312,47 @@ const loadCargos = async () => {
   }
 }
 
+const loadVehicles = async () => {
+  vehicleLoading.value = true
+  try {
+    const list = await vehicleApi.list(vehicleSearchKeyword.value || undefined)
+    vehicleOptions.value = list.map(item => ({ vehicleNo: item.vehicleNo }))
+  } catch (error) {
+    console.error('加载车牌号列表失败', error)
+  } finally {
+    vehicleLoading.value = false
+  }
+}
+
+const handleVehicleSearch = () => {
+  loadVehicles()
+}
+
+const onVehicleSelect = (vehicleNo: string) => {
+  formData.vehicleNo = vehicleNo
+  showVehiclePicker.value = false
+}
+
+const onManualVehicleConfirm = () => {
+  const v = (manualVehicleNo.value || '').trim()
+  if (v) {
+    formData.vehicleNo = v
+    showVehiclePicker.value = false
+  }
+  showManualVehicleInput.value = false
+  manualVehicleNo.value = ''
+}
+
+// 保存前确保车牌号已写入车牌号表(新车牌则先创建)
+const ensureVehicleExists = async (vehicleNo: string) => {
+  if (!vehicleNo?.trim()) return
+  try {
+    await vehicleApi.create({ vehicleNo: vehicleNo.trim(), remarks: null })
+  } catch (err: any) {
+    if (err.response?.status !== 409) throw err
+  }
+}
+
 const onCompanyConfirm = ({ selectedOptions }: any) => {
   formData.partnerCompany = selectedOptions[0].value
   showCompanyPicker.value = false
@@ -303,6 +405,9 @@ const handleSubmit = async () => {
 
   saving.value = true
   try {
+    if (formData.vehicleNo?.trim()) {
+      await ensureVehicleExists(formData.vehicleNo.trim())
+    }
     if (editingRecord.value) {
       await inventoryApi.update(editingRecord.value.id, formData)
       showToast({ type: 'success', message: '更新成功' })
@@ -310,9 +415,9 @@ const handleSubmit = async () => {
       await inventoryApi.create(formData)
       showToast({ type: 'success', message: '创建成功' })
     }
-    // 根据来源跳转到对应页面
+    // 根据来源跳转到对应页面(兼容 from=plan / from=plans)
     const from = route.query.from as string
-    if (from === 'plans') {
+    if (from === 'plan' || from === 'plans') {
       router.push('/plans')
     } else {
       router.push('/inventory')
@@ -325,9 +430,9 @@ const handleSubmit = async () => {
 }
 
 const handleBack = () => {
-  // 根据来源跳转到对应页面
+  // 根据来源跳转到对应页面(兼容 from=plan / from=plans)
   const from = route.query.from as string
-  if (from === 'plans') {
+  if (from === 'plan' || from === 'plans') {
     router.push('/plans')
   } else {
     router.push('/inventory')
@@ -341,6 +446,13 @@ watch(showCompanyPicker, (newVal) => {
   }
 })
 
+watch(showVehiclePicker, (newVal) => {
+  if (newVal) {
+    vehicleSearchKeyword.value = ''
+    loadVehicles()
+  }
+})
+
 onMounted(async () => {
   await Promise.all([loadCompanies(), loadCargos()])
   if (isEdit.value || isView.value) {
@@ -378,5 +490,36 @@ onMounted(async () => {
 }
 
 /* 不强制让 van-picker 占满剩余高度,使用 popup 的 max-height 控制总体高度 */
+
+.vehicle-picker-container {
+  display: flex;
+  flex-direction: column;
+  max-height: 70vh;
+}
+
+.vehicle-picker-container .vehicle-list {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.vehicle-picker-container .manual-input-row {
+  padding: 12px 16px;
+  border-top: 1px solid #ebedf0;
+  background: #fff;
+}
+
+.dialog-field {
+  padding: 12px 16px;
+}
+
+/* 数量为默认值 32 时红色标识,提醒用户关注 */
+.quantity-default-red .form-label,
+.quantity-default-red .form-label .required {
+  color: var(--van-danger-color, #ee0a24);
+}
+.quantity-default-red :deep(.van-field__control) {
+  color: var(--van-danger-color, #ee0a24);
+  font-weight: 500;
+}
 </style>
 

+ 314 - 0
mobile-h5/src/views/inventory/fill.vue

@@ -0,0 +1,314 @@
+<template>
+  <div class="page fill-page">
+    <div class="page-header">
+      <div class="header-title">山峡出入库信息</div>
+    </div>
+
+    <div v-if="loading" class="loading-wrap">
+      <van-loading type="spinner" />
+    </div>
+
+    <template v-else-if="record">
+      <!-- 已填写完整:只读 + 二维码 -->
+      <div v-if="isFilled" class="readonly-section">
+        <div class="form-section card">
+          <div class="section-title">出入库单信息(已锁定)</div>
+          <div class="info-row"><span class="label">日期:</span><span class="value">{{ record.date }}</span></div>
+          <div class="info-row"><span class="label">往来单位:</span><span class="value">{{ record.partnerCompany }}</span></div>
+          <div class="info-row"><span class="label">业务类型:</span><span class="value">{{ record.businessType }}</span></div>
+          <div class="info-row"><span class="label">产品:</span><span class="value">{{ record.product }}</span></div>
+          <div class="info-row"><span class="label">数量(吨):</span><span class="value">{{ record.quantity }}</span></div>
+          <div class="info-row">
+            <span class="label">车牌号:</span>
+            <span class="value vehicle-no">{{ record.vehicleNo }}</span>
+          </div>
+          <div class="info-row" v-if="record.remarks"><span class="label">备注:</span><span class="value">{{ record.remarks }}</span></div>
+        </div>
+        <div class="qrcode-section card">
+          <div class="inventory-id-line">
+            {{ record.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ record.id }}
+          </div>
+          <div class="qrcode-wrap">
+            <img :src="qrcodeSrc" alt="二维码" class="qrcode-img" />
+          </div>
+          <div class="qrcode-section-desc">出入库单标识(扫码核验)</div>
+        </div>
+      </div>
+
+      <!-- 未填写完整:可编辑表单 -->
+      <div v-else class="form-container">
+        <van-form @submit="handleSubmit">
+          <div class="form-section card">
+            <div class="section-title">请完善以下信息</div>
+            <div class="info-row readonly-row">
+              <span class="label">往来单位:</span>
+              <span class="value">{{ record.partnerCompany }}</span>
+            </div>
+            <div class="info-row readonly-row">
+              <span class="label">业务类型:</span>
+              <span class="value">{{ record.businessType }}</span>
+            </div>
+            <div class="info-row readonly-row">
+              <span class="label">产品:</span>
+              <span class="value">{{ record.product }}</span>
+            </div>
+            <div class="form-item">
+              <div class="form-label">日期 <span class="required">*</span></div>
+              <div class="form-input" @click="showDatePicker = true">
+                <span :class="{ placeholder: !formData.date }">{{ formData.date || '选择日期' }}</span>
+                <van-icon name="arrow" class="arrow-icon" />
+              </div>
+            </div>
+            <div class="form-item">
+              <div class="form-label">数量(吨) <span class="required">*</span></div>
+              <van-field v-model="formData.quantity" type="number" placeholder="请输入数量(吨)" />
+            </div>
+            <div class="form-item">
+              <div class="form-label">车牌号 <span class="required">*</span></div>
+              <van-field v-model="formData.vehicleNo" placeholder="请输入车牌号" />
+            </div>
+            <div class="form-item">
+              <div class="form-label">备注</div>
+              <van-field
+                :model-value="formData.remarks ?? ''"
+                @update:model-value="(v: string) => { formData.remarks = v }"
+                type="textarea"
+                autosize
+                rows="2"
+                placeholder="选填"
+              />
+            </div>
+          </div>
+          <div class="form-actions">
+            <van-button block type="primary" native-type="submit" :loading="saving">保存</van-button>
+          </div>
+        </van-form>
+      </div>
+    </template>
+
+    <van-calendar v-model:show="showDatePicker" @confirm="onDateConfirm" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { showToast } from 'vant'
+import { inventoryApi, type InventoryRecord, type InventoryUpdate } from '@/api/inventoryApi'
+import { vehicleApi } from '@/api/masterDataApi'
+import dayjs from 'dayjs'
+
+const route = useRoute()
+const loading = ref(true)
+const saving = ref(false)
+const record = ref<InventoryRecord | null>(null)
+const showDatePicker = ref(false)
+
+const formData = reactive<InventoryUpdate>({
+  date: '',
+  quantity: undefined,
+  vehicleNo: '',
+  remarks: ''
+})
+
+const isFilled = computed(() => {
+  if (!record.value) return false
+  const r = record.value
+  const hasDate = !!(r.date && r.date.trim())
+  const hasQty = r.quantity != null && r.quantity !== 0
+  const hasVehicle = !!(r.vehicleNo && r.vehicleNo.trim())
+  return hasDate && hasQty && hasVehicle
+})
+
+const qrcodeSrc = computed(() => {
+  if (!record.value) return ''
+  const id = String(record.value.id)
+  return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(id)}`
+})
+
+const loadData = async () => {
+  const id = route.params.id
+  if (!id) return
+  loading.value = true
+  try {
+    record.value = await inventoryApi.get(Number(id))
+    formData.date = record.value.date || ''
+    formData.quantity = record.value.quantity
+    formData.vehicleNo = record.value.vehicleNo || ''
+    formData.remarks = record.value.remarks || ''
+  } catch (e) {
+    showToast({ type: 'fail', message: '加载失败' })
+  } finally {
+    loading.value = false
+  }
+}
+
+const onDateConfirm = (value: Date | Date[]) => {
+  const d = Array.isArray(value) ? value[0] : value
+  formData.date = dayjs(d).format('YYYY-MM-DD')
+  showDatePicker.value = false
+}
+
+// 保存前确保车牌号已写入车牌号表(新车牌则先创建,与 Web 行为一致)
+const ensureVehicleExists = async (vehicleNo: string) => {
+  if (!vehicleNo?.trim()) return
+  try {
+    await vehicleApi.create({ vehicleNo: vehicleNo.trim(), remarks: null })
+  } catch (err: any) {
+    if (err.response?.status !== 409) throw err
+  }
+}
+
+const handleSubmit = async () => {
+  const date = (formData.date || '').trim()
+  const qty = formData.quantity
+  const vehicle = (formData.vehicleNo || '').trim()
+  if (!date || (qty == null || qty === 0) || !vehicle) {
+    showToast({ type: 'fail', message: '请填写日期、数量、车牌号' })
+    return
+  }
+  if (!record.value) return
+  saving.value = true
+  try {
+    await ensureVehicleExists(vehicle)
+    await inventoryApi.update(record.value.id, {
+      date,
+      quantity: Number(qty),
+      vehicleNo: vehicle,
+      // 备注清空时传 null,否则后端不会更新该字段
+      remarks: formData.remarks === '' ? null : (formData.remarks || undefined)
+    })
+    showToast({ type: 'success', message: '保存成功' })
+    await loadData()
+  } catch (e: any) {
+    showToast({ type: 'fail', message: e?.response?.data?.detail || e?.message || '保存失败' })
+  } finally {
+    saving.value = false
+  }
+}
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style lang="scss" scoped>
+.fill-page {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 24px;
+}
+
+.page-header {
+  background: #fff;
+  padding: 16px;
+  border-bottom: 1px solid #e5e7eb;
+  text-align: center;
+  .header-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #111827;
+  }
+}
+
+.loading-wrap {
+  padding: 48px;
+  display: flex;
+  justify-content: center;
+}
+
+.card {
+  background: #fff;
+  border-radius: 12px;
+  margin: 16px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 12px;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+  font-size: 14px;
+  .label {
+    color: #6b7280;
+    min-width: 90px;
+  }
+  .value {
+    color: #111827;
+    flex: 1;
+  }
+  &.readonly-row {
+    padding: 4px 0;
+  }
+}
+
+.form-item {
+  margin-bottom: 12px;
+  .form-label {
+    font-size: 14px;
+    color: #374151;
+    margin-bottom: 6px;
+    .required {
+      color: #ef4444;
+    }
+  }
+  .form-input {
+    background: #f9fafb;
+    border: 1px solid #e5e7eb;
+    border-radius: 8px;
+    padding: 10px 12px;
+    font-size: 14px;
+    color: #111827;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    .placeholder {
+      color: #9ca3af;
+    }
+    .arrow-icon {
+      color: #9ca3af;
+    }
+  }
+}
+
+.form-actions {
+  padding: 16px;
+}
+
+.qrcode-section {
+  text-align: center;
+  .inventory-id-line {
+    font-size: 20px;
+    font-weight: 700;
+    color: #111827;
+    margin-bottom: 12px;
+  }
+  .qrcode-wrap {
+    padding: 16px 0;
+  }
+  .qrcode-img {
+    width: 200px;
+    height: 200px;
+    display: inline-block;
+  }
+  .qrcode-section-desc {
+    font-size: 15px;
+    font-weight: 600;
+    color: #374151;
+    margin-top: 8px;
+  }
+}
+
+.info-row .value.vehicle-no {
+  font-weight: 700;
+  color: #ef4444;
+}
+</style>

+ 205 - 6
mobile-h5/src/views/inventory/index.vue

@@ -118,7 +118,23 @@
             >
               {{ item.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
             </van-button>
-            <van-button size="small" type="danger" plain @click.stop="handleDelete(item.id)">删除</van-button>
+            <van-button
+              size="small"
+              type="primary"
+              plain
+              @click.stop="handleCopyFillLink(item)"
+            >
+              磅单二维码
+            </van-button>
+            <van-button
+              v-if="isRecordToday(item)"
+              size="small"
+              type="danger"
+              plain
+              @click.stop="handleDelete(item.id)"
+            >
+              删除
+            </van-button>
           </div>
         </div>
       </div>
@@ -273,11 +289,44 @@
       </div>
     </van-dialog>
 
+    <!-- 磅单二维码弹窗:供截图保存,不依赖 HTTPS -->
+    <van-dialog
+      v-model:show="qrcodeModalOpen"
+      title="山峡出入库磅单二维码"
+      :show-cancel-button="false"
+      confirm-button-text=""
+      :show-confirm-button="false"
+      :close-on-click-overlay="true"
+      :overlay-style="{ background: 'rgba(0,0,0,0.92)' }"
+      class="qrcode-dialog"
+      @closed="handleQrcodeDialogClose"
+    >
+      <div v-if="captureRow" class="qrcode-dialog-body pound-slip-capture-inline">
+        <div class="capture-card">
+          <div class="capture-title">出入库单信息(已锁定)</div>
+          <div class="capture-row"><span class="label">日期:</span><span class="value">{{ captureRow.date }}</span></div>
+          <div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ captureRow.partnerCompany }}</span></div>
+          <div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ captureRow.businessType }}</span></div>
+          <div class="capture-row"><span class="label">产品:</span><span class="value">{{ captureRow.product }}</span></div>
+          <div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ captureRow.quantity }}</span></div>
+          <div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ captureRow.vehicleNo }}</span></div>
+          <div v-if="captureRow.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ captureRow.remarks }}</span></div>
+        </div>
+        <div class="capture-card capture-qrcode">
+          <div class="capture-id">{{ captureRow.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ captureRow.id }}</div>
+          <div class="capture-qr-wrap">
+            <img v-if="captureQrObjectUrl" :src="captureQrObjectUrl" alt="二维码" class="capture-qr-img" />
+          </div>
+          <div class="capture-desc">出入库单标识(扫码核验)</div>
+        </div>
+        <div class="qrcode-dialog-hint">请截图保存上方二维码及磅单信息</div>
+      </div>
+    </van-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, onMounted, nextTick } from 'vue'
 import dayjs from 'dayjs'
 import { useRoute, useRouter } from 'vue-router'
 import { showToast, showDialog } from 'vant'
@@ -295,11 +344,12 @@ const allData = ref<InventoryRecord[]>([]) // 存储所有数据
 const searchKeyword = ref('')
 const currentPage = ref(1)
 const pageSize = ref(20)
-const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
 
 const dateFilterOptions = [
   { label: '全部', value: 'all' },
   { label: '今日', value: 'today' },
+  { label: '明日', value: 'tomorrow' },
   { label: '昨日', value: 'yesterday' },
   { label: '本周', value: 'week' }
 ] as const
@@ -309,6 +359,11 @@ const relatedModalOpen = ref(false)
 const loadingRelated = ref(false)
 const relatedData = ref<{ plan: Plan | null, weighing: WeighingRecord | null }>({ plan: null, weighing: null })
 
+// 磅单二维码弹窗:当前展示的记录与二维码图(Object URL)
+const qrcodeModalOpen = ref(false)
+const captureRow = ref<InventoryRecord | null>(null)
+const captureQrObjectUrl = ref<string>('')
+
 const loadData = async (reset = false) => {
   if (reset) {
     currentPage.value = 1
@@ -329,6 +384,10 @@ const loadData = async (reset = false) => {
         const d = today.format('YYYY-MM-DD')
         params.dateFrom = d
         params.dateTo = d
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = today.add(1, 'day').format('YYYY-MM-DD')
+        params.dateFrom = t
+        params.dateTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = today.subtract(1, 'day').format('YYYY-MM-DD')
         params.dateFrom = y
@@ -343,7 +402,8 @@ const loadData = async (reset = false) => {
       }
 
       const records = await inventoryApi.list(params)
-      allData.value = records
+      // 按日期倒序排列
+      allData.value = records.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
     }
 
     // 分页切片
@@ -389,8 +449,9 @@ const setDateFilter = (type: (typeof dateFilterOptions)[number]['value']) => {
 }
 
 const handleBack = () => {
-  // 如果是从计划管理跳转过来的,返回到计划管理页面
-  if (route.query.from === 'plans') {
+  // 如果是从计划管理跳转过来的,返回到计划管理页面(兼容 from=plan / from=plans)
+  const from = route.query.from as string
+  if (from === 'plan' || from === 'plans') {
     router.push('/plans')
   } else {
     router.push('/dashboard')
@@ -419,6 +480,12 @@ const handleEdit = (record: InventoryRecord) => {
   router.push(`/inventory/detail/${record.id}?mode=edit`)
 }
 
+/** 判断是否为当天记录(仅当天可删除) */
+const isRecordToday = (item: InventoryRecord) => {
+  const dateStr = item.date || ''
+  return dateStr === dayjs().format('YYYY-MM-DD')
+}
+
 const handleDelete = (id: number) => {
   showDialog({
     title: '确认删除',
@@ -461,6 +528,37 @@ const handleGenerateWeighing = (record: InventoryRecord) => {
   })
 }
 
+/**
+ * 生成二维码图片地址:内容为出入库单 ID(与 H5 填写页一致)
+ */
+const getQrImageUrl = (inventoryId: number) =>
+  `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(String(inventoryId))}`
+
+/** 打开磅单二维码弹窗,供用户截图保存(不依赖 HTTPS) */
+const handleCopyFillLink = async (row: InventoryRecord) => {
+  const qrImageUrl = getQrImageUrl(row.id)
+  try {
+    captureRow.value = row
+    captureQrObjectUrl.value = ''
+    qrcodeModalOpen.value = true
+    await nextTick()
+    const res = await fetch(qrImageUrl, { mode: 'cors' })
+    if (!res.ok) throw new Error('二维码图片加载失败')
+    const blob = await res.blob()
+    captureQrObjectUrl.value = URL.createObjectURL(blob)
+  } catch (e: any) {
+    showToast({ type: 'fail', message: e?.message || '加载二维码失败' })
+    qrcodeModalOpen.value = false
+    captureRow.value = null
+  }
+}
+
+const handleQrcodeDialogClose = () => {
+  if (captureQrObjectUrl.value) URL.revokeObjectURL(captureQrObjectUrl.value)
+  captureRow.value = null
+  captureQrObjectUrl.value = ''
+}
+
 const handleViewRelated = async (record: InventoryRecord) => {
   loadingRelated.value = true
   relatedModalOpen.value = true
@@ -726,6 +824,19 @@ onMounted(() => {
   }
 }
 
+/* 磅单二维码弹窗:顶部/底部/内容区上下边距缩小 */
+:deep(.qrcode-dialog) {
+  .van-dialog__header {
+    padding: 8px 16px 6px;
+  }
+  .van-dialog__content {
+    padding: 6px 16px;
+  }
+  .van-dialog__footer {
+    padding: 6px 16px 8px;
+  }
+}
+
 .fab-button {
   position: fixed;
   right: 16px;
@@ -751,4 +862,92 @@ onMounted(() => {
     font-size: 24px;
   }
 }
+
+/* 磅单二维码弹窗内容(紧凑布局,便于截图) */
+.qrcode-dialog-body {
+  max-height: 70vh;
+  overflow-y: auto;
+  padding: 6px 10px;
+}
+.qrcode-dialog-hint {
+  margin-top: 6px;
+  padding: 5px 10px;
+  font-size: 12px;
+  color: #6b7280;
+  text-align: center;
+  background: #f3f4f6;
+  border-radius: 6px;
+}
+.pound-slip-capture-inline {
+  background: #f5f7fa;
+  padding: 6px 10px;
+  font-family: inherit;
+  .capture-header {
+    font-size: 15px;
+    font-weight: 600;
+    color: #111827;
+    padding: 4px 10px;
+    background: #fff;
+    text-align: center;
+    border-radius: 8px;
+    margin-bottom: 8px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+  }
+  .capture-card {
+    background: #fff;
+    border-radius: 8px;
+    margin-bottom: 8px;
+    padding: 8px 10px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+  }
+  .capture-title {
+    font-size: 13px;
+    font-weight: 600;
+    color: #374151;
+    margin-bottom: 4px;
+  }
+  .capture-row {
+    display: flex;
+    align-items: center;
+    margin-bottom: 4px;
+    font-size: 13px;
+    .label {
+      color: #6b7280;
+      min-width: 82px;
+    }
+    .value {
+      color: #111827;
+      flex: 1;
+    }
+    .vehicle-no {
+      font-weight: 700;
+      color: #111827;
+      font-size: 15px;
+    }
+  }
+  .capture-qrcode {
+    text-align: center;
+  }
+  .capture-id {
+    font-size: 16px;
+    font-weight: 700;
+    color: #111827;
+    margin-bottom: 6px;
+  }
+  .capture-qr-wrap {
+    padding: 8px 0;
+    min-height: 168px;
+  }
+  .capture-qr-img {
+    width: 168px;
+    height: 168px;
+    display: inline-block;
+  }
+  .capture-desc {
+    font-size: 13px;
+    font-weight: 600;
+    color: #374151;
+    margin-top: 4px;
+  }
+}
 </style>

+ 791 - 0
mobile-h5/src/views/inventory/plan.vue

@@ -0,0 +1,791 @@
+<template>
+  <div class="page">
+    <div class="header">
+      <div class="header-left">
+        <div class="header-title">计划出入库填写</div>
+      </div>
+    </div>
+
+    <div v-if="loading" class="loading-wrap">
+      <van-loading type="spinner" />
+    </div>
+
+    <template v-else>
+      <div v-if="errorMessage" class="error-wrap">
+        <van-empty :description="errorMessage" />
+      </div>
+      <div v-else-if="pageData" class="content">
+        <div class="card plan-card">
+          <div class="section-title">计划信息</div>
+          <div class="info-row">
+            <span class="label">往来单位:</span>
+            <span class="value">{{ pageData.plan.customerName || '未填' }}</span>
+          </div>
+          <div class="info-row">
+            <span class="label">产品:</span>
+            <span class="value">{{ pageData.plan.product }}</span>
+          </div>
+          <div class="info-row">
+            <span class="label">起始日期:</span>
+            <span class="value">{{ pageData.plan.startDate || '--' }}</span>
+          </div>
+          <div class="info-row">
+            <span class="label">截止日期:</span>
+            <span class="value">{{ pageData.plan.endDate || '--' }}</span>
+          </div>
+        </div>
+
+        <div class="card list-card">
+          <div class="list-header">
+            <div class="section-title">今日出入库单</div>
+            <van-button
+              size="small"
+              type="primary"
+              @click="openCreateDialog"
+            >
+              新增
+            </van-button>
+          </div>
+
+          <div v-if="pageData.today_inventories.length === 0" class="empty">
+            <van-empty description="今日暂无出入库单" />
+          </div>
+          <div v-else class="list">
+            <div
+              v-for="item in pageData.today_inventories"
+              :key="item.id"
+              class="inv-card"
+            >
+              <div class="inv-main">
+                <div class="inv-line">
+                  <span class="label">单号:</span>
+                  <span class="value">{{ item.batchNo || '未填单号' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">日期:</span>
+                  <span class="value">{{ item.date || '--' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">车牌号:</span>
+                  <span class="value">{{ item.vehicleNo || '--' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">数量(吨):</span>
+                  <span class="value">{{ item.quantity }}</span>
+                </div>
+              </div>
+              <div class="inv-actions">
+                <van-button
+                  size="small"
+                  type="primary"
+                  plain
+                  @click="showQr(item)"
+                >
+                  查看二维码
+                </van-button>
+                <van-button
+                  size="small"
+                  type="primary"
+                  plain
+                  @click="handleCopyFromList(item)"
+                >
+                  复制图片到剪贴板
+                </van-button>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="card list-card" v-if="pageData.month_inventories && pageData.month_inventories.length">
+          <div class="list-header">
+            <div class="section-title">本月历史出入库单</div>
+          </div>
+          <div class="list">
+            <div
+              v-for="item in pageData.month_inventories"
+              :key="item.id"
+              class="inv-card"
+            >
+              <div class="inv-main">
+                <div class="inv-line">
+                  <span class="label">单号:</span>
+                  <span class="value">{{ item.batchNo || '未填单号' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">日期:</span>
+                  <span class="value">{{ item.date || '--' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">车牌号:</span>
+                  <span class="value">{{ item.vehicleNo || '--' }}</span>
+                </div>
+                <div class="inv-line">
+                  <span class="label">数量(吨):</span>
+                  <span class="value">{{ item.quantity }}</span>
+                </div>
+              </div>
+              <div class="inv-actions">
+                <van-button
+                  size="small"
+                  type="primary"
+                  plain
+                  @click="showQr(item)"
+                >
+                  查看二维码
+                </van-button>
+                <van-button
+                  size="small"
+                  type="primary"
+                  plain
+                  @click="handleCopyFromList(item)"
+                >
+                  复制图片到剪贴板
+                </van-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </template>
+
+    <!-- 新增出入库单弹窗 -->
+    <van-dialog
+      v-model:show="createDialogOpen"
+      title="新增出入库单"
+      show-cancel-button
+      cancel-button-text="取消"
+      confirm-button-text="保存"
+      :show-confirm-button="false"
+      class="create-dialog"
+    >
+      <div class="dialog-body">
+        <van-form @submit="handleCreateSubmit">
+          <div class="form-item">
+            <div class="form-label">日期</div>
+            <div class="form-value readonly">{{ todayStr }}</div>
+          </div>
+          <div class="form-item">
+            <div class="form-label">数量(吨) *</div>
+            <van-field
+              v-model.number="createForm.quantity"
+              type="number"
+              placeholder="请输入数量(吨)"
+            />
+          </div>
+          <div class="form-item">
+            <div class="form-label">车牌号 *</div>
+            <van-field
+              v-model="createForm.vehicleNo"
+              placeholder="请输入车牌号"
+            />
+          </div>
+          <div class="form-item">
+            <div class="form-label">备注</div>
+            <van-field
+              v-model="createForm.remarks"
+              type="textarea"
+              rows="2"
+              autosize
+              placeholder="选填"
+            />
+          </div>
+          <div class="dialog-actions">
+            <van-button
+              block
+              type="primary"
+              native-type="submit"
+              :loading="creating"
+            >
+              保存
+            </van-button>
+          </div>
+        </van-form>
+      </div>
+    </van-dialog>
+
+    <!-- 查看二维码弹窗:风格与「出入库管理」的磅单二维码一致 -->
+    <van-dialog
+      v-model:show="qrDialogOpen"
+      title="出入库单二维码"
+      show-cancel-button
+      cancel-button-text="关闭"
+      confirm-button-text=""
+      :show-confirm-button="false"
+      class="qr-dialog"
+    >
+      <div
+        v-if="qrInventory"
+        ref="qrCaptureRef"
+        class="qr-dialog-body"
+      >
+        <div class="capture-header">山峡出入库信息</div>
+        <div class="capture-card">
+          <div class="capture-title">出入库单信息(已锁定)</div>
+          <div class="capture-row"><span class="label">日期:</span><span class="value">{{ qrInventory.date || '--' }}</span></div>
+          <div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ qrInventory.partnerCompany || '--' }}</span></div>
+          <div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ qrInventory.businessType }}</span></div>
+          <div class="capture-row"><span class="label">产品:</span><span class="value">{{ qrInventory.product }}</span></div>
+          <div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ qrInventory.quantity }}</span></div>
+          <div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ qrInventory.vehicleNo || '--' }}</span></div>
+          <div v-if="qrInventory.batchNo" class="capture-row"><span class="label">单号:</span><span class="value">{{ qrInventory.batchNo }}</span></div>
+          <div v-if="qrInventory.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ qrInventory.remarks }}</span></div>
+        </div>
+        <div class="capture-card capture-qrcode">
+          <div class="capture-id">{{ qrInventory.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ qrInventory.id }}</div>
+          <div class="capture-qr-wrap">
+            <img :src="qrImageSrc" alt="二维码" class="capture-qr-img" />
+          </div>
+          <div class="capture-desc">出入库单标识(扫码核验)</div>
+        </div>
+      </div>
+    </van-dialog>
+
+    <!-- 隐藏的截图区域:用于列表中的「复制图片到剪贴板」 -->
+    <div v-if="captureInventory" ref="captureRef" class="pound-slip-capture">
+      <div class="capture-header">山峡出入库信息</div>
+      <div class="capture-card">
+        <div class="capture-title">出入库单信息(已锁定)</div>
+        <div class="capture-row"><span class="label">日期:</span><span class="value">{{ captureInventory.date || '--' }}</span></div>
+        <div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ captureInventory.partnerCompany || '--' }}</span></div>
+        <div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ captureInventory.businessType }}</span></div>
+        <div class="capture-row"><span class="label">产品:</span><span class="value">{{ captureInventory.product }}</span></div>
+        <div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ captureInventory.quantity }}</span></div>
+        <div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ captureInventory.vehicleNo || '--' }}</span></div>
+        <div v-if="captureInventory.batchNo" class="capture-row"><span class="label">单号:</span><span class="value">{{ captureInventory.batchNo }}</span></div>
+        <div v-if="captureInventory.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ captureInventory.remarks }}</span></div>
+      </div>
+      <div class="capture-card capture-qrcode">
+        <div class="capture-id">{{ captureInventory.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ captureInventory.id }}</div>
+        <div class="capture-qr-wrap">
+          <img :src="getQrImageUrl(captureInventory.id)" alt="二维码" class="capture-qr-img" crossorigin="anonymous" />
+        </div>
+        <div class="capture-desc">出入库单标识(扫码核验)</div>
+      </div>
+    </div>
+
+    <!-- 全屏生成图片 Loading 遮罩 -->
+    <van-overlay :show="copyingImage" class="copy-overlay">
+      <div class="copy-overlay__content">
+        <van-loading type="spinner" color="#fff" />
+        <div class="copy-overlay__text">图片生成中...</div>
+      </div>
+    </van-overlay>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted, nextTick } from 'vue'
+import { useRoute } from 'vue-router'
+import dayjs from 'dayjs'
+import { showToast } from 'vant'
+import { planApi, type ThirdPartyPlanPageData } from '@/api/plansApi'
+import { inventoryApi, type InventoryCreate, type InventoryRecord } from '@/api/inventoryApi'
+import { copyImageBlobToClipboard } from '@/utils/utils'
+import html2canvas from 'html2canvas'
+
+const route = useRoute()
+
+const loading = ref(true)
+const errorMessage = ref('')
+const pageData = ref<ThirdPartyPlanPageData | null>(null)
+
+const todayStr = dayjs().format('YYYY-MM-DD')
+
+const createDialogOpen = ref(false)
+const creating = ref(false)
+const createForm = reactive<Pick<InventoryCreate, 'quantity' | 'vehicleNo' | 'remarks'>>({
+  quantity: 0,
+  vehicleNo: '',
+  remarks: ''
+})
+
+const qrDialogOpen = ref(false)
+const qrInventory = ref<InventoryRecord | null>(null)
+const qrCaptureRef = ref<HTMLElement | null>(null)
+
+// 生成图片时的全屏遮罩
+const copyingImage = ref(false)
+
+// 列表复制使用的隐藏截图区域
+const captureInventory = ref<InventoryRecord | null>(null)
+const captureRef = ref<HTMLElement | null>(null)
+
+const getQrImageUrl = (inventoryId: number) =>
+  `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
+    String(inventoryId)
+  )}`
+
+const qrImageSrc = computed(() => {
+  if (!qrInventory.value) return ''
+  return getQrImageUrl(qrInventory.value.id)
+})
+
+const loadPage = async () => {
+  const id = Number(route.params.id)
+  if (!id) {
+    errorMessage.value = '链接无效:缺少计划ID'
+    loading.value = false
+    return
+  }
+  loading.value = true
+  errorMessage.value = ''
+  try {
+    pageData.value = await planApi.getThirdPartyPage(id)
+  } catch (e: any) {
+    errorMessage.value = e?.response?.data?.detail || e?.message || '加载失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreateDialog = () => {
+  if (!pageData.value) return
+  createForm.quantity = 0
+  createForm.vehicleNo = ''
+  createForm.remarks = ''
+  createDialogOpen.value = true
+}
+
+const handleCreateSubmit = async () => {
+  if (!pageData.value) return
+  const qty = createForm.quantity
+  const vehicle = (createForm.vehicleNo || '').trim()
+  if (!qty || qty === 0 || !vehicle) {
+    showToast({ type: 'fail', message: '请填写数量和车牌号' })
+    return
+  }
+  creating.value = true
+  try {
+    const p = pageData.value.plan
+    await inventoryApi.create({
+      date: todayStr,
+      partnerCompany: p.customerName || '待填写',
+      businessType: p.businessType,
+      product: p.product,
+      quantity: Number(qty),
+      planNo: p.planNo,
+      vehicleNo: vehicle,
+      remarks: createForm.remarks || undefined,
+      purchasePlanId: p.id
+    })
+    showToast({ type: 'success', message: '创建成功' })
+    createDialogOpen.value = false
+    await loadPage()
+  } catch (e: any) {
+    showToast({ type: 'fail', message: e?.response?.data?.detail || e?.message || '创建失败' })
+  } finally {
+    creating.value = false
+  }
+}
+
+const showQr = (item: InventoryRecord) => {
+  qrInventory.value = item
+  qrDialogOpen.value = true
+}
+
+const handleCopyFromList = async (item: InventoryRecord) => {
+  captureInventory.value = item
+  try {
+    copyingImage.value = true
+    await nextTick()
+    const el = captureRef.value
+    if (!el) throw new Error('未找到截图区域')
+
+    const img = el.querySelector('.capture-qr-img') as HTMLImageElement | null
+    const waitForImg = () =>
+      new Promise<void>((resolve) => {
+        if (!img) {
+          resolve()
+          return
+        }
+        if (img.complete) resolve()
+        else img.onload = () => resolve()
+      })
+    await waitForImg()
+
+    const canvas = await html2canvas(el, {
+      useCORS: true,
+      scale: 2,
+      backgroundColor: '#f5f7fa'
+    })
+    const outBlob = await new Promise<Blob | null>((resolve) =>
+      canvas.toBlob((b) => resolve(b), 'image/png', 1)
+    )
+    if (outBlob) {
+      const ok = await copyImageBlobToClipboard(outBlob)
+      if (ok) {
+        showToast({ type: 'success', message: '图片已复制到剪贴板' })
+      } else {
+        showToast({ type: 'fail', message: '当前环境不支持复制图片,请在 HTTPS 环境下使用' })
+      }
+    } else {
+      showToast({ type: 'fail', message: '生成图片失败' })
+    }
+  } catch (e: any) {
+    showToast({ type: 'fail', message: e?.message || '复制失败' })
+  } finally {
+    captureInventory.value = null
+    copyingImage.value = false
+  }
+}
+
+
+onMounted(() => {
+  loadPage()
+})
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 24px;
+}
+
+.header {
+  background: #fff;
+  padding: 16px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid #e5e7eb;
+
+  &-left {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 1;
+  }
+
+  &-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #111827;
+  }
+}
+
+.loading-wrap {
+  padding: 48px;
+  display: flex;
+  justify-content: center;
+}
+
+.error-wrap {
+  padding: 40px 16px;
+}
+
+.content {
+  padding: 16px;
+}
+
+.card {
+  background: #fff;
+  border-radius: 16px;
+  padding: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  margin-bottom: 16px;
+}
+
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 12px;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+  font-size: 14px;
+
+  .label {
+    color: #6b7280;
+    min-width: 90px;
+  }
+
+  .value {
+    color: #111827;
+    flex: 1;
+  }
+}
+
+.list-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.inv-card {
+  border-radius: 12px;
+  border: 1px solid #e5e7eb;
+  padding: 12px;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.inv-main {
+  flex: 1;
+}
+
+.inv-line {
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  margin-bottom: 4px;
+
+  .label {
+    color: #6b7280;
+    min-width: 80px;
+  }
+
+  .value {
+    color: #111827;
+    flex: 1;
+  }
+}
+
+.inv-actions {
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.empty {
+  padding-top: 8px;
+}
+
+.create-dialog .dialog-body {
+  padding: 16px;
+}
+
+.form-item {
+  margin-bottom: 12px;
+
+  .form-label {
+    font-size: 14px;
+    color: #374151;
+    margin-bottom: 6px;
+  }
+
+  .form-value.readonly {
+    font-size: 14px;
+    color: #111827;
+    padding: 8px 12px;
+    border-radius: 8px;
+    background: #f9fafb;
+    border: 1px solid #e5e7eb;
+  }
+}
+
+.dialog-actions {
+  margin-top: 8px;
+}
+
+/* 查看二维码弹窗:与「出入库管理」磅单二维码风格一致 */
+.qr-dialog-body {
+  padding: 16px;
+  background: #f5f7fa;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+.qr-dialog-body .capture-header {
+  font-size: 18px;
+  font-weight: 600;
+  color: #111827;
+  padding: 16px;
+  background: #fff;
+  text-align: center;
+  border-radius: 12px;
+  margin-bottom: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.qr-dialog-body .capture-card {
+  background: #fff;
+  border-radius: 12px;
+  margin-bottom: 16px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.qr-dialog-body .capture-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 12px;
+}
+
+.qr-dialog-body .capture-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+  font-size: 14px;
+}
+
+.qr-dialog-body .capture-row .label {
+  color: #6b7280;
+  min-width: 90px;
+}
+
+.qr-dialog-body .capture-row .value {
+  color: #111827;
+  flex: 1;
+}
+
+.qr-dialog-body .capture-row .value.vehicle-no {
+  font-weight: 700;
+  color: #ef4444;
+}
+
+.qr-dialog-body .capture-qrcode {
+  text-align: center;
+}
+
+.qr-dialog-body .capture-id {
+  font-size: 20px;
+  font-weight: 700;
+  color: #111827;
+  margin-bottom: 12px;
+}
+
+.qr-dialog-body .capture-qr-wrap {
+  padding: 16px 0;
+  min-height: 200px;
+}
+
+.qr-dialog-body .capture-qr-img {
+  width: 200px;
+  height: 200px;
+  display: inline-block;
+}
+
+.qr-dialog-body .capture-desc {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-top: 8px;
+}
+
+/* 列表复制使用的隐藏截图区域(与弹窗样式一致,置于屏外) */
+.pound-slip-capture {
+  position: fixed;
+  left: -9999px;
+  top: 0;
+  width: 360px;
+  background: #f5f7fa;
+  padding: 16px;
+  font-family: inherit;
+}
+
+.pound-slip-capture .capture-header {
+  font-size: 18px;
+  font-weight: 600;
+  color: #111827;
+  padding: 16px;
+  background: #fff;
+  text-align: center;
+  border-radius: 12px;
+  margin-bottom: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.pound-slip-capture .capture-card {
+  background: #fff;
+  border-radius: 12px;
+  margin-bottom: 16px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.pound-slip-capture .capture-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 12px;
+}
+
+.pound-slip-capture .capture-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+  font-size: 14px;
+}
+
+.pound-slip-capture .capture-row .label {
+  color: #6b7280;
+  min-width: 90px;
+}
+
+.pound-slip-capture .capture-row .value {
+  color: #111827;
+  flex: 1;
+}
+
+.pound-slip-capture .capture-row .value.vehicle-no {
+  font-weight: 700;
+  color: #ef4444;
+}
+
+.pound-slip-capture .capture-qrcode {
+  text-align: center;
+}
+
+.pound-slip-capture .capture-id {
+  font-size: 20px;
+  font-weight: 700;
+  color: #111827;
+  margin-bottom: 12px;
+}
+
+.pound-slip-capture .capture-qr-wrap {
+  padding: 16px 0;
+  min-height: 200px;
+}
+
+.pound-slip-capture .capture-qr-img {
+  width: 200px;
+  height: 200px;
+  display: inline-block;
+}
+
+.pound-slip-capture .capture-desc {
+  font-size: 15px;
+  font-weight: 600;
+  color: #374151;
+  margin-top: 8px;
+}
+
+/* 全屏复制图片 Loading 遮罩 */
+.copy-overlay {
+  z-index: 2000;
+}
+
+.copy-overlay__content {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  color: #fff;
+}
+
+.copy-overlay__text {
+  font-size: 14px;
+}
+</style>
+

+ 139 - 3
mobile-h5/src/views/master-data/index.vue

@@ -55,6 +55,53 @@
         </van-list>
       </van-tab>
 
+      <!-- 车牌号 -->
+      <van-tab title="车牌号" name="vehicle">
+        <div class="toolbar">
+          <van-search
+            v-model="vehicleSearchKeyword"
+            placeholder="搜索车牌号 / 备注"
+            @search="handleVehicleSearch"
+            @clear="handleVehicleSearch"
+            class="search-input"
+          />
+          <van-button type="primary" size="small" class="search-button" @click="handleVehicleSearch">搜索</van-button>
+        </div>
+
+        <van-list
+          v-model:loading="vehicleLoading"
+          :finished="vehicleFinished"
+          finished-text="没有更多了"
+          @load="onVehicleLoad"
+        >
+          <div v-if="!vehicleLoading && vehicleDisplayList.length === 0" class="empty">
+            <van-empty description="暂无数据" />
+          </div>
+          <div v-else class="list">
+            <div
+              v-for="item in vehicleDisplayList"
+              :key="item.id"
+              class="card"
+            >
+              <div class="card-body">
+                <div class="info-row">
+                  <span class="label">车牌号:</span>
+                  <span class="value">{{ item.vehicleNo }}</span>
+                </div>
+                <div class="info-row" v-if="item.remarks">
+                  <span class="label">备注:</span>
+                  <span class="value">{{ item.remarks }}</span>
+                </div>
+              </div>
+              <div class="card-footer">
+                <van-button size="small" type="primary" plain @click="handleVehicleEdit(item)">编辑</van-button>
+                <van-button size="small" type="danger" plain @click="handleVehicleDelete(item.id)">删除</van-button>
+              </div>
+            </div>
+          </div>
+        </van-list>
+      </van-tab>
+
       <!-- 货物 -->
       <van-tab title="货物" name="cargo">
         <div class="toolbar">
@@ -104,7 +151,7 @@
     </van-tabs>
 
     <!-- 浮动新增按钮 -->
-    <div class="fab-button" @click="activeTab === 'company' ? handleCompanyAdd() : handleCargoAdd()">
+    <div class="fab-button" @click="handleFabAdd">
       <van-icon name="plus" />
     </div>
 
@@ -115,7 +162,7 @@
 import { ref } from 'vue'
 import { useRouter } from 'vue-router'
 import { showToast, showDialog } from 'vant'
-import { companyApi, cargoApi, type Company, type Cargo } from '@/api/masterDataApi'
+import { companyApi, cargoApi, vehicleApi, type Company, type Cargo, type Vehicle } from '@/api/masterDataApi'
 
 const router = useRouter()
 
@@ -139,6 +186,15 @@ const cargoAllData = ref<Cargo[]>([])
 const cargoCurrentPage = ref(1)
 const cargoPageSize = ref(20)
 
+const vehicleLoading = ref(false)
+const vehicleFinished = ref(false)
+const vehicleSearchKeyword = ref('')
+const vehicleAllData = ref<Vehicle[]>([])
+const vehicleCurrentPage = ref(1)
+const vehiclePageSize = ref(20)
+
+const vehicleDisplayList = ref<Vehicle[]>([])
+
 const loadCompanyData = async (reset = false) => {
   if (reset) {
     companyCurrentPage.value = 1
@@ -227,11 +283,57 @@ const onCargoLoad = () => {
   }
 }
 
+const loadVehicleData = async (reset = false) => {
+  if (reset) {
+    vehicleCurrentPage.value = 1
+    vehicleAllData.value = []
+    vehicleDisplayList.value = []
+    vehicleFinished.value = false
+  }
+
+  vehicleLoading.value = true
+  try {
+    if (reset || vehicleCurrentPage.value === 1) {
+      const vehicles = await vehicleApi.list(vehicleSearchKeyword.value || undefined)
+      vehicleAllData.value = vehicles
+    }
+
+    const start = (vehicleCurrentPage.value - 1) * vehiclePageSize.value
+    const end = start + vehiclePageSize.value
+    const newData = vehicleAllData.value.slice(start, end)
+
+    if (reset || vehicleCurrentPage.value === 1) {
+      vehicleDisplayList.value = newData
+    } else {
+      vehicleDisplayList.value.push(...newData)
+    }
+
+    if (vehicleDisplayList.value.length >= vehicleAllData.value.length) {
+      vehicleFinished.value = true
+    } else {
+      vehicleCurrentPage.value++
+    }
+  } catch (error) {
+    showToast({ type: 'fail', message: '加载数据失败' })
+    vehicleFinished.value = true
+  } finally {
+    vehicleLoading.value = false
+  }
+}
+
+const onVehicleLoad = () => {
+  if (!vehicleFinished.value) {
+    loadVehicleData()
+  }
+}
+
 const handleTabChange = (name: string) => {
   if (name === 'company') {
     loadCompanyData(true)
-  } else {
+  } else if (name === 'cargo') {
     loadCargoData(true)
+  } else {
+    loadVehicleData(true)
   }
 }
 
@@ -243,6 +345,16 @@ const handleCargoSearch = () => {
   loadCargoData(true) // 重置并重新加载
 }
 
+const handleVehicleSearch = () => {
+  loadVehicleData(true)
+}
+
+const handleFabAdd = () => {
+  if (activeTab.value === 'company') handleCompanyAdd()
+  else if (activeTab.value === 'cargo') handleCargoAdd()
+  else handleVehicleAdd()
+}
+
 const handleBack = () => {
   router.push('/dashboard')
 }
@@ -295,6 +407,30 @@ const handleCargoDelete = (id: number) => {
     .catch(() => {})
 }
 
+const handleVehicleAdd = () => {
+  router.push('/master-data/vehicle/detail')
+}
+
+const handleVehicleEdit = (item: Vehicle) => {
+  router.push(`/master-data/vehicle/detail/${item.id}?mode=edit`)
+}
+
+const handleVehicleDelete = (id: number) => {
+  showDialog({
+    title: '确认删除',
+    message: '确定要删除该车牌号吗?',
+    showCancelButton: true,
+    cancelButtonText: '取消',
+    confirmButtonText: '确认'
+  })
+    .then(async () => {
+      await vehicleApi.delete(id)
+      showToast({ type: 'success', message: '删除成功' })
+      loadVehicleData(true)
+    })
+    .catch(() => {})
+}
+
 </script>
 
 <style lang="scss" scoped>

+ 127 - 0
mobile-h5/src/views/master-data/vehicle-detail.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="page-content">
+    <div class="page-header">
+      <div class="page-header__back" @click="handleBack">
+        <div class="back-arrow"></div>
+      </div>
+      <div class="page-header__title">{{ isEdit ? '编辑车牌号' : isView ? '查看车牌号' : '新增车牌号' }}</div>
+    </div>
+
+    <div class="form-container">
+      <van-form @submit="handleSubmit">
+        <div class="form-section">
+          <div class="form-item">
+            <div class="form-label">车牌号 <span class="required">*</span></div>
+            <div v-if="isView" class="form-input">
+              {{ formData.vehicleNo || '--' }}
+            </div>
+            <van-field
+              v-else
+              v-model="formData.vehicleNo"
+              placeholder="请输入车牌号"
+            />
+          </div>
+
+          <div class="form-item">
+            <div class="form-label">备注</div>
+            <div v-if="isView" class="form-input">
+              {{ formData.remarks || '--' }}
+            </div>
+            <van-field
+              v-else
+              :model-value="formData.remarks || ''"
+              @update:model-value="formData.remarks = $event || null"
+              type="textarea"
+              autosize
+              rows="3"
+              placeholder="请输入备注"
+            />
+          </div>
+        </div>
+
+        <div v-if="!isView" class="form-actions">
+          <van-button block type="primary" native-type="submit" :loading="saving">
+            保存
+          </van-button>
+          <van-button block plain @click="handleBack">
+            取消
+          </van-button>
+        </div>
+      </van-form>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { showToast } from 'vant'
+import { vehicleApi, type Vehicle, type VehicleCreate } from '@/api/masterDataApi'
+
+const router = useRouter()
+const route = useRoute()
+
+const isView = computed(() => route.query.mode === 'view')
+const isEdit = computed(() => route.query.mode === 'edit' || !!route.params.id)
+
+const saving = ref(false)
+const editingVehicle = ref<Vehicle | null>(null)
+
+const formData = reactive<VehicleCreate>({
+  vehicleNo: '',
+  remarks: null
+})
+
+const loadData = async () => {
+  if (isEdit.value && route.params.id) {
+    try {
+      const vehicle = await vehicleApi.get(Number(route.params.id))
+      editingVehicle.value = vehicle
+      Object.assign(formData, {
+        vehicleNo: vehicle.vehicleNo,
+        remarks: vehicle.remarks || ''
+      })
+    } catch (error) {
+      showToast({ type: 'fail', message: '加载数据失败' })
+      router.back()
+    }
+  }
+}
+
+const handleSubmit = async () => {
+  if (!formData.vehicleNo?.trim()) {
+    showToast({ type: 'fail', message: '请填写车牌号' })
+    return
+  }
+
+  saving.value = true
+  try {
+    if (editingVehicle.value) {
+      await vehicleApi.update(editingVehicle.value.id, formData)
+      showToast({ type: 'success', message: '更新成功' })
+    } else {
+      await vehicleApi.create(formData)
+      showToast({ type: 'success', message: '创建成功' })
+    }
+    router.back()
+  } catch (error: any) {
+    showToast({ type: 'fail', message: error.message || '操作失败' })
+  } finally {
+    saving.value = false
+  }
+}
+
+const handleBack = () => {
+  router.back()
+}
+
+onMounted(() => {
+  if (isEdit.value || isView.value) {
+    loadData()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+@import '@/assets/styles/detail-page.scss';
+</style>

+ 17 - 9
mobile-h5/src/views/plans/index.vue

@@ -242,11 +242,12 @@ const allData = ref<Plan[]>([]) // 存储所有数据
 const searchKeyword = ref('')
 const currentPage = ref(1)
 const pageSize = ref(20)
-const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
 
 const dateFilterOptions = [
   { label: '全部', value: 'all' },
   { label: '今日', value: 'today' },
+  { label: '明日', value: 'tomorrow' },
   { label: '昨日', value: 'yesterday' },
   { label: '本周', value: 'week' }
 ] as const
@@ -343,6 +344,10 @@ const loadData = async (reset = false) => {
         const d = today.format('YYYY-MM-DD')
         params.createdFrom = d
         params.createdTo = d
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = today.add(1, 'day').format('YYYY-MM-DD')
+        params.createdFrom = t
+        params.createdTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = today.subtract(1, 'day').format('YYYY-MM-DD')
         params.createdFrom = y
@@ -449,17 +454,20 @@ const handleDelete = (id: number) => {
 }
 
 const handleGenerateInventory = (record: Plan) => {
-  router.push(
-    `/inventory?planNo=${encodeURIComponent(record.planNo || '')}&product=${encodeURIComponent(
-      record.product || ''
-    )}&businessType=${encodeURIComponent(record.businessType || '')}&customerName=${encodeURIComponent(
-      record.customerName || ''
-    )}&purchasePlanId=${record.id}&from=plans`
-  )
+  router.push({
+    path: '/inventory',
+    query: {
+      planNo: record.planNo || '',
+      product: record.product || '',
+      businessType: record.businessType || '',
+      customerName: record.customerName || '',
+      purchasePlanId: String(record.id),
+      from: 'plan'
+    }
+  })
   showToast({ type: 'success', message: '已跳转到出入库页面' })
 }
 
-
 onMounted(() => {
   loadData(true) // 初始加载
 })

+ 29 - 3
mobile-h5/src/views/weighing/index.vue

@@ -95,7 +95,7 @@
             <van-button size="small" type="primary" plain @click.stop="handleViewRelated(item)">查看</van-button>
             <van-button size="small" type="primary" plain @click.stop="handleEdit(item)">编辑</van-button>
             <van-button
-              v-if="item.isVoided === 0"
+              v-if="item.isVoided === 0 && isWeighingRecordToday(item)"
               size="small"
               type="warning"
               plain
@@ -274,11 +274,12 @@ const allData = ref<WeighingRecord[]>([]) // 存储所有数据
 const searchKeyword = ref('')
 const currentPage = ref(1)
 const pageSize = ref(20)
-const dateFilterType = ref<'all' | 'today' | 'yesterday' | 'week'>('all')
+const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
 
 const dateFilterOptions = [
   { label: '全部', value: 'all' },
   { label: '今日', value: 'today' },
+  { label: '明日', value: 'tomorrow' },
   { label: '昨日', value: 'yesterday' },
   { label: '本周', value: 'week' }
 ] as const
@@ -311,6 +312,10 @@ const loadData = async (reset = false) => {
         const d = today.format('YYYY-MM-DD')
         params.dateFrom = d
         params.dateTo = d
+      } else if (dateFilterType.value === 'tomorrow') {
+        const t = today.add(1, 'day').format('YYYY-MM-DD')
+        params.dateFrom = t
+        params.dateTo = t
       } else if (dateFilterType.value === 'yesterday') {
         const y = today.subtract(1, 'day').format('YYYY-MM-DD')
         params.dateFrom = y
@@ -325,7 +330,13 @@ const loadData = async (reset = false) => {
       }
 
       const records = await weighingApi.list(params)
-      allData.value = records
+      // 按日期倒序排列(日期取 inventoryDate | 毛重日期 | 创建日期)
+      const getRecordDate = (r: WeighingRecord) =>
+        r.inventoryDate ||
+        (r.grossWeightTime ? r.grossWeightTime.split(' ')[0] : '') ||
+        (r.createdAt ? r.createdAt.split(' ')[0] : '') ||
+        ''
+      allData.value = records.slice().sort((a, b) => getRecordDate(b).localeCompare(getRecordDate(a)))
     }
 
     // 分页切片
@@ -402,6 +413,21 @@ const handleEdit = (record: WeighingRecord) => {
   router.push(`/weighing/detail/${record.id}?mode=edit`)
 }
 
+/** 获取地磅单记录日期(inventoryDate | 毛重日期 | 创建日期) */
+const getWeighingRecordDate = (item: WeighingRecord) => {
+  return (
+    item.inventoryDate ||
+    (item.grossWeightTime ? item.grossWeightTime.split(' ')[0] : '') ||
+    (item.createdAt ? item.createdAt.split(' ')[0] : '') ||
+    ''
+  )
+}
+
+/** 判断是否为当天记录(仅当天可作废) */
+const isWeighingRecordToday = (item: WeighingRecord) => {
+  const dateStr = getWeighingRecordDate(item)
+  return dateStr === dayjs().format('YYYY-MM-DD')
+}
 
 const handleVoid = (id: number) => {
   showDialog({

+ 1 - 1
mobile-h5/vite.config.ts

@@ -60,7 +60,7 @@ export default defineConfig(({ mode }) => {
                 if (pathParts.length === 3 && pathParts[0] === 'api' && pathParts[1] === 'master-data') {
                   const resource = pathParts[2]
                   // 基础资料模块的列表接口:companies, cargos
-                  const masterDataResources = ['companies', 'cargos']
+                  const masterDataResources = ['companies', 'cargos', 'vehicles']
                   if (masterDataResources.includes(resource)) {
                     const newPath = path + '/' + (query ? '?' + query : '')
                     proxyReq.path = newPath