This commit is contained in:
2026-04-24 00:37:24 +08:00
parent 1b1ec7f64d
commit 5693472f64
11 changed files with 101 additions and 164 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"version": 2,
"sessions": {
"ebf5e3bab92e4319aec2c39116541728": [
{
"expertId": "BackendArchitect",
"name": "磐石石",
"profession": "后端架构师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/BackendArchitect/BackendArchitect.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/BackendArchitect/BackendArchitect_zh.md",
"usedAt": 1776327849643,
"industryId": "all"
}
]
},
"lastUpdated": 1776336333635
}
-76
View File
@@ -1,76 +0,0 @@
# 2026-04-16 工作日志
## OPS2 系统工作流程分析
完成了对 OPS2 流程管理系统三端架构的全面分析,生成了完整的工作流程分析报告(ops2_workflow_analysis.md)。
### 系统技术栈
- 后端:Go + Gin + GORM,支持 SQLite/MySQL/PostgreSQL 可配置切换
- PC前端:Vue3 + Vite + Pinia + Axios
- 移动端:uni-appVue3+Vite),目标 H5 + Android
### 核心功能模块
1. **用户认证**Cookie-based 认证,MD5+salt 密码哈希,支持 Remember Me
2. **日程管理**:日历事件 CRUD,权限分层(创建者/模块管理员/全局管理员)
3. **采购订单**6种状态流转(pending→ordered→arrived→received/lost/returned),含完整审计日志
4. **文件系统**SHA256 内容去重,MIME 白名单校验,图片上传服务
---
## 移动端重构(对标 PC 前端)
完成了 ops_uniapp 移动端的全面重构,参考 PC 前端架构对齐。
### 新增/重构文件
- `api/request.js`:统一请求封装(自动注入 cookie、统一 err_code 解析、cookie 过期自动跳登录)
- `api/auth.js``api/schedule.js``api/purchase.js`:对标 PC 端 API 模块
- `store/user.js`:轻量单例 Store(对标 PC 端 Pinia useUserStore,含 restoreSession
- `pages/signin.vue`:重构登录页(表单验证、密码显示/隐藏、remember me
- `pages/index/index.vue`:重构首页(今日日程卡片 + 待处理采购统计)
- `pages/setting/my_info.vue`:重构设置页(头像上传、信息修改、退出登录)
- `pages.json`:修正 API 路径前缀(/api/v1 → /api),更新页面路由
- `App.vue`:接入 userStore.restoreSession()
### 关键设计
- API base 统一为 `/api`(修复旧版 /api/v1 错误)
- cookie 认证流程与 PC 端完全对齐
- store/user.js 用模块单例替代 Piniauni-app 兼容)
---
## 补建预留路由对应的 Vue 文件
修复了 `vite:import-analysis` 报错(pages.json 声明了路由但文件不存在)。
### 新建文件
- `pages/schedule/schedule.vue`:移动端日程月份视图,支持按月浏览、添加/编辑/删除日程、颜色分类,30s 轮询刷新
- `pages/purchase/list.vue`:移动端采购订单列表,支持搜索/状态过滤/分页加载更多;底部抽屉查看订单详情(费用明细/图片/变更记录);已登录可变更订单状态
### 重要发现
- 移动端 `my_network_func.js``head_path = "/api/v1"` 与后端实际 `/api` 路径不匹配,需修复
- 审计日志系统完善(TabScheduleLog、TabPurchaseLog、TabPurchaseCommit
- 后端同时托管静态前端产物,单体部署架构
---
## @api 命名空间重构(修复 404 模块请求)
### 问题
`GET http://localhost:5173/api/auth.js net::ERR_ABORTED 404`
manifest.json 配置了 `/api` 代理到后端 8080,导致所有 `/api` 开头的请求都被代理。
即使加了 vite.config.js 的 resolve.aliasHBuilderX 的内置 dev server 行为也不完全符合预期。
### 解决
将 API 模块目录从 `api/` 重命名为 `@api/`,彻底消除与 `/api` 代理前缀的冲突。
- `api/``@api/`,所有 import 路径相应更新
- 真正的后端 API 请求走 `/api`(代理到 8080
- `@api/*.js` 模块不走 `/api` 代理,不会冲突
- `manifest.json` 的 h5.devServer 只保留 disableHostCheckproxy 移至 vite.config.js
### 更新文件
- `@api/request.js``@api/auth.js``@api/schedule.js``@api/purchase.js`(新建)
- `store/user.js``pages/signin.vue``pages/setting/my_info.vue``pages/index/index.vue``pages/schedule/schedule.vue``pages/purchase/list.vue`(更新 import 路径)
- `vite.config.js`(简化 alias 配置)
- 删除旧 `api/` 目录
+10 -28
View File
@@ -1,31 +1,13 @@
# 2026-04-23 日志
# 2026-04-23
## 物品详情页编辑弹窗增加图片管理功能
- 仓库容器详情页(WarehouseContainerDetail.vue):物品列表表格在序列号列后新增备注列,显示 `item.Remark` 字段,colspan 从 6 改为 7
- 仓库总览页(WarehouseOverview.vue):物品列表表格同样在序列号列后新增备注列,显示 `item.Remark` 字段,colspan 从 6 改为 7
- 工单详情页(ShowWorkOrder.vue):卡头时间显示从仅"创建时间"改为"创建时间 + 更新时间",使用 `order.UpdatedAt`,中英文 i18n 新增 `work_order.updated_at`
**涉及文件:**
- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue` — 编辑弹窗增加 `useDropzone` 组件,支持加载已有图片、上传新图片、删除图片
- `frontend/ops_vue_js/src/components/useDropzone.vue` — 导出 `loadInitialFiles` 方法供外部调用
# 2026-04-24
**实现方式:**
- 编辑弹窗中新增 `editDropzoneRef` ref,绑定 `useDropzone` 组件
- `openEdit()` 时调用 `loadInitialFiles()` 刷新初始文件
- `submitEdit()` 时从 dropzone 获取所有图片哈希(包含新上传和已存在的),一并传给 `updateItem` API
- 后端 `update_item` API 已支持 `photos` 字段,会重建图片绑定
**关键代码片段:**
```javascript
// 提交时获取所有图片哈希
const photos = getEditPhotoHashes()
warehouseApi.updateItem({ id, name, serial_number, remark, quantity, photos })
```
## 物品编辑改为独立页面
**涉及文件:**
- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemEdit.vue` — 新建,物品编辑独立页面
- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue` — 编辑按钮改为 `router.push('/warehouse/item/edit/:id')`,移除弹窗代码
- `frontend/ops_vue_js/src/router/index.js` — 新增 `/warehouse/item/edit/:id` 路由
**实现方式:**
- 创建 `WarehouseItemEdit.vue``onMounted` 获取物品数据(包含已有图片),通过 `setTimeout` 调用 `loadInitialFiles()` 加载到 dropzone
- 详情页编辑按钮改为跳转,移除弹窗及相关 state/function
- 后端 apiWarehouse.goTabWarehouseContainer 和 TabWarehouseItem 的 CreatedAt/UpdatedAt 从 string 改为 *time.Time,加 gorm autoCreateTime/autoUpdateTime tag,清理了所有手动设置时间的代码(6 处),所有字段补齐 json tag
- 工单列表页(WorkOrderList.vue):创建时间列后新增更新时间列,显示 `order.UpdatedAt`colspan 从 4 改为 5
- 仓库总览页(WarehouseOverview.vue):容器列表和物品列表均新增更新时间列(创建时间后),colspan 从 7 改为 8,中英文 i18n 新增 `warehouse.updated_at`
- 容器详情页(WarehouseContainerDetail.vue):卡头元信息、子容器列表、物品列表三处均新增更新日期列/显示
- 物品详情页(WarehouseItemDetail.vue):卡头新增更新日期显示;修复移动物品弹窗中目标容器搜索下拉框闪退问题(@mousedown.prevent + blur 延迟关闭)
+21 -20
View File
@@ -13,25 +13,27 @@ import (
// ---------- 数据表结构 ----------
type TabWarehouseContainer struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:255;not null;comment:容器名"`
Remark string `gorm:"type:text;comment:描述"`
CreatedAt string `gorm:"size:20;comment:创建日期"`
CreatorID uint `gorm:"not null;index;comment:创建者id"`
ParentID *uint `gorm:"index;comment:父容器idnil=顶级"`
ItemCount int `gorm:"default:0;comment:直接子物品数量"`
ChildCount int `gorm:"default:0;comment:子容器数量"`
ID uint `gorm:"primaryKey" json:"ID"`
Title string `gorm:"size:255;not null;comment:容器名" json:"Title"`
Remark string `gorm:"type:text;comment:描述" json:"Remark"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"CreatedAt"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime" json:"UpdatedAt"`
CreatorID uint `gorm:"not null;index;comment:创建者id" json:"CreatorID"`
ParentID *uint `gorm:"index;comment:父容器idnil=顶级" json:"ParentID"`
ItemCount int `gorm:"default:0;comment:直接子物品数量" json:"ItemCount"`
ChildCount int `gorm:"default:0;comment:子容器数量" json:"ChildCount"`
}
type TabWarehouseItem struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:255;not null;comment:物品名"`
SerialNumber string `gorm:"size:255;comment:序列号"`
Remark string `gorm:"type:text;comment:描述"`
Quantity int `gorm:"default:1;comment:数量"`
CreatedAt string `gorm:"size:20;comment:创建日期"`
CreatorID uint `gorm:"not null;index;comment:创建者id"`
ContainerID *uint `gorm:"index;comment:所属容器idnil=未入库"`
ID uint `gorm:"primaryKey" json:"ID"`
Name string `gorm:"size:255;not null;comment:物品名" json:"Name"`
SerialNumber string `gorm:"size:255;comment:序列号" json:"SerialNumber"`
Remark string `gorm:"type:text;comment:描述" json:"Remark"`
Quantity int `gorm:"default:1;comment:数量" json:"Quantity"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"CreatedAt"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime" json:"UpdatedAt"`
CreatorID uint `gorm:"not null;index;comment:创建者id" json:"CreatorID"`
ContainerID *uint `gorm:"index;comment:所属容器idnil=未入库" json:"ContainerID"`
}
type TabWarehouseContainerFileBind struct {
@@ -192,7 +194,6 @@ func ApiWarehouse(r *gin.RouterGroup) {
c := TabWarehouseContainer{
Title: from.Title,
Remark: from.Remark,
CreatedAt: strconv.FormatInt(time.Now().Unix(), 10),
CreatorID: user.ID,
ParentID: from.ParentID,
}
@@ -368,6 +369,7 @@ func ApiWarehouse(r *gin.RouterGroup) {
type FromList struct {
Search string `json:"search"`
ParentID *uint `json:"parent_id"`
AllLevels bool `json:"all_levels"`
Entries int `json:"entries"`
Page int `json:"page"`
}
@@ -390,8 +392,8 @@ func ApiWarehouse(r *gin.RouterGroup) {
}
if from.ParentID != nil {
query = query.Where("parent_id = ?", *from.ParentID)
} else if from.Search == "" {
// 无搜索时默认只显示顶级容器
} else if from.Search == "" && !from.AllLevels {
// 无搜索时默认只显示顶级容器all_levels=true 时返回所有层级)
query = query.Where("parent_id IS NULL")
}
query.Count(&count)
@@ -568,7 +570,6 @@ func ApiWarehouse(r *gin.RouterGroup) {
SerialNumber: from.SerialNumber,
Remark: from.Remark,
Quantity: quantity,
CreatedAt: strconv.FormatInt(time.Now().Unix(), 10),
CreatorID: user.ID,
ContainerID: from.ContainerID,
}
+2
View File
@@ -131,6 +131,7 @@
"no_photos": "No photos",
"status": "Status",
"created_at": "Created At",
"updated_at": "Updated At",
"filter_all": "All",
"status_pending": "Pending",
"status_checked": "Checked",
@@ -171,6 +172,7 @@
"remark": "Remark",
"remark_placeholder": "Enter remark (optional)",
"created_at": "Created At",
"updated_at": "Updated At",
"created_by": "Created By",
"child_containers": "Sub-Containers",
"items": "Items",
+2
View File
@@ -131,6 +131,7 @@
"no_photos": "暂无图片",
"status": "状态",
"created_at": "创建时间",
"updated_at": "更新时间",
"filter_all": "全部",
"status_pending": "待处理",
"status_checked": "已检查",
@@ -171,6 +172,7 @@
"remark": "备注",
"remark_placeholder": "输入备注(可选)",
"created_at": "创建日期",
"updated_at": "更新日期",
"created_by": "创建人",
"child_containers": "子容器",
"items": "物品",
@@ -390,6 +390,7 @@ onMounted(async () => {
{{ usersStore.getUsernameFromUserID(container.CreatorID) }}
</span>
<span>{{ t('warehouse.created_at') }}: {{ fmtTs(container.CreatedAt) }}</span>
<span v-if="container.UpdatedAt">{{ t('warehouse.updated_at') }}: {{ fmtTs(container.UpdatedAt) }}</span>
<span>{{ t('warehouse.child_containers') }}: {{ container.ChildCount }}</span>
<span>{{ t('warehouse.items') }}: {{ container.ItemCount }}</span>
</div>
@@ -428,13 +429,14 @@ onMounted(async () => {
<th class="px-5 py-3 font-medium w-24 text-center">{{ t('warehouse.child_containers') }}</th>
<th class="px-5 py-3 font-medium w-24 text-center">{{ t('warehouse.items') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.updated_at') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
<th class="px-5 py-3 font-medium w-24 text-right">{{ t('warehouse.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingSub">
<td colspan="6" class="px-5 py-8 text-center">
<td colspan="7" class="px-5 py-8 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -442,7 +444,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-else-if="subContainers.length === 0">
<td colspan="6" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="7" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_containers') }}
</td>
</tr>
@@ -470,6 +472,7 @@ onMounted(async () => {
</span>
</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(c.CreatedAt) }}</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(c.UpdatedAt) }}</td>
<td class="px-5 py-3">
<div class="flex items-center gap-1.5">
<img
@@ -552,15 +555,17 @@ onMounted(async () => {
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400">
<th class="px-5 py-3 font-medium">{{ t('warehouse.item_name') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.serial_number') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.remark') }}</th>
<th class="px-5 py-3 font-medium w-20 text-center">{{ t('warehouse.quantity') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.updated_at') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
<th class="px-5 py-3 font-medium w-20 text-right">{{ t('warehouse.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingItems">
<td colspan="6" class="px-5 py-8 text-center">
<td colspan="8" class="px-5 py-8 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -568,7 +573,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-else-if="items.length === 0">
<td colspan="6" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="8" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_items') }}
</td>
</tr>
@@ -586,8 +591,10 @@ onMounted(async () => {
</div>
</td>
<td class="px-5 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-[140px] truncate">{{ item.serial_number || '—' }}</td>
<td class="px-5 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate">{{ item.Remark || '—' }}</td>
<td class="px-5 py-3 text-center text-sm">{{ item.Quantity }}</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(item.CreatedAt) }}</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(item.UpdatedAt) }}</td>
<td class="px-5 py-3">
<div class="flex items-center gap-1.5">
<img
@@ -52,6 +52,7 @@ const targetContainers = ref([])
const targetSearch = ref('')
const targetLoading = ref(false)
const showTargetDropdown = ref(false)
let targetDropdownTimer = null
// ── 删除确认 ──
const showDeleteConfirm = ref(false)
@@ -186,18 +187,39 @@ async function openMove() {
showMove.value = true
}
function onTargetFocus() {
if (targetDropdownTimer) clearTimeout(targetDropdownTimer)
showTargetDropdown.value = true
loadTargetContainers(targetSearch.value)
}
function onTargetInput() {
if (targetDropdownTimer) clearTimeout(targetDropdownTimer)
showTargetDropdown.value = true
loadTargetContainers(targetSearch.value)
}
function closeTargetDropdown() {
targetDropdownTimer = setTimeout(() => {
showTargetDropdown.value = false
}, 150)
}
async function loadTargetContainers(search = '') {
targetLoading.value = true
try {
const isSearch = search.trim().length > 0
const { errCode, data } = await warehouseApi.getContainers({
search,
entries: 50,
all_levels: true,
entries: isSearch ? 50 : 10,
page: 1,
})
if (errCode === 0) {
targetContainers.value = (data.containers ?? []).filter(
(c) => c.ID !== itemId.value && c.ID !== item.value?.ContainerID
const filtered = (data.containers ?? []).filter(
(c) => c.ID !== item.value?.ContainerID
)
targetContainers.value = isSearch ? filtered : filtered.slice(0, 5)
}
} catch {
targetContainers.value = []
@@ -389,6 +411,7 @@ onMounted(() => {
{{ usersStore.getUsernameFromUserID(item.CreatorID) }}
</span>
<span>{{ t('warehouse.created_at') }}: {{ fmtTs(item.CreatedAt) }}</span>
<span v-if="item.UpdatedAt">{{ t('warehouse.updated_at') }}: {{ fmtTs(item.UpdatedAt) }}</span>
</div>
</div>
@@ -503,19 +526,21 @@ onMounted(() => {
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('warehouse.target_container') }}</label>
<div class="relative">
<div class="relative" @click.stop>
<IconSearch class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" :size="15" />
<input
v-model="targetSearch"
type="text"
:placeholder="t('warehouse.search_container')"
class="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@focus="loadTargetContainers(''); showTargetDropdown = true"
@input="loadTargetContainers(targetSearch); showTargetDropdown = true"
@focus="onTargetFocus"
@input="onTargetInput"
@blur="closeTargetDropdown"
/>
<div
v-if="showTargetDropdown && (targetContainers.length > 0 || targetLoading)"
v-if="showTargetDropdown"
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card max-h-60 overflow-y-auto"
@mousedown.prevent
>
<div v-if="targetLoading" class="px-3 py-2 text-xs text-gray-400">
<svg class="inline h-3.5 w-3.5 animate-spin mr-1" viewBox="0 0 24 24" fill="none">
@@ -320,7 +320,7 @@ function formatDate(dateStr) {
d = new Date(dateStr)
}
if (isNaN(d.getTime())) return '—'
return d.toLocaleDateString(isEn.value ? 'en-US' : 'zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
return d.toLocaleString(isEn.value ? 'en-US' : 'zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
} catch { return dateStr }
}
@@ -426,12 +426,13 @@ onMounted(() => {
<th class="px-6 py-3 font-medium w-24 text-center">{{ t('warehouse.child_containers') }}</th>
<th class="px-6 py-3 font-medium w-24 text-center">{{ t('warehouse.items') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap w-44">{{ t('warehouse.created_at') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap w-44">{{ t('warehouse.updated_at') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="containerLoading">
<td colspan="7" class="px-6 py-8 text-center text-gray-400">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -440,7 +441,7 @@ onMounted(() => {
</td>
</tr>
<tr v-else-if="containers.length === 0">
<td colspan="7" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="8" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_containers') }}
</td>
</tr>
@@ -472,6 +473,7 @@ onMounted(() => {
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ fmtTs(c.CreatedAt) }}</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ fmtTs(c.UpdatedAt) }}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<img
@@ -548,15 +550,17 @@ onMounted(() => {
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400">
<th class="px-6 py-3 font-medium">{{ t('warehouse.item_name') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.serial_number') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.remark') }}</th>
<th class="px-6 py-3 font-medium w-20 text-center">{{ t('warehouse.quantity') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.location') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap">{{ t('warehouse.updated_at') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="itemLoading">
<td colspan="6" class="px-6 py-8 text-center text-gray-400">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -565,7 +569,7 @@ onMounted(() => {
</td>
</tr>
<tr v-else-if="items.length === 0">
<td colspan="6" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="8" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_items') }}
</td>
</tr>
@@ -577,6 +581,7 @@ onMounted(() => {
>
<td class="px-6 py-3 font-medium max-w-[200px] truncate">{{ item.Name }}</td>
<td class="px-6 py-3 max-w-[160px] truncate text-xs text-gray-500 dark:text-gray-400">{{ item.SerialNumber || '—' }}</td>
<td class="px-6 py-3 max-w-[200px] truncate text-xs text-gray-500 dark:text-gray-400">{{ item.Remark || '—' }}</td>
<td class="px-6 py-3 text-center text-sm">{{ item.Quantity }}</td>
<td class="px-6 py-3">
<span v-if="item.ContainerID != null" class="inline-flex items-center gap-1 text-sm text-blue-600">
@@ -588,6 +593,7 @@ onMounted(() => {
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500">{{ formatDate(item.CreatedAt) }}</td>
<td class="px-6 py-3 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500">{{ formatDate(item.UpdatedAt) }}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<img
@@ -393,7 +393,10 @@ onUnmounted(() => {
{{ usersStore.getUsernameFromUserID(order.UserID) }}
</span>
</div>
<span class="text-sm text-gray-400">{{ formatDate(order?.CreatedAt) }}</span>
<div class="text-sm text-gray-400 text-right">
<div>{{ t('work_order.created_at') }}: {{ formatDate(order?.CreatedAt) }}</div>
<div v-if="order?.UpdatedAt">{{ t('work_order.updated_at') }}: {{ formatDate(order.UpdatedAt) }}</div>
</div>
</div>
<!-- 状态快捷切换所有登录用户可见 -->
@@ -144,12 +144,13 @@ onMounted(fetchOrders)
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 w-16">No.</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('work_order.title') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap w-44">{{ t('work_order.created_at') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap w-44">{{ t('work_order.updated_at') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 w-36">{{ t('work_order.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="px-6 py-8 text-center text-gray-400">
<td colspan="5" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -158,7 +159,7 @@ onMounted(fetchOrders)
</td>
</tr>
<tr v-else-if="orders.length === 0">
<td colspan="4" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="5" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
暂无工单
</td>
</tr>
@@ -172,6 +173,7 @@ onMounted(fetchOrders)
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ order.ID }}</td>
<td class="px-6 py-3 font-medium text-gray-900 dark:text-white max-w-xs truncate">{{ order.Title }}</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ formatDate(order.CreatedAt) }}</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ formatDate(order.UpdatedAt) }}</td>
<td class="px-6 py-3">
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"