新增工单功能
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# 2026-04-23 工作记录
|
||||
|
||||
## 分析 OPS2 流程管理平台架构
|
||||
|
||||
完成了对项目前后端完整运行逻辑的分析:
|
||||
|
||||
- **项目性质**:内部流程管理平台,前后端同源部署(后端 Gin serve 前端 dist)
|
||||
- **后端**:Go 1.24 + Gin + GORM,支持 SQLite/MySQL/PostgreSQL 三选一
|
||||
- **前端**:Vue 3 + Vite + Pinia + Tailwind CSS,Hash History 路由
|
||||
- **认证**:自实现 Cookie Token(非 JWT),存于数据库,支持"记住我"滚动续期
|
||||
- **数据库**:13 张表,涵盖用户体系(4张)、认证日志(2张)、文件(1张)、日程(2张)、采购(5张,含软删除+操作日志双保险)
|
||||
- **核心功能**:日程日历(FullCalendar,公开可访问)+ 采购流程管理(完整状态流转)+ 用户系统
|
||||
- **权限设计**:基于用户组(schedule_admin / purchase_admin),内存缓存管理员 ID 列表
|
||||
- **文件系统**:SHA256 内容寻址,自动去重存储
|
||||
- **API 协议**:统一 `{ userCookieValue, data }` 请求体,统一 `{ err_code, err_msg, return }` 响应体
|
||||
|
||||
## 新增 work_order 工单模块
|
||||
|
||||
完整实现了工单模块,文件清单:
|
||||
|
||||
**后端(backend/my_work/routers/)**
|
||||
- `apiWorkOrder.go`:4张表(TabWorkOrder / TabWorkOrderFileBind / TabWorkOrderCommit / TabWorkOrderLog)+ 7个接口(add/update/list/get/commit/delete/count)+ work_order_admin 用户组
|
||||
- `api.go`:注册 `/api/work_order/*` 路由
|
||||
- `main.go`:注册 `ApiWorkOrderInit()`
|
||||
|
||||
**前端(frontend/ops_vue_js/src/)**
|
||||
- `api/work_order.js`:API 封装
|
||||
- `views/work_order/WorkOrderList.vue`:工单列表页(分页+状态筛选)
|
||||
- `views/work_order/AddEditWorkOrder.vue`:新增/编辑页(图片上传 useDropzone)
|
||||
- `views/work_order/ShowWorkOrder.vue`:详情页(进度时间线+新增进度表单)
|
||||
- `router/index.js`:注册4条路由(list/add/edit/:id/show/:id)
|
||||
- `components/AppHeader.vue`:导航菜单添加"工单"入口
|
||||
- `i18n/zh-CN.json` & `i18n/en.json`:添加 work_order 翻译节和 appname.work_order
|
||||
|
||||
**工单状态枚举**:pending/checked/parts_ordered/repaired/returned/unrepairable
|
||||
|
||||
**commit 图片支持**:
|
||||
- 后端:新增 `TabWorkOrderCommitFileBind` 表(commit_id/work_order_id/file_id),commit 接口接收 `photos` 数组,get 接口返回每条 commit 的 photos
|
||||
- 前端:ShowWorkOrder.vue 新增进度表单底部加了 useDropzone 组件,每条进度时间线下方展示关联图片
|
||||
- API:`commit(id, status, comment, photos)` 函数签名新增 photos 参数
|
||||
|
||||
**工单关联采购订单**:
|
||||
- 后端:新增 `TabWorkOrderPurchaseOrderBind` 表(work_order_id/commit_id/purchase_order_id),支持多对多关联
|
||||
- commit 接口新增 `purchaseOrderIds` 参数([]uint)
|
||||
- get 接口每条 commit 返回 `purchaseOrders` 数组(包含 id/title/status)
|
||||
- 新增 `/search_purchase_orders` 接口:空搜索返回最新5条,支持 ID 精确匹配和标题/备注模糊匹配
|
||||
- 前端:状态选 `parts_ordered` 时显示采购订单搜索框(防抖300ms),输入框获取焦点自动搜索
|
||||
- 时间线每条 commit 下方展示关联的采购订单链接(点击跳转到采购详情页)
|
||||
|
||||
@@ -70,6 +70,7 @@ func main() {
|
||||
routers.ApiFilesInit()
|
||||
routers.ApiScheduleInit()
|
||||
routers.ApiPurchaseInit()
|
||||
routers.ApiWorkOrderInit()
|
||||
|
||||
//创建必要目录
|
||||
for _, path := range models.ConfigsFile.Pahts {
|
||||
|
||||
@@ -55,6 +55,7 @@ func ApiRoot(r *gin.RouterGroup) {
|
||||
ApiFiles(r.Group("/files"))
|
||||
ApiPurchase(r.Group("/purchase"))
|
||||
ApiSchedule(r.Group("/schedule"))
|
||||
ApiWorkOrder(r.Group("/work_order"))
|
||||
r.GET("/", func(ctx *gin.Context) {
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"isOpsApiRoot":true,
|
||||
|
||||
@@ -3,6 +3,7 @@ package routers
|
||||
import (
|
||||
"encoding/json"
|
||||
"ops/models"
|
||||
parsefmt "fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -294,10 +295,6 @@ func ApiPurchase(r *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
oldStatus := order.OrderStatus
|
||||
if oldStatus == from.Status {
|
||||
ReturnJson(ctx, "status_no_change", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
updates := map[string]interface{}{
|
||||
@@ -359,38 +356,46 @@ func ApiPurchase(r *gin.RouterGroup) {
|
||||
//fmt.Println(user)
|
||||
// DebugPrintJson(data)
|
||||
|
||||
type From_purchase_getorders struct {
|
||||
Search string
|
||||
Status string
|
||||
Entries int
|
||||
Page int
|
||||
}
|
||||
|
||||
var jsondata From_purchase_getorders
|
||||
if err := decodeJSON(data, &jsondata); err == nil {
|
||||
//fmt.Println(jsondata)
|
||||
|
||||
is_data_ok := true
|
||||
|
||||
if jsondata.Entries <= 0 || jsondata.Entries > 300 {
|
||||
is_data_ok = false
|
||||
}
|
||||
if jsondata.Page <= 0 {
|
||||
is_data_ok = false
|
||||
type From_purchase_getorders struct {
|
||||
Search string
|
||||
Status string
|
||||
Entries int
|
||||
Page int
|
||||
}
|
||||
|
||||
if is_data_ok {
|
||||
var jsondata From_purchase_getorders
|
||||
if err := decodeJSON(data, &jsondata); err == nil {
|
||||
//fmt.Println(jsondata)
|
||||
|
||||
//读取有多少条目
|
||||
var count int64
|
||||
query := models.DB.Model(TabPurchaseOrder{})
|
||||
if jsondata.Search != "" {
|
||||
query = query.Where("title LIKE ?", "%"+jsondata.Search+"%")
|
||||
is_data_ok := true
|
||||
|
||||
if jsondata.Entries <= 0 || jsondata.Entries > 300 {
|
||||
is_data_ok = false
|
||||
}
|
||||
if jsondata.Status != "" {
|
||||
query = query.Where("order_status = ?", jsondata.Status)
|
||||
if jsondata.Page <= 0 {
|
||||
is_data_ok = false
|
||||
}
|
||||
query.Count(&count)
|
||||
|
||||
if is_data_ok {
|
||||
|
||||
//读取有多少条目
|
||||
var count int64
|
||||
query := models.DB.Model(TabPurchaseOrder{})
|
||||
if jsondata.Search != "" {
|
||||
// 精确匹配订单 ID
|
||||
var id uint
|
||||
if _, err := parsefmt.Sscanf(jsondata.Search, "%d", &id); err == nil && id > 0 {
|
||||
query = query.Where("id = ?", id)
|
||||
} else {
|
||||
// 模糊匹配标题和用途(Remark)
|
||||
query = query.Where("title LIKE ? OR remark LIKE ?",
|
||||
"%"+jsondata.Search+"%", "%"+jsondata.Search+"%")
|
||||
}
|
||||
}
|
||||
if jsondata.Status != "" {
|
||||
query = query.Where("order_status = ?", jsondata.Status)
|
||||
}
|
||||
query.Count(&count)
|
||||
|
||||
//读取条目
|
||||
var getorders []TabPurchaseOrder
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"ops/models"
|
||||
parsefmt "fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
workOrderUserGroup models.TabUserGroups_
|
||||
workOrderAdmins []uint
|
||||
)
|
||||
|
||||
// updateWorkOrderAdminsCash 刷新工单管理员缓存
|
||||
func updateWorkOrderAdminsCash() {
|
||||
workOrderAdmins = nil
|
||||
workOrderAdmins = append(workOrderAdmins, 1) // id=1 超级管理员
|
||||
var binds []models.TabUserGroupBinds_
|
||||
models.DB.Where("group_id = ?", workOrderUserGroup.ID).Find(&binds)
|
||||
for _, item := range binds {
|
||||
if !slices.Contains(workOrderAdmins, item.UserID) {
|
||||
workOrderAdmins = append(workOrderAdmins, item.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// canModifyWorkOrder 判断是否有权限修改/删除工单(创建者或管理员)
|
||||
func canModifyWorkOrder(userID, creatorUserID uint) bool {
|
||||
if slices.Contains(workOrderAdmins, userID) {
|
||||
return true
|
||||
}
|
||||
return userID == creatorUserID
|
||||
}
|
||||
|
||||
// ---------- 数据表结构 ----------
|
||||
|
||||
type TabWorkOrder struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
UserID uint `gorm:"not null;comment:创建人ID"`
|
||||
Title string `gorm:"size:200;not null;comment:工单标题"`
|
||||
Description string `gorm:"type:text;comment:问题描述"`
|
||||
CurrentStatus string `gorm:"size:50;default:pending;comment:当前状态: pending-待处理 checked-已检查 parts_ordered-已下单零件 repaired-已维修 returned-已送还 unrepairable-无法维修"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
type TabWorkOrderFileBind struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
FileID uint `gorm:"not null;comment:关联文件ID"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
type TabWorkOrderCommit struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
UserID uint `gorm:"not null;comment:操作人ID"`
|
||||
Action string `gorm:"size:50;not null;comment:操作类型: create-创建 create_status-状态变更"`
|
||||
Status string `gorm:"size:50;comment:变更后的状态"`
|
||||
OldStatus string `gorm:"size:50;comment:变更前的状态"`
|
||||
Comment string `gorm:"type:text;comment:备注"`
|
||||
IP string `gorm:"size:50;comment:操作IP"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
type TabWorkOrderLog struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
UserID uint `gorm:"not null;comment:操作人ID"`
|
||||
ActionType string `gorm:"size:50;not null;comment:操作类型: create update delete query"`
|
||||
OldContent string `gorm:"type:text;comment:修改前内容(JSON)"`
|
||||
NewContent string `gorm:"type:text;comment:修改后内容(JSON)"`
|
||||
IP string `gorm:"size:50;comment:操作IP"`
|
||||
Remark string `gorm:"size:500;comment:备注"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
type TabWorkOrderCommitFileBind struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CommitID uint `gorm:"not null;index;comment:关联进度ID"`
|
||||
FileID uint `gorm:"not null;comment:关联文件ID"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
// TabWorkOrderPurchaseOrderBind 工单与采购订单的关联表
|
||||
type TabWorkOrderPurchaseOrderBind struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
CommitID uint `gorm:"not null;index;comment:关联进度ID"`
|
||||
PurchaseOrderID uint `gorm:"not null;comment:关联采购订单ID"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
// PurchaseOrderInfo 采购订单简要信息
|
||||
type PurchaseOrderInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
|
||||
func ApiWorkOrderInit() {
|
||||
models.DB.AutoMigrate(&TabWorkOrder{})
|
||||
models.DB.AutoMigrate(&TabWorkOrderFileBind{})
|
||||
models.DB.AutoMigrate(&TabWorkOrderCommit{})
|
||||
models.DB.AutoMigrate(&TabWorkOrderLog{})
|
||||
models.DB.AutoMigrate(&TabWorkOrderCommitFileBind{})
|
||||
models.DB.AutoMigrate(&TabWorkOrderPurchaseOrderBind{})
|
||||
|
||||
workOrderUserGroup.Name = "work_order_admin"
|
||||
if models.DB.Where(&workOrderUserGroup).First(&workOrderUserGroup).Error == nil {
|
||||
updateWorkOrderAdminsCash()
|
||||
} else {
|
||||
workOrderUserGroup.Type = "usergroup"
|
||||
models.DB.Create(&workOrderUserGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 路由注册 ----------
|
||||
|
||||
func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
|
||||
// 新增工单
|
||||
r.POST("/add", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromAdd struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Photos []string `json:"photos"`
|
||||
}
|
||||
var from FromAdd
|
||||
if err := decodeJSON(data, &from); err != nil || from.Title == "" {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验图片哈希
|
||||
for _, hash := range from.Photos {
|
||||
if models.IsContainsSpecialChar(hash) {
|
||||
ReturnJson(ctx, "photo_hash_invalid", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := TabWorkOrder{
|
||||
UserID: user.ID,
|
||||
Title: from.Title,
|
||||
Description: from.Description,
|
||||
CurrentStatus: "pending",
|
||||
}
|
||||
models.DB.Create(&order)
|
||||
|
||||
// 绑定图片
|
||||
for _, hash := range from.Photos {
|
||||
findFile := TabFileInfo_{Sha256: hash, Type: "image"}
|
||||
if models.DB.Where(&findFile).First(&findFile).Error == nil {
|
||||
models.DB.Create(&TabWorkOrderFileBind{
|
||||
WorkOrderID: order.ID,
|
||||
FileID: findFile.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 写创建 commit
|
||||
models.DB.Create(&TabWorkOrderCommit{
|
||||
WorkOrderID: order.ID,
|
||||
UserID: user.ID,
|
||||
Action: "create",
|
||||
Status: "pending",
|
||||
OldStatus: "",
|
||||
Comment: "工单创建",
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
// 写操作日志
|
||||
newContent, _ := json.Marshal(from)
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: order.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "create",
|
||||
NewContent: string(newContent),
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{"id": order.ID})
|
||||
})
|
||||
|
||||
// 编辑工单
|
||||
r.POST("/update", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromUpdate struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Photos []string `json:"photos"`
|
||||
}
|
||||
var from FromUpdate
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 || from.Title == "" {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验图片哈希
|
||||
for _, hash := range from.Photos {
|
||||
if models.IsContainsSpecialChar(hash) {
|
||||
ReturnJson(ctx, "photo_hash_invalid", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !canModifyWorkOrder(user.ID, order.UserID) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
oldContent, _ := json.Marshal(order)
|
||||
|
||||
models.DB.Model(&order).Updates(map[string]interface{}{
|
||||
"title": from.Title,
|
||||
"description": from.Description,
|
||||
})
|
||||
|
||||
// 重建图片绑定
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderFileBind{})
|
||||
for _, hash := range from.Photos {
|
||||
findFile := TabFileInfo_{Sha256: hash, Type: "image"}
|
||||
if models.DB.Where(&findFile).First(&findFile).Error == nil {
|
||||
models.DB.Create(&TabWorkOrderFileBind{
|
||||
WorkOrderID: from.ID,
|
||||
FileID: findFile.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
newContent, _ := json.Marshal(from)
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: from.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "update",
|
||||
OldContent: string(oldContent),
|
||||
NewContent: string(newContent),
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 获取工单列表
|
||||
r.POST("/list", func(ctx *gin.Context) {
|
||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromList struct {
|
||||
Search string `json:"search"`
|
||||
Status string `json:"status"`
|
||||
Entries int `json:"entries"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
var from FromList
|
||||
if err := decodeJSON(data, &from); err != nil {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
if from.Entries <= 0 || from.Entries > 300 {
|
||||
from.Entries = 10
|
||||
}
|
||||
if from.Page <= 0 {
|
||||
from.Page = 1
|
||||
}
|
||||
|
||||
var count int64
|
||||
query := models.DB.Model(&TabWorkOrder{})
|
||||
if from.Search != "" {
|
||||
query = query.Where("title LIKE ?", "%"+from.Search+"%")
|
||||
}
|
||||
if from.Status != "" {
|
||||
query = query.Where("current_status = ?", from.Status)
|
||||
}
|
||||
query.Count(&count)
|
||||
|
||||
var orders []TabWorkOrder
|
||||
query.Order("created_at DESC").
|
||||
Offset(from.Entries * (from.Page - 1)).
|
||||
Limit(from.Entries).
|
||||
Find(&orders)
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"all_count": count,
|
||||
"all_orders": orders,
|
||||
})
|
||||
})
|
||||
|
||||
// 获取工单详情
|
||||
r.POST("/get", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromGet struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
var from FromGet
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 关联图片
|
||||
var binds []TabWorkOrderFileBind
|
||||
models.DB.Where("work_order_id = ?", from.ID).Find(&binds)
|
||||
var fileIDs []uint
|
||||
for _, b := range binds {
|
||||
fileIDs = append(fileIDs, b.FileID)
|
||||
}
|
||||
var files []TabFileInfo_
|
||||
if len(fileIDs) > 0 {
|
||||
models.DB.Where("id IN ?", fileIDs).Find(&files)
|
||||
}
|
||||
|
||||
// commits
|
||||
var commits []TabWorkOrderCommit
|
||||
models.DB.Where("work_order_id = ?", from.ID).Order("created_at ASC").Find(&commits)
|
||||
|
||||
// 为每条 commit 附加图片和采购订单
|
||||
type CommitWithPhotos struct {
|
||||
TabWorkOrderCommit
|
||||
Photos []TabFileInfo_ `json:"photos"`
|
||||
PurchaseOrders []PurchaseOrderInfo `json:"purchaseOrders"`
|
||||
}
|
||||
var commitsWithPhotos []CommitWithPhotos
|
||||
for _, c := range commits {
|
||||
item := CommitWithPhotos{TabWorkOrderCommit: c, Photos: []TabFileInfo_{}, PurchaseOrders: []PurchaseOrderInfo{}}
|
||||
|
||||
// 附加图片
|
||||
var fileBinds []TabWorkOrderCommitFileBind
|
||||
models.DB.Where("commit_id = ?", c.ID).Find(&fileBinds)
|
||||
if len(fileBinds) > 0 {
|
||||
var fileIDs []uint
|
||||
for _, fb := range fileBinds {
|
||||
fileIDs = append(fileIDs, fb.FileID)
|
||||
}
|
||||
models.DB.Where("id IN ?", fileIDs).Find(&item.Photos)
|
||||
}
|
||||
|
||||
// 附加采购订单
|
||||
var poBinds []TabWorkOrderPurchaseOrderBind
|
||||
models.DB.Where("commit_id = ?", c.ID).Find(&poBinds)
|
||||
if len(poBinds) > 0 {
|
||||
var poIDs []uint
|
||||
for _, pb := range poBinds {
|
||||
poIDs = append(poIDs, pb.PurchaseOrderID)
|
||||
}
|
||||
var pos []TabPurchaseOrder
|
||||
models.DB.Where("id IN ?", poIDs).Find(&pos)
|
||||
for _, po := range pos {
|
||||
item.PurchaseOrders = append(item.PurchaseOrders, PurchaseOrderInfo{
|
||||
ID: po.ID,
|
||||
Title: po.Title,
|
||||
Status: po.OrderStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
commitsWithPhotos = append(commitsWithPhotos, item)
|
||||
}
|
||||
|
||||
canModify := canModifyWorkOrder(user.ID, order.UserID)
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"order": order,
|
||||
"canModify": canModify,
|
||||
"photos": files,
|
||||
"commits": commitsWithPhotos,
|
||||
})
|
||||
})
|
||||
|
||||
// 新增进度 commit(状态推进)
|
||||
r.POST("/commit", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromCommit struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Comment string `json:"comment"`
|
||||
Photos []string `json:"photos"`
|
||||
PurchaseOrderIDs []uint `json:"purchaseOrderIds"` // 关联的采购订单ID列表
|
||||
}
|
||||
var from FromCommit
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{
|
||||
"pending": true,
|
||||
"checked": true,
|
||||
"parts_ordered": true,
|
||||
"repaired": true,
|
||||
"returned": true,
|
||||
"unrepairable": true,
|
||||
}
|
||||
if !validStatuses[from.Status] {
|
||||
ReturnJson(ctx, "invalid_status", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验图片哈希
|
||||
for _, hash := range from.Photos {
|
||||
if models.IsContainsSpecialChar(hash) {
|
||||
ReturnJson(ctx, "photo_hash_invalid", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !canModifyWorkOrder(user.ID, order.UserID) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
oldStatus := order.CurrentStatus
|
||||
models.DB.Model(&order).Update("current_status", from.Status)
|
||||
|
||||
comment := from.Comment
|
||||
if comment == "" {
|
||||
comment = "状态变更为: " + from.Status
|
||||
}
|
||||
|
||||
commit := TabWorkOrderCommit{
|
||||
WorkOrderID: order.ID,
|
||||
UserID: user.ID,
|
||||
Action: "create_status",
|
||||
Status: from.Status,
|
||||
OldStatus: oldStatus,
|
||||
Comment: comment,
|
||||
IP: ctx.ClientIP(),
|
||||
}
|
||||
models.DB.Create(&commit)
|
||||
|
||||
// 绑定进度图片
|
||||
for _, hash := range from.Photos {
|
||||
findFile := TabFileInfo_{Sha256: hash, Type: "image"}
|
||||
if models.DB.Where(&findFile).First(&findFile).Error == nil {
|
||||
models.DB.Create(&TabWorkOrderCommitFileBind{
|
||||
CommitID: commit.ID,
|
||||
WorkOrderID: order.ID,
|
||||
FileID: findFile.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定采购订单(去重)
|
||||
if len(from.PurchaseOrderIDs) > 0 {
|
||||
seen := make(map[uint]bool)
|
||||
for _, pid := range from.PurchaseOrderIDs {
|
||||
if !seen[pid] {
|
||||
seen[pid] = true
|
||||
models.DB.Create(&TabWorkOrderPurchaseOrderBind{
|
||||
WorkOrderID: order.ID,
|
||||
CommitID: commit.ID,
|
||||
PurchaseOrderID: pid,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newContent, _ := json.Marshal(map[string]string{
|
||||
"status": from.Status,
|
||||
"comment": comment,
|
||||
})
|
||||
oldContent, _ := json.Marshal(map[string]string{
|
||||
"status": oldStatus,
|
||||
})
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: from.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "update_status",
|
||||
OldContent: string(oldContent),
|
||||
NewContent: string(newContent),
|
||||
IP: ctx.ClientIP(),
|
||||
Remark: comment,
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 删除工单
|
||||
r.POST("/delete", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromDelete struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
var from FromDelete
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !canModifyWorkOrder(user.ID, order.UserID) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderFileBind{})
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderCommitFileBind{})
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderPurchaseOrderBind{})
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderCommit{})
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderLog{})
|
||||
models.DB.Delete(&order)
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 获取工单数量统计
|
||||
r.POST("/count", func(ctx *gin.Context) {
|
||||
isAuth, _, _ := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type WOCount struct {
|
||||
Pending int64 `json:"pending"`
|
||||
Checked int64 `json:"checked"`
|
||||
PartsOrdered int64 `json:"parts_ordered"`
|
||||
Repaired int64 `json:"repaired"`
|
||||
Returned int64 `json:"returned"`
|
||||
Unrepairable int64 `json:"unrepairable"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
var count WOCount
|
||||
models.DB.Model(&TabWorkOrder{}).Count(&count.Total)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "pending").Count(&count.Pending)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "checked").Count(&count.Checked)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "parts_ordered").Count(&count.PartsOrdered)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "repaired").Count(&count.Repaired)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "returned").Count(&count.Returned)
|
||||
models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "unrepairable").Count(&count.Unrepairable)
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"pending": count.Pending,
|
||||
"checked": count.Checked,
|
||||
"parts_ordered": count.PartsOrdered,
|
||||
"repaired": count.Repaired,
|
||||
"returned": count.Returned,
|
||||
"unrepairable": count.Unrepairable,
|
||||
"total": count.Total,
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索采购订单(用于工单关联)
|
||||
r.POST("/search_purchase_orders", func(ctx *gin.Context) {
|
||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromSearch struct {
|
||||
Search string `json:"search"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
var from FromSearch
|
||||
if err := decodeJSON(data, &from); err != nil {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if from.Limit <= 0 || from.Limit > 20 {
|
||||
from.Limit = 5
|
||||
}
|
||||
|
||||
query := models.DB.Model(&TabPurchaseOrder{})
|
||||
|
||||
// 如果搜索词为空,返回最新的 N 条
|
||||
if from.Search != "" {
|
||||
// 尝试精确匹配 ID
|
||||
var id uint
|
||||
if _, err := parsefmt.Sscanf(from.Search, "%d", &id); err == nil && id > 0 {
|
||||
query = query.Where("id = ?", id)
|
||||
} else {
|
||||
// 模糊匹配标题或备注
|
||||
query = query.Where("title LIKE ? OR remark LIKE ?",
|
||||
"%"+from.Search+"%", "%"+from.Search+"%")
|
||||
}
|
||||
}
|
||||
|
||||
var orders []TabPurchaseOrder
|
||||
query.Order("created_at DESC").Limit(from.Limit).Find(&orders)
|
||||
|
||||
type OrderInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
var result []OrderInfo
|
||||
for _, o := range orders {
|
||||
result = append(result, OrderInfo{
|
||||
ID: o.ID,
|
||||
Title: o.Title,
|
||||
Status: o.OrderStatus,
|
||||
})
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{"orders": result})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { api } from './index'
|
||||
|
||||
export const workOrderApi = {
|
||||
/** 获取工单列表 */
|
||||
getList(params = {}) {
|
||||
return api.post('/work_order/list', params)
|
||||
},
|
||||
|
||||
/** 获取工单数量统计 */
|
||||
getCount() {
|
||||
return api.post('/work_order/count', {})
|
||||
},
|
||||
|
||||
/** 新增工单 */
|
||||
add(data) {
|
||||
return api.post('/work_order/add', data)
|
||||
},
|
||||
|
||||
/** 编辑工单 */
|
||||
update(data) {
|
||||
return api.post('/work_order/update', data)
|
||||
},
|
||||
|
||||
/** 获取工单详情(含图片、commits) */
|
||||
get(id) {
|
||||
return api.post('/work_order/get', { id })
|
||||
},
|
||||
|
||||
/** 新增进度(状态变更) */
|
||||
commit(id, status, comment = '', photos = [], purchaseOrderIds = []) {
|
||||
return api.post('/work_order/commit', { id, status, comment, photos, purchaseOrderIds })
|
||||
},
|
||||
|
||||
/** 删除工单 */
|
||||
delete(id) {
|
||||
return api.post('/work_order/delete', { id })
|
||||
},
|
||||
|
||||
/** 搜索采购订单 */
|
||||
searchPurchaseOrders(search = '', limit = 5) {
|
||||
return api.post('/work_order/search_purchase_orders', { search, limit })
|
||||
},
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const navItems = computed(() => [
|
||||
{ label: t("appname.home"), to: "/" },
|
||||
{ label: t("appname.schedule"), to: "/schedule" },
|
||||
{ label: t("appname.purchase"), to: "/purchase" },
|
||||
{ label: t("appname.work_order"), to: "/work_order" },
|
||||
// { label: t("appname.warehouse"), to: "/warehouse" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -86,7 +86,7 @@ const prop = defineProps({
|
||||
},
|
||||
uploadURL: {
|
||||
type: String,
|
||||
default: "/api/files/upload",
|
||||
default: "/api/files/upload/image",
|
||||
},
|
||||
/** 初始已有文件 [{ hash, name, ... }] */
|
||||
initialFiles: {
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"register": "Register",
|
||||
"schedule": "Schedule",
|
||||
"purchase": "Purchase",
|
||||
"warehouse": "Warehouse"
|
||||
"warehouse": "Warehouse",
|
||||
"work_order": "Work Order"
|
||||
},
|
||||
"tagadder": {
|
||||
"not_fund_item": "No matching items found",
|
||||
@@ -78,6 +79,7 @@
|
||||
"show": "Show",
|
||||
"entries": "entries",
|
||||
"search": "Search",
|
||||
"search_placeholder": "Search ID, title, or purpose",
|
||||
"add_part": "Add Order",
|
||||
"exp_report": "Export Report",
|
||||
"There_are_a_total_of": ",There are a total of",
|
||||
@@ -108,6 +110,45 @@
|
||||
"edit_order": "Edit Order",
|
||||
"submit_changes":"Submit changes"
|
||||
},
|
||||
"work_order": {
|
||||
"list_title": "Work Order List",
|
||||
"add": "New Work Order",
|
||||
"add_title": "New Work Order",
|
||||
"edit_title": "Edit Work Order",
|
||||
"detail_title": "Work Order Detail",
|
||||
"title": "Title",
|
||||
"title_placeholder": "Enter work order title",
|
||||
"description": "Description",
|
||||
"description_placeholder": "Enter problem description",
|
||||
"photos": "Photos",
|
||||
"no_photos": "No photos",
|
||||
"status": "Status",
|
||||
"created_at": "Created At",
|
||||
"filter_all": "All",
|
||||
"status_pending": "Pending",
|
||||
"status_checked": "Checked",
|
||||
"status_parts_ordered": "Parts Ordered",
|
||||
"status_repaired": "Repaired",
|
||||
"status_returned": "Returned",
|
||||
"status_unrepairable": "Unrepairable",
|
||||
"commit_history": "Progress History",
|
||||
"no_commits": "No progress records",
|
||||
"commit_create": "Work order created",
|
||||
"add_commit": "Add Progress",
|
||||
"commit_status_label": "Status",
|
||||
"commit_comment_label": "Comment",
|
||||
"commit_comment_placeholder": "Add comment (optional)",
|
||||
"commit_photos_label": "Photos",
|
||||
"commit_submit": "Submit",
|
||||
"submit_commit": "Submit",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"back_to_list": "Back to List",
|
||||
"not_found": "Work order not found",
|
||||
"confirm_delete": "Are you sure you want to delete this work order? This action cannot be undone.",
|
||||
"submit": "Submit",
|
||||
"save_changes": "Save Changes"
|
||||
},
|
||||
"purchase_addorder": {
|
||||
"add_order": "Add Order",
|
||||
"edit_order": "Edit Order",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"register": "注册",
|
||||
"schedule": "日程",
|
||||
"purchase": "采购",
|
||||
"warehouse": "仓库"
|
||||
"warehouse": "仓库",
|
||||
"work_order": "工单"
|
||||
},
|
||||
"tagadder": {
|
||||
"not_fund_item": "没有找到匹配项",
|
||||
@@ -78,6 +79,7 @@
|
||||
"show": "显示",
|
||||
"entries": "个订单",
|
||||
"search": "搜索",
|
||||
"search_placeholder": "搜索订单号、名称、用途",
|
||||
"add_part": "添加订单",
|
||||
"exp_report": "生成报告",
|
||||
"There_are_a_total_of": ",一共",
|
||||
@@ -108,6 +110,45 @@
|
||||
"edit_order": "编辑订单",
|
||||
"submit_changes":"提交修改"
|
||||
},
|
||||
"work_order": {
|
||||
"list_title": "工单列表",
|
||||
"add": "新增工单",
|
||||
"add_title": "新增工单",
|
||||
"edit_title": "编辑工单",
|
||||
"detail_title": "工单详情",
|
||||
"title": "工单标题",
|
||||
"title_placeholder": "输入工单标题",
|
||||
"description": "问题描述",
|
||||
"description_placeholder": "输入问题描述",
|
||||
"photos": "图片",
|
||||
"no_photos": "暂无图片",
|
||||
"status": "状态",
|
||||
"created_at": "创建时间",
|
||||
"filter_all": "全部",
|
||||
"status_pending": "待处理",
|
||||
"status_checked": "已检查",
|
||||
"status_parts_ordered": "已下单零件",
|
||||
"status_repaired": "已维修",
|
||||
"status_returned": "已送还",
|
||||
"status_unrepairable": "无法维修",
|
||||
"commit_history": "工作进度",
|
||||
"no_commits": "暂无进度记录",
|
||||
"commit_create": "工单创建",
|
||||
"add_commit": "新增进度",
|
||||
"commit_status_label": "进度状态",
|
||||
"commit_comment_label": "备注",
|
||||
"commit_comment_placeholder": "输入进度备注(可选)",
|
||||
"commit_photos_label": "上传图片",
|
||||
"commit_submit": "提交",
|
||||
"submit_commit": "提交",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"back_to_list": "返回列表",
|
||||
"not_found": "工单不存在",
|
||||
"confirm_delete": "确定要删除此工单吗?此操作不可撤销。",
|
||||
"submit": "提交",
|
||||
"save_changes": "保存修改"
|
||||
},
|
||||
"purchase_addorder": {
|
||||
"add_order": "添加订单",
|
||||
"edit_order": "编辑订单",
|
||||
|
||||
@@ -54,6 +54,26 @@ const router = createRouter({
|
||||
name: 'purchase-edit',
|
||||
component: () => import('@/views/purchase/editorder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'work_order',
|
||||
name: 'work-order-list',
|
||||
component: () => import('@/views/work_order/WorkOrderList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'work_order/add',
|
||||
name: 'work-order-add',
|
||||
component: () => import('@/views/work_order/AddEditWorkOrder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'work_order/edit/:id',
|
||||
name: 'work-order-edit',
|
||||
component: () => import('@/views/work_order/AddEditWorkOrder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'work_order/show/:id',
|
||||
name: 'work-order-show',
|
||||
component: () => import('@/views/work_order/ShowWorkOrder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'warehouse',
|
||||
name: 'warehouse',
|
||||
|
||||
@@ -17,6 +17,7 @@ const totalCount = ref(0)
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const statusFilter = ref('')
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
@@ -45,6 +46,7 @@ async function fetchOrders() {
|
||||
try {
|
||||
const { errCode, data } = await purchaseApi.getOrders({
|
||||
status: statusFilter.value,
|
||||
search: searchQuery.value,
|
||||
entries: pageSize.value,
|
||||
page: currentPage.value,
|
||||
})
|
||||
@@ -99,6 +101,11 @@ function handleJumpPageInput(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
@@ -112,7 +119,7 @@ onMounted(fetchOrders)
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<RouterLink to="/purchase/addorder" class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700">
|
||||
<IconPlus :size="16" />
|
||||
{{ t('purchase.add_part') }}
|
||||
@@ -128,6 +135,23 @@ onMounted(fetchOrders)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 关键词搜索框 -->
|
||||
<div class="flex items-center gap-2 sm:ml-auto">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('purchase.search_placeholder')"
|
||||
class="w-48 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
|
||||
@input="handleSearch"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
|
||||
@click="handleSearch"
|
||||
>
|
||||
{{ t('purchase.search') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
|
||||
@@ -156,7 +156,6 @@ function getStatusColorClass(status) {
|
||||
}
|
||||
|
||||
function openStatusDialog(newStatus) {
|
||||
if (newStatus === order.value?.OrderStatus) return;
|
||||
pendingStatus.value = newStatus;
|
||||
pendingComment.value = "";
|
||||
pendingPhotos.value = [];
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 工单新增/编辑页面
|
||||
* - 路由有 :id 参数时为编辑模式,否则为新增模式
|
||||
* - 支持图片上传(复用 useDropzone 组件)
|
||||
*/
|
||||
import { reactive, ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useValidation } from '@/composables'
|
||||
import { workOrderApi } from '@/api/work_order'
|
||||
import useDropzone from '@/components/useDropzone.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const toast = useToastStore()
|
||||
const { validate, errors, clearErrors } = useValidation()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const orderId = computed(() => (isEdit.value ? Number(route.params.id) : null))
|
||||
|
||||
usePageTitle(isEdit.value ? 'work_order.edit_title' : 'work_order.add_title')
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const loading = ref(false)
|
||||
const pageLoading = ref(false)
|
||||
const pageError = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
// ==================== 表单数据 ====================
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
photos: [],
|
||||
})
|
||||
|
||||
// ==================== 图片上传 ====================
|
||||
const dropzoneRef = ref(null)
|
||||
|
||||
function getPhotoHashes() {
|
||||
return dropzoneRef.value?.return_files().map((f) => f.hash) ?? []
|
||||
}
|
||||
|
||||
// ==================== 加载编辑数据 ====================
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const res = await workOrderApi.get(orderId.value)
|
||||
if (res.errCode !== 0 || !res.data) {
|
||||
pageError.value = t('work_order.not_found')
|
||||
return
|
||||
}
|
||||
|
||||
const { order, photos } = res.data
|
||||
form.title = order.Title ?? ''
|
||||
form.description = order.Description ?? ''
|
||||
|
||||
// 回填图片
|
||||
await nextTick()
|
||||
if (photos && photos.length > 0) {
|
||||
form.photos = photos
|
||||
}
|
||||
} catch {
|
||||
pageError.value = t('work_order.not_found')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 删除 ====================
|
||||
function handleDelete() {
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await workOrderApi.delete(orderId.value)
|
||||
if (res.errCode === 0) {
|
||||
toast.success(t('message.delete_ok'))
|
||||
router.replace('/work_order')
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('message.server_error'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提交 ====================
|
||||
async function handleSubmit() {
|
||||
clearErrors()
|
||||
const ok = validate('title', form.title, t('work_order.title'))
|
||||
if (!ok) return
|
||||
|
||||
const photos = getPhotoHashes()
|
||||
loading.value = true
|
||||
try {
|
||||
let res
|
||||
if (isEdit.value) {
|
||||
res = await workOrderApi.update({
|
||||
id: orderId.value,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
photos,
|
||||
})
|
||||
} else {
|
||||
res = await workOrderApi.add({
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
photos,
|
||||
})
|
||||
}
|
||||
|
||||
if (res.errCode === 0) {
|
||||
toast.success(t('message.save_ok'))
|
||||
const newId = isEdit.value ? orderId.value : res.data?.id
|
||||
setTimeout(() => {
|
||||
router.replace(newId ? `/work_order/show/${newId}` : '/work_order')
|
||||
}, 800)
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('message.server_error'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl px-6 py-6">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="pageLoading" class="flex items-center justify-center py-20 text-gray-400">
|
||||
<svg class="mr-2 h-5 w-5 animate-spin" 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-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('message.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- 工单不存在 -->
|
||||
<div
|
||||
v-else-if="pageError"
|
||||
class="rounded-xl border border-red-200 bg-red-50 px-6 py-10 text-center text-red-500 dark:border-red-800 dark:bg-red-900/20"
|
||||
>
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<!-- 主卡片 -->
|
||||
<div v-else class="flex flex-col gap-0 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-dk-muted">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ isEdit ? t('work_order.edit_title') : t('work_order.add_title') }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 删除按钮(编辑模式才显示) -->
|
||||
<button
|
||||
v-if="isEdit"
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
:disabled="loading"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('work_order.delete') }}
|
||||
</button>
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-base"
|
||||
@click="router.back()"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('purchase.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字段验证错误提示 -->
|
||||
<div
|
||||
v-if="errors.title"
|
||||
class="mx-6 mt-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{{ errors.title }}
|
||||
</div>
|
||||
|
||||
<!-- 表单区 -->
|
||||
<div class="space-y-5 px-6 py-5">
|
||||
<!-- 工单标题 -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('work_order.title') }}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
:placeholder="t('work_order.title_placeholder')"
|
||||
class="w-full rounded-lg border px-3.5 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-blue-500/20 dark:bg-dk-base dark:text-white"
|
||||
:class="errors.title ? 'border-red-500 focus:border-red-500' : 'border-gray-300 focus:border-blue-500 dark:border-dk-muted'"
|
||||
/>
|
||||
<span v-if="errors.title" class="mt-1 block text-xs text-red-500">{{ errors.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 问题描述 -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('work_order.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="5"
|
||||
:placeholder="t('work_order.description_placeholder')"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('work_order.photos') }}
|
||||
</label>
|
||||
<useDropzone
|
||||
ref="dropzoneRef"
|
||||
:initial-files="form.photos"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提交 -->
|
||||
<div class="border-t border-gray-200 px-6 py-4 dark:border-dk-muted">
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
|
||||
:disabled="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<svg v-if="loading" class="h-4 w-4 animate-spin" 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-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ isEdit ? t('work_order.save_changes') : t('work_order.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
:message="t('work_order.confirm_delete')"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,609 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { workOrderApi } from '@/api/work_order'
|
||||
import useDropzone from '@/components/useDropzone.vue'
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
IconTrash,
|
||||
IconX,
|
||||
IconSearch,
|
||||
IconExternalLink,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
usePageTitle('work_order.detail_title')
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToastStore()
|
||||
const userStore = useUserStore()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
const orderId = computed(() => parseInt(route.params.id))
|
||||
|
||||
const order = ref(null)
|
||||
const photos = ref([])
|
||||
const commits = ref([])
|
||||
const canModify = ref(false)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
|
||||
// 提交进度相关
|
||||
const submittingCommit = ref(false)
|
||||
const commitStatus = ref('pending')
|
||||
const commitComment = ref('')
|
||||
const commitPhotos = ref([])
|
||||
|
||||
// 采购订单关联相关
|
||||
const purchaseSearchQuery = ref('')
|
||||
const purchaseSearchResults = ref([])
|
||||
const selectedPurchaseOrders = ref([])
|
||||
const purchaseSearchLoading = ref(false)
|
||||
const purchaseDropdownVisible = ref(false)
|
||||
let purchaseSearchTimer = null
|
||||
const purchaseDropdownRef = ref(null)
|
||||
|
||||
// 是否可以提交(订单、备注、上传图片都为空时才禁止)
|
||||
const canSubmit = computed(() => {
|
||||
const hasSelectedOrders = selectedPurchaseOrders.value.length > 0
|
||||
const hasComment = !!commitComment.value
|
||||
const hasPhotos = commitPhotos.value.length > 0
|
||||
// 订单、备注、上传图片都为空时才禁止提交
|
||||
return hasSelectedOrders || hasComment || hasPhotos
|
||||
})
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
function onDocumentClick(e) {
|
||||
if (purchaseDropdownRef.value && !purchaseDropdownRef.value.contains(e.target)) {
|
||||
purchaseDropdownVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ value: 'pending', labelKey: 'work_order.status_pending', color: 'yellow' },
|
||||
{ value: 'checked', labelKey: 'work_order.status_checked', color: 'blue' },
|
||||
{ value: 'parts_ordered', labelKey: 'work_order.status_parts_ordered', color: 'purple' },
|
||||
{ value: 'repaired', labelKey: 'work_order.status_repaired', color: 'green' },
|
||||
{ value: 'returned', labelKey: 'work_order.status_returned', color: 'gray' },
|
||||
{ value: 'unrepairable', labelKey: 'work_order.status_unrepairable', color: 'red' },
|
||||
]
|
||||
|
||||
const statusColorMap = {
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400',
|
||||
checked: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
|
||||
parts_ordered: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
||||
repaired: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
|
||||
returned: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
unrepairable: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
const opt = statusOptions.find((o) => o.value === status)
|
||||
return opt ? t(opt.labelKey) : status
|
||||
}
|
||||
|
||||
function getStatusColorClass(status) {
|
||||
return statusColorMap[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return '-'
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
function getPhotoUrl(file) {
|
||||
return `/api/files/get/${file.Sha256}`
|
||||
}
|
||||
|
||||
// ==================== 加载工单 ====================
|
||||
async function fetchOrder() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { errCode, data } = await workOrderApi.get(orderId.value)
|
||||
if (errCode === 0 && data) {
|
||||
order.value = data.order ?? null
|
||||
canModify.value = data.canModify ?? false
|
||||
photos.value = data.photos ?? []
|
||||
commits.value = data.commits ?? []
|
||||
// 初始化进度提交状态为当前状态
|
||||
if (order.value?.CurrentStatus) {
|
||||
commitStatus.value = order.value.CurrentStatus
|
||||
}
|
||||
} else {
|
||||
notFound.value = true
|
||||
}
|
||||
} catch {
|
||||
notFound.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提交进度 ====================
|
||||
async function handleCommit() {
|
||||
if (submittingCommit.value) return
|
||||
submittingCommit.value = true
|
||||
try {
|
||||
const purchaseOrderIds = selectedPurchaseOrders.value.map(p => p.id)
|
||||
const { errCode } = await workOrderApi.commit(
|
||||
orderId.value,
|
||||
commitStatus.value,
|
||||
commitComment.value,
|
||||
commitPhotos.value,
|
||||
purchaseOrderIds,
|
||||
)
|
||||
if (errCode === 0) {
|
||||
toast.success(t('message.save_ok'))
|
||||
commitComment.value = ''
|
||||
commitPhotos.value = []
|
||||
selectedPurchaseOrders.value = []
|
||||
await fetchOrder()
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('message.server_error'))
|
||||
} finally {
|
||||
submittingCommit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 快捷切换状态 ====================
|
||||
async function quickChangeStatus(newStatus) {
|
||||
if (newStatus === order.value?.CurrentStatus) return
|
||||
submittingCommit.value = true
|
||||
try {
|
||||
const { errCode } = await workOrderApi.commit(orderId.value, newStatus, '')
|
||||
if (errCode === 0) {
|
||||
toast.success(t('message.save_ok'))
|
||||
await fetchOrder()
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('message.server_error'))
|
||||
} finally {
|
||||
submittingCommit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 采购订单搜索 ====================
|
||||
async function searchPurchaseOrders() {
|
||||
purchaseSearchLoading.value = true
|
||||
try {
|
||||
const { errCode, data } = await workOrderApi.searchPurchaseOrders(purchaseSearchQuery.value, 10)
|
||||
if (errCode === 0) {
|
||||
purchaseSearchResults.value = data.orders || []
|
||||
}
|
||||
} catch {
|
||||
purchaseSearchResults.value = []
|
||||
} finally {
|
||||
purchaseSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPurchaseSearchInput() {
|
||||
purchaseDropdownVisible.value = true
|
||||
clearTimeout(purchaseSearchTimer)
|
||||
purchaseSearchTimer = setTimeout(() => {
|
||||
searchPurchaseOrders()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectPurchaseOrder(po) {
|
||||
// 检查是否已选中
|
||||
if (!selectedPurchaseOrders.value.find(p => p.id === po.id)) {
|
||||
selectedPurchaseOrders.value.push(po)
|
||||
}
|
||||
// 清空搜索框并重新搜索,保持下拉框显示
|
||||
purchaseSearchQuery.value = ''
|
||||
searchPurchaseOrders()
|
||||
}
|
||||
|
||||
function removePurchaseOrder(poId) {
|
||||
selectedPurchaseOrders.value = selectedPurchaseOrders.value.filter(p => p.id !== poId)
|
||||
}
|
||||
|
||||
function goToPurchaseOrder(poId) {
|
||||
router.push(`/purchase/showorder/${poId}`)
|
||||
}
|
||||
|
||||
// 采购订单状态颜色
|
||||
function getPurchaseStatusClass(status) {
|
||||
const map = {
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400',
|
||||
ordered: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
||||
arrived: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
|
||||
received: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
|
||||
lost: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
returned: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const purchaseStatusLabels = {
|
||||
pending: '待处理',
|
||||
ordered: '已下单',
|
||||
arrived: '已到达',
|
||||
received: '已收件',
|
||||
lost: '丢件',
|
||||
returned: '退件',
|
||||
}
|
||||
|
||||
function getPurchaseStatusLabel(status) {
|
||||
return purchaseStatusLabels[status] || status
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrder()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl px-6 py-6">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<RouterLink
|
||||
to="/work_order"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dk-card dark:hover:text-gray-200"
|
||||
>
|
||||
<IconChevronLeft :size="16" />
|
||||
{{ t('work_order.back_to_list') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="canModify && order"
|
||||
:to="`/work_order/edit/${order.ID}`"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-gray-300 dark:hover:bg-dk-base"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{{ t('work_order.edit') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-24">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-500" 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-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span class="ml-3 text-gray-500">{{ t('message.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div
|
||||
v-else-if="notFound"
|
||||
class="rounded-xl border border-gray-200 bg-white py-16 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
<p class="text-gray-400">{{ t('work_order.not_found') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 工单详情 -->
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
|
||||
<!-- ===== 主信息卡 ===== -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<!-- 卡头:标题 + 状态 + 创建者 + 时间 -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('work_order.detail_title') }} #{{ orderId }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="order?.CurrentStatus"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:class="getStatusColorClass(order.CurrentStatus)"
|
||||
>
|
||||
<IconLoader2 v-if="submittingCommit" :size="10" class="animate-spin" />
|
||||
{{ getStatusLabel(order.CurrentStatus) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="order?.UserID"
|
||||
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400"
|
||||
>
|
||||
<img
|
||||
:src="usersStore.getAvatarUrlFromUserID(order.UserID)"
|
||||
class="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
{{ usersStore.getUsernameFromUserID(order.UserID) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">{{ formatDate(order?.CreatedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态快捷切换(有权限才显示) -->
|
||||
<div
|
||||
v-if="canModify"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-gray-100 px-6 py-3 dark:border-dk-muted"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('purchase.change_status') }}:</span>
|
||||
<button
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all"
|
||||
:class="order?.CurrentStatus === opt.value
|
||||
? [getStatusColorClass(opt.value), 'border-transparent']
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-base'"
|
||||
:disabled="submittingCommit"
|
||||
@click="quickChangeStatus(opt.value)"
|
||||
>
|
||||
<IconCheck v-if="order?.CurrentStatus === opt.value" :size="12" />
|
||||
{{ t(opt.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 工单基本信息 -->
|
||||
<div class="space-y-4 px-6 py-5">
|
||||
<!-- 标题 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.title') }}</label>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ order?.Title || '-' }}</p>
|
||||
</div>
|
||||
<!-- 描述 -->
|
||||
<div v-if="order?.Description">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.description') }}</label>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{{ order.Description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片区 -->
|
||||
<div class="border-t border-gray-100 px-6 py-5 dark:border-dk-muted">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-500 dark:text-gray-400">{{ t('work_order.photos') }}</h4>
|
||||
<div v-if="photos.length === 0" class="text-sm text-gray-400">{{ t('work_order.no_photos') }}</div>
|
||||
<div v-else class="flex flex-wrap gap-3">
|
||||
<a
|
||||
v-for="file in photos"
|
||||
:key="file.ID"
|
||||
:href="getPhotoUrl(file)"
|
||||
target="_blank"
|
||||
class="group relative block h-24 w-24 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 transition hover:border-blue-400 dark:border-dk-muted dark:bg-dk-base"
|
||||
>
|
||||
<img
|
||||
:src="getPhotoUrl(file)"
|
||||
:alt="file.Name"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 工作进度时间线 ===== -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('work_order.commit_history') }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- 新增进度表单(有权限才显示) -->
|
||||
<div v-if="canModify" class="border-t border-gray-100 px-6 py-5 dark:border-dk-muted">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('work_order.add_commit') }}</h4>
|
||||
|
||||
<!-- 第一行:进度状态、关联采购订单 -->
|
||||
<div class="mb-3 flex flex-wrap items-start gap-3">
|
||||
<!-- 状态选择 -->
|
||||
<div class="min-w-40">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.commit_status_label') }}</label>
|
||||
<select
|
||||
v-model="commitStatus"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
>
|
||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||
{{ t(opt.labelKey) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 采购订单关联(仅在已下单状态显示) -->
|
||||
<div v-if="commitStatus === 'parts_ordered'" class="flex-1">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">关联采购订单</label>
|
||||
<!-- 已选中的订单 -->
|
||||
<div v-if="selectedPurchaseOrders.length > 0" class="mb-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="po in selectedPurchaseOrders"
|
||||
:key="po.id"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
#{{ po.id }} {{ po.title || '' }}
|
||||
<button
|
||||
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
@click="removePurchaseOrder(po.id)"
|
||||
>
|
||||
<IconX :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 搜索框 -->
|
||||
<div ref="purchaseDropdownRef" class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<IconSearch :size="14" class="text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
v-model="purchaseSearchQuery"
|
||||
type="text"
|
||||
placeholder="搜索采购订单..."
|
||||
class="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
@input="onPurchaseSearchInput"
|
||||
@focus="purchaseDropdownVisible = true; searchPurchaseOrders()"
|
||||
/>
|
||||
<!-- 搜索结果下拉 -->
|
||||
<div
|
||||
v-if="purchaseDropdownVisible && purchaseSearchResults.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
<button
|
||||
v-for="po in purchaseSearchResults"
|
||||
:key="po.id"
|
||||
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-dk-muted"
|
||||
@mousedown.prevent="selectPurchaseOrder(po)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ po.id }}</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ po.title || '-' }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="getPurchaseStatusClass(po.status)"
|
||||
>
|
||||
{{ getPurchaseStatusLabel(po.status) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="purchaseDropdownVisible && purchaseSearchQuery && purchaseSearchResults.length === 0 && !purchaseSearchLoading"
|
||||
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white py-3 text-center text-sm text-gray-400 dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
未找到匹配的订单
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:备注 -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.commit_comment_label') }}</label>
|
||||
<textarea
|
||||
v-model="commitComment"
|
||||
rows="2"
|
||||
:placeholder="t('work_order.commit_comment_placeholder')"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:图片上传 -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.commit_photos_label') }}</label>
|
||||
<useDropzone
|
||||
v-model="commitPhotos"
|
||||
:maxFiles="10"
|
||||
:maxSize="10 * 1024 * 1024"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 第四行:提交 -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
:disabled="isCommitting || !canSubmit"
|
||||
class="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="handleCommit"
|
||||
>
|
||||
{{ isCommitting ? '提交中...' : t('work_order.commit_submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度列表 -->
|
||||
<div class="px-6 py-4">
|
||||
<div v-if="commits.length === 0" class="py-4 text-sm text-gray-400">{{ t('work_order.no_commits') }}</div>
|
||||
<ol v-else class="relative border-l border-gray-200 dark:border-dk-muted">
|
||||
<li
|
||||
v-for="commit in [...commits].reverse()"
|
||||
:key="commit.ID"
|
||||
class="mb-6 ml-4"
|
||||
>
|
||||
<!-- 时间线圆点 -->
|
||||
<div
|
||||
class="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white dark:border-dk-card"
|
||||
:class="commit.Status ? getStatusColorClass(commit.Status).split(' ')[0] : 'bg-gray-400'"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<span
|
||||
v-if="commit.Status"
|
||||
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:class="getStatusColorClass(commit.Status)"
|
||||
>
|
||||
{{ getStatusLabel(commit.Status) }}
|
||||
</span>
|
||||
<!-- 操作人 -->
|
||||
<span class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<img
|
||||
:src="usersStore.getAvatarUrlFromUserID(commit.UserID)"
|
||||
class="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
{{ usersStore.getUsernameFromUserID(commit.UserID) }}
|
||||
</span>
|
||||
<!-- 时间 -->
|
||||
<time class="text-xs text-gray-400">{{ formatDate(commit.CreatedAt) }}</time>
|
||||
</div>
|
||||
|
||||
<!-- 备注文字 -->
|
||||
<p
|
||||
v-if="commit.Comment && commit.Comment !== '状态变更为: ' + commit.Status && commit.Action !== 'create'"
|
||||
class="mt-1.5 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ commit.Comment }}
|
||||
</p>
|
||||
<p v-else-if="commit.Action === 'create'" class="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('work_order.commit_create') }}
|
||||
</p>
|
||||
|
||||
<!-- 进度图片 -->
|
||||
<div
|
||||
v-if="commit.photos && commit.photos.length > 0"
|
||||
class="mt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<a
|
||||
v-for="file in commit.photos"
|
||||
:key="file.ID"
|
||||
:href="getPhotoUrl(file)"
|
||||
target="_blank"
|
||||
class="group relative block h-16 w-16 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 transition hover:border-blue-400 dark:border-dk-muted dark:bg-dk-base"
|
||||
>
|
||||
<img
|
||||
:src="getPhotoUrl(file)"
|
||||
:alt="file.Name"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 关联的采购订单 -->
|
||||
<div
|
||||
v-if="commit.purchaseOrders && commit.purchaseOrders.length > 0"
|
||||
class="mt-2 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-gray-400">关联采购订单:</span>
|
||||
<RouterLink
|
||||
v-for="po in commit.purchaseOrders"
|
||||
:key="po.id"
|
||||
:to="`/purchase/showorder/${po.id}`"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
||||
>
|
||||
#{{ po.id }} {{ po.title || '' }}
|
||||
<span
|
||||
class="rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
:class="getPurchaseStatusClass(po.status)"
|
||||
>
|
||||
{{ getPurchaseStatusLabel(po.status) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { workOrderApi } from '@/api/work_order'
|
||||
import {
|
||||
IconPlus,
|
||||
IconChevronLeftPipe,
|
||||
IconChevronRightPipe,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
usePageTitle('work_order.list_title')
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToastStore()
|
||||
|
||||
const orders = ref([])
|
||||
const totalCount = ref(0)
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const statusFilter = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', labelKey: 'work_order.filter_all' },
|
||||
{ value: 'pending', labelKey: 'work_order.status_pending' },
|
||||
{ value: 'checked', labelKey: 'work_order.status_checked' },
|
||||
{ value: 'parts_ordered', labelKey: 'work_order.status_parts_ordered' },
|
||||
{ value: 'repaired', labelKey: 'work_order.status_repaired' },
|
||||
{ value: 'returned', labelKey: 'work_order.status_returned' },
|
||||
{ value: 'unrepairable', labelKey: 'work_order.status_unrepairable' },
|
||||
]
|
||||
|
||||
const statusColorMap = {
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400',
|
||||
checked: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
||||
parts_ordered: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
|
||||
repaired: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
|
||||
returned: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
unrepairable: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value) || 1)
|
||||
|
||||
const pageRange = computed(() => {
|
||||
const total = totalPages.value
|
||||
const cur = currentPage.value
|
||||
let start = Math.max(1, cur - 2)
|
||||
let end = Math.min(cur + 4, total)
|
||||
if (end - start < 4) start = Math.max(1, end - 4)
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
})
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { errCode, data } = await workOrderApi.getList({
|
||||
status: statusFilter.value,
|
||||
entries: pageSize.value,
|
||||
page: currentPage.value,
|
||||
})
|
||||
if (errCode === 0) {
|
||||
orders.value = data.all_orders ?? []
|
||||
totalCount.value = data.all_count ?? 0
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
// 拦截器已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function jumpToOrder(id) {
|
||||
router.push(`/work_order/show/${id}`)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(new Date(dateStr))
|
||||
}
|
||||
|
||||
function handlePageSizeInput(e) {
|
||||
let val = parseInt(e.target.value) || 10
|
||||
if (val > 300) val = 300
|
||||
if (val < 1) val = 1
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl px-6 py-6">
|
||||
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('work_order.list_title') }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center">
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/work_order/add"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<IconPlus :size="16" />
|
||||
{{ t('work_order.add') }}
|
||||
</RouterLink>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
@change="currentPage = 1; fetchOrders()"
|
||||
>
|
||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||
{{ t(opt.labelKey) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto px-0">
|
||||
<table class="w-full text-left text-sm text-gray-900">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
|
||||
<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 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">
|
||||
<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" />
|
||||
</svg>
|
||||
{{ t('message.loading') }}
|
||||
</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>
|
||||
</tr>
|
||||
<tr
|
||||
v-else
|
||||
v-for="order in orders"
|
||||
:key="order.ID"
|
||||
class="border-b border-gray-100 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-base cursor-pointer"
|
||||
@click="jumpToOrder(order.ID)"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="statusColorMap[order.CurrentStatus] || 'bg-gray-100 text-gray-600'"
|
||||
>
|
||||
{{ t('work_order.status_' + order.CurrentStatus) || order.CurrentStatus }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex flex-col items-center gap-3 border-t border-gray-100 px-6 py-4 sm:flex-row sm:justify-between dark:border-dk-muted">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>共 {{ totalCount }} 条</span>
|
||||
<span>每页</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="pageSize"
|
||||
min="1"
|
||||
max="300"
|
||||
class="w-14 rounded border border-gray-300 px-1.5 py-0.5 text-center text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
@change="handlePageSizeInput"
|
||||
/>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="goToPage(1)" :disabled="currentPage === 1" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
|
||||
<IconChevronsLeft :size="16" />
|
||||
</button>
|
||||
<button @click="goToPage(currentPage - 1)" :disabled="currentPage === 1" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
|
||||
<IconChevronLeftPipe :size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-for="p in pageRange"
|
||||
:key="p"
|
||||
@click="goToPage(p)"
|
||||
:class="['rounded px-2.5 py-1 text-sm', p === currentPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-muted']"
|
||||
>{{ p }}</button>
|
||||
<button @click="goToPage(currentPage + 1)" :disabled="currentPage === totalPages" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
|
||||
<IconChevronRightPipe :size="16" />
|
||||
</button>
|
||||
<button @click="goToPage(totalPages)" :disabled="currentPage === totalPages" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
|
||||
<IconChevronsRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://192.168.13.105",
|
||||
target: "http://127.0.0.1:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user