feat: 完成WMS库房管理系统功能迭代与界面优化

1.  统一系统名称为WMS库房管理,替换所有页面标题、登录页、侧边栏文字
2.  新增公共物料下拉选择组件MaterialSelect,替换多处本地物料字典加载逻辑
3.  用户列表、AGV任务列表、入库/出库/盘点/库存列表新增ID与创建时间列
4.  用户列表、物料查询等接口改为后端真分页,按创建时间倒序排序
5.  库存查询新增全局模糊搜索,支持物料编码/批次号/SN匹配
6.  库存汇总新增区域汇总视图与导出功能,修复导出表头与数据字段
7.  优化侧边栏菜单样式与布局,修复菜单图标间距与文字对齐问题
8.  完善库存查询、区域汇总的文档注释与导出字段
This commit is contained in:
SunYF
2026-09-07 15:26:37 +08:00
parent 6ee131a4bd
commit 649d2cc358
17 changed files with 364 additions and 181 deletions
+18 -3
View File
@@ -147,10 +147,25 @@ func userDTO(u *ent.User) map[string]any {
}
}
// listUsersHandler GET /api/user/list 管理员查看全部用户
// listUsersHandler GET /api/user/list 管理员查看全部用户(后端真分页,created_at desc 最新置顶)
func listUsersHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requirePerm(ctx, "user:manage", func(w http.ResponseWriter, r *http.Request) {
list, err := ctx.EntClient.User.Query().Order(ent.Asc("id")).All(ctx0())
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
q := ctx.EntClient.User.Query()
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
@@ -159,7 +174,7 @@ func listUsersHandler(ctx *svc.ServiceContext) http.HandlerFunc {
for _, u := range list {
out = append(out, userDTO(u))
}
ok(w, map[string]any{"list": out})
ok(w, map[string]any{"list": out, "total": total, "page": page, "pageSize": pageSize})
})
}
+192 -83
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
@@ -24,8 +25,9 @@ type stockKey struct {
}
// stockAggRow 聚合主列表的行结构(按 物料+区域+质量+管理粒度 聚合,只返回数量,不铺开明细)
// ID = 组内最近入库的库存行真实主键(inventory.id)——聚合视图无自身实体,取真实主键,禁止伪 id
type stockAggRow struct {
ID int64 `json:"id"` // 组合键稳定序号的伪 id,供前端行 key 使用
ID int64 `json:"id"` // inventory.id(组内最近入库行)
MaterialCode string `json:"materialCode"` // 物料编码
MaterialName string `json:"materialName"` // 物料名称
Spec string `json:"spec"` // 规格型号
@@ -49,6 +51,7 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
materialCode := r.URL.Query().Get("materialCode")
materialName := r.URL.Query().Get("materialName") // 物料名称模糊
keyword := r.URL.Query().Get("keyword") // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用)
zoneCode := r.URL.Query().Get("zoneCode")
qualityStatus := r.URL.Query().Get("qualityStatus")
manageMode := r.URL.Query().Get("manageMode") // 1/2/空=全部
@@ -61,6 +64,7 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
rows, total, err := aggregateStock(ctx, aggregateStockArgs{
MaterialCode: materialCode,
MaterialName: materialName,
Keyword: keyword,
ZoneCode: zoneCode,
QualityStatus: qualityStatus,
ManageMode: manageMode,
@@ -87,6 +91,7 @@ func queryStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
type aggregateStockArgs struct {
MaterialCode string
MaterialName string
Keyword string // 模糊搜索:物料编码/批次号/SN 任一匹配(下拉拾取器用)
ZoneCode string
QualityStatus string
ManageMode string
@@ -114,6 +119,14 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
if a.MaterialName != "" {
q = q.Where(inventory.MaterialNameContains(a.MaterialName))
}
if a.Keyword != "" {
// 模糊搜索:物料编码 / 批次号 / SN 任一命中(库存拾取下拉用);ContainsFold 大小写不敏感
q = q.Where(inventory.Or(
inventory.MaterialCodeContainsFold(a.Keyword),
inventory.BatchNoContainsFold(a.Keyword),
inventory.SnCodeContainsFold(a.Keyword),
))
}
if a.ZoneCode != "" {
q = q.Where(inventory.ZoneCodeEQ(a.ZoneCode))
}
@@ -184,6 +197,7 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
snCnt int
lastInbNo string
lastTime int64
lastInvID int64 // 组内最近入库的库存行真实主键(inventory.id),作行标识
}
group := map[stockKey]*agg{}
order := []stockKey{}
@@ -210,9 +224,11 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
}
g.snCnt++
}
// all 已按 created_at desc, id desc 排序,首见即最近一条
if inv.CreatedAt > g.lastTime {
g.lastTime = inv.CreatedAt
g.lastInbNo = inv.InboundNo
g.lastInvID = int64(inv.ID)
}
}
@@ -235,7 +251,7 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
name = key.MaterialCode
}
rows = append(rows, &stockAggRow{
ID: int64(i + 1),
ID: g.lastInvID, // 组内最近入库的库存行真实主键(inventory.id),禁止伪 id
MaterialCode: key.MaterialCode,
MaterialName: name,
Spec: matSpec[key.MaterialCode],
@@ -256,23 +272,26 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
// materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态)
// 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。
// ID 取物料主表(material)主键——聚合视图无自身实体,主表 ID 即行标识,禁止伪 id。
type materialAggRow struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
ManageMode int `json:"manageMode"` // 1结构件/2精密件
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
Zones []string `json:"zones"` // 分布区域列表
ZoneCount int `json:"zoneCount"` // 分布区域
BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数(精密件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间
ID int64 `json:"id"` // 物料主表 IDmaterial.id,档案缺失时为 0
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
ManageMode int `json:"manageMode"` // 1结构件/2精密件
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
Zones []string `json:"zones"` // 分布区域列表
ZoneCount int `json:"zoneCount"` // 分布区域数
BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数(精密件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
MaterialCreatedAt int64 `json:"materialCreatedAt"` // 物料主表创建时间(material.created_at
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间
}
// materialSummaryArgs 物料汇总的筛选条件
@@ -328,9 +347,11 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
return nil, 0, err
}
// 物料名称/规格补全
// 物料名称/规格/主表ID/主表创建时间补全
matName := map[string]string{}
matSpec := map[string]string{}
matID := map[string]int64{}
matCreatedAt := map[string]int64{}
matCodes := []string{}
seen := map[string]bool{}
for _, inv := range all {
@@ -345,6 +366,8 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
for _, m := range mats {
matName[m.Code] = m.Name
matSpec[m.Code] = m.Spec
matID[m.Code] = int64(m.ID)
matCreatedAt[m.Code] = m.CreatedAt
}
}
@@ -426,22 +449,24 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
name = code
}
rows = append(rows, &materialAggRow{
MaterialCode: code,
MaterialName: name,
Spec: matSpec[code],
ManageMode: g.mMode,
TotalQty: g.totalQty,
LockedQty: g.lockedQty,
AvailQty: g.totalQty - g.lockedQty,
QtyQualified: g.qQualified,
QtyPending: g.qPending,
QtyRejected: g.qRejected,
Zones: g.zones,
ZoneCount: len(g.zones),
BatchCount: g.batchCnt,
SnCount: g.snCnt,
LastInboundNo: g.lastInbNo,
CreatedAt: g.lastTime,
ID: matID[code],
MaterialCode: code,
MaterialName: name,
Spec: matSpec[code],
ManageMode: g.mMode,
TotalQty: g.totalQty,
LockedQty: g.lockedQty,
AvailQty: g.totalQty - g.lockedQty,
QtyQualified: g.qQualified,
QtyPending: g.qPending,
QtyRejected: g.qRejected,
Zones: g.zones,
ZoneCount: len(g.zones),
BatchCount: g.batchCnt,
SnCount: g.snCnt,
LastInboundNo: g.lastInbNo,
MaterialCreatedAt: matCreatedAt[code],
CreatedAt: g.lastTime,
})
}
return rows, total, nil
@@ -512,6 +537,27 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。
func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("view") == "zone" {
out, err := buildZoneSummary(ctx)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
headers := []string{"ID", "区域编码", "区域名称", "质量状态", "类型", "数量", "区域创建时间", "最近入库时间"}
matrix := make([][]any, 0, len(out))
for _, rw := range out {
id := any("")
if rw.ID > 0 {
id = rw.ID
}
matrix = append(matrix, []any{
id, rw.ZoneCode, rw.ZoneName, rw.Quality, rw.Type, rw.Qty,
unixFmt(rw.ZoneCreatedAt), unixFmt(rw.LastCreatedAt),
})
}
sendExcel(w, xlsxFilename("区域汇总"), headers, matrix)
return
}
if r.URL.Query().Get("view") == "material" {
rows, _, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
@@ -524,15 +570,19 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
headers := []string{"物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "最近入库时间"}
headers := []string{"ID", "物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "物料创建时间", "最近入库时间"}
matrix := make([][]any, 0, len(rows))
for _, rw := range rows {
id := any("")
if rw.ID > 0 {
id = rw.ID
}
matrix = append(matrix, []any{
rw.MaterialCode, rw.MaterialName, rw.Spec,
id, rw.MaterialCode, rw.MaterialName, rw.Spec,
manageModeLabel(rw.ManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty,
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
strings.Join(rw.Zones, "、"), rw.BatchCount, rw.SnCount,
rw.LastInboundNo, unixFmt(rw.CreatedAt),
rw.LastInboundNo, unixFmt(rw.MaterialCreatedAt), unixFmt(rw.CreatedAt),
})
}
sendExcel(w, xlsxFilename("物料汇总"), headers, matrix)
@@ -554,11 +604,11 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
headers := []string{"物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"}
headers := []string{"ID", "物料编码", "物料名称", "规格", "类型", "区域", "质量状态", "总数量", "锁定量", "可用量", "批次数", "SN数", "最近入库单", "最近入库时间"}
matrix := make([][]any, 0, len(rows))
for _, rw := range rows {
matrix = append(matrix, []any{
rw.MaterialCode, rw.MaterialName, rw.Spec,
rw.ID, rw.MaterialCode, rw.MaterialName, rw.Spec,
manageModeLabel(rw.ManageMode), rw.ZoneCode, rw.QualityStatus,
rw.TotalQty, rw.LockedQty, rw.AvailQty, rw.BatchCount, rw.SnCount,
rw.LastInboundNo, unixFmt(rw.CreatedAt),
@@ -567,54 +617,113 @@ func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
sendExcel(w, xlsxFilename("库存汇总"), headers, matrix)
}
}
// zoneSummRow 区域汇总行(按 区域 + 质量状态 + 类型 聚合)
// ID/ZoneCreatedAt 取区域主表(zone)主键与创建时间——聚合视图无自身实体,主表字段即行标识,禁止伪 id。
type zoneSummRow struct {
ID int64 `json:"id"` // 区域主表 ID(zone.id,未分区时为 0
ZoneCode string `json:"zoneCode"`
ZoneName string `json:"zoneName"`
Quality string `json:"quality"`
Type string `json:"type"` // 批次/精密件
Qty int `json:"qty"`
ZoneCreatedAt int64 `json:"zoneCreatedAt"` // 区域主表创建时间(zone.created_at
LastCreatedAt int64 `json:"lastCreatedAt"` // 组内最近一次入库时间
}
// buildZoneSummary 区域汇总聚合:供列表接口与导出共用。
// 行排序固定(区域编码 → 质量 → 类型),避免 map 迭代顺序随机导致每次刷新行序跳动。
func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
zones, err := ctx.EntClient.Zone.Query().All(ctx0())
if err != nil {
return nil, err
}
zoneNameMap := map[string]string{}
zoneIDMap := map[string]int64{}
zoneCreatedAtMap := map[string]int64{}
for _, z := range zones {
zoneNameMap[z.ZoneCode] = z.ZoneName
zoneIDMap[z.ZoneCode] = int64(z.ID)
zoneCreatedAtMap[z.ZoneCode] = z.CreatedAt
}
rows, err := ctx.EntClient.Inventory.Query().
Where(inventory.StatusIn("在库", "锁定")).All(ctx0())
if err != nil {
return nil, err
}
type aggInfo struct {
qty int
lastCreated int64
}
agg := map[string]*aggInfo{} // key: zc|quality|type
for _, inv := range rows {
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
t := "批次"
if inv.ManageMode == 2 {
t = "精密件"
}
key := zc + "|" + inv.QualityStatus + "|" + t
g, ok := agg[key]
if !ok {
g = &aggInfo{}
agg[key] = g
}
if inv.ManageMode == 1 {
g.qty += inv.Quantity
} else {
g.qty += 1
}
if inv.CreatedAt > g.lastCreated {
g.lastCreated = inv.CreatedAt
}
}
out := make([]zoneSummRow, 0, len(agg))
for k, v := range agg {
parts := strings.Split(k, "|")
zc := parts[0]
name := zoneNameMap[zc]
if name == "" {
name = zc
}
out = append(out, zoneSummRow{
ID: zoneIDMap[zc],
ZoneCode: zc,
ZoneName: name,
Quality: parts[1],
Type: parts[2],
Qty: v.qty,
ZoneCreatedAt: zoneCreatedAtMap[zc],
LastCreatedAt: v.lastCreated,
})
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.ZoneCode != b.ZoneCode {
return a.ZoneCode < b.ZoneCode
}
if a.Quality != b.Quality {
return a.Quality < b.Quality
}
return a.Type < b.Type
})
return out, nil
}
// zoneSummaryHandler 区域库存汇总(按 区域 + 质量状态 + 类型 聚合)
// GET /api/stock/zone-summary
// 返回 rows: [{zoneCode, zoneName, quality, type(批次/精密件), qty}]
// 返回 rows: zoneSummRow(含区域主表 ID/创建时间 + 组内最近入库时间)
// 仅按"区域(Z01~Z04)"维度聚合,不引入货架/层/格(业务决策:小货架高频取货无需精确货位索引)。
func zoneSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
zones, _ := ctx.EntClient.Zone.Query().All(ctx0())
zoneNameMap := map[string]string{}
for _, z := range zones {
zoneNameMap[z.ZoneCode] = z.ZoneName
}
rows, _ := ctx.EntClient.Inventory.Query().
Where(inventory.StatusIn("在库", "锁定")).All(ctx0())
type summRow struct {
ZoneCode string `json:"zoneCode"`
ZoneName string `json:"zoneName"`
Quality string `json:"quality"`
Type string `json:"type"` // 批次/精密件
Qty int `json:"qty"`
}
agg := map[string]int{} // key: zc|quality|type
for _, inv := range rows {
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
t := "批次"
if inv.ManageMode == 2 {
t = "精密件"
}
key := zc + "|" + inv.QualityStatus + "|" + t
if inv.ManageMode == 1 {
agg[key] += inv.Quantity
} else {
agg[key] += 1
}
}
out := make([]summRow, 0, len(agg))
for k, v := range agg {
parts := strings.Split(k, "|")
name := zoneNameMap[parts[0]]
if name == "" {
name = parts[0]
}
out = append(out, summRow{parts[0], name, parts[1], parts[2], v})
out, err := buildZoneSummary(ctx)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"rows": out})
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>库房客户端</title>
<title>WMS库房管理</title>
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
</head>
@@ -0,0 +1,53 @@
<script setup>
// 物料下拉选择器(公共组件)
// 规范:数据量不确定的下拉只加载最近 100 条(created_at desc),支持远程模糊搜索(keyword 查询,结果仍封顶 100)。
// 选中后通过 item-change 事件抛出完整物料对象(无匹配为 null),供页面校验 manageMode 等。
import { ref, onMounted } from 'vue'
import request from '../utils/request'
const props = defineProps({
modelValue: { type: [String, Array], default: '' },
multiple: { type: Boolean, default: false },
manageMode: { type: Number, default: 0 }, // 0=全部 1=结构件 2=精密件
placeholder: { type: String, default: '选择物料' },
clearable: { type: Boolean, default: true },
width: { type: String, default: '100%' }
})
const emit = defineEmits(['update:modelValue', 'item-change'])
const options = ref([])
const loading = ref(false)
// 最近 100 条 + keyword 模糊搜索(后端 /material/query 兼容空 keyword
async function load(kw = '') {
loading.value = true
try {
const data = await request.get('/material/query', {
params: { page: 1, pageSize: 100, keyword: kw || '', manageMode: props.manageMode || '' }
})
options.value = data?.list || []
} catch {
options.value = []
} finally {
loading.value = false
}
}
function onChange(v) {
emit('update:modelValue', v)
if (props.multiple) {
emit('item-change', options.value.filter((o) => v.includes(o.code)))
} else {
emit('item-change', options.value.find((o) => o.code === v) || null)
}
}
</script>
<template>
<el-select :model-value="modelValue" :multiple="multiple" filterable remote clearable
:remote-method="load" :loading="loading" :placeholder="placeholder"
:style="{ width }" @change="onChange">
<el-option v-for="m in options" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
</template>
+4 -3
View File
@@ -1,4 +1,4 @@
// WMS 库房客户端 页面级操作说明:整体流程 + 各字段 数据来源/用途/怎么填
// WMS库房管理 页面级操作说明:整体流程 + 各字段 数据来源/用途/怎么填
// 由 components/PageHelp.vue 在左下角 "?" 按钮弹出展示。
// 说明:这里的"数据来源"特指该字段数据由谁产生、从哪个系统/页面来,帮助跑通真实流程。
@@ -60,7 +60,7 @@ export const helpOutbound = {
export const helpInventory = {
title: '库存查询',
overview:
'查看当前库存:结构件按批次(数量/锁定/区域),精密件按 SN(状态/区域)。数据由入库、出库、检验、盘点等业务实时维护,本页只读。\n\n本页三个视图页签(首屏默认「物料汇总」):\n· 物料汇总:一物料一行,直接回答"这个物料总共有多少"——总量/锁定/可用 + 质量分布(合格/未检/不合格) + 分布在哪些区域;点行下钻该物料的批次/SN 明细。\n· 库存明细:按 物料 × 区域 × 质量 聚合,可组合 区域/质量状态/入库时间 筛选(物料汇总不受区域/质量筛选影响)。\n· 区域汇总:按 区域 × 质量状态 × 类型 看各库区的货量。\n\n层级概念:入库单 → 批次/SN → 库存行。区域/质量状态/批次/最近入库单都是某物料的一对多属性,放在下钻明细中查看,不在物料行上并排平铺。',
'查看当前库存:结构件按批次(数量/锁定/区域),精密件按 SN(状态/区域)。数据由入库、出库、检验、盘点等业务实时维护,本页只读。\n\n本页三个视图页签(首屏默认「物料汇总」):\n· 物料汇总:一物料一行,直接回答"这个物料总共有多少"——总量/锁定/可用 + 质量分布(合格/未检/不合格) + 分布在哪些区域;ID 与创建时间取自物料主表,另有最近入库单/最近入库时间;点行下钻该物料的批次/SN 明细。\n· 库存明细:按 物料 × 区域 × 质量 聚合,可组合 区域/质量状态/入库时间 筛选(物料汇总不受区域/质量筛选影响)。\n· 区域汇总:按 区域 × 质量状态 × 类型 看各库区的货量;ID 与创建时间取自区域主表,另有组内最近入库时间;支持全量导出。\n\n层级概念:入库单 → 批次/SN → 库存行。区域/质量状态/批次/最近入库单都是某物料的一对多属性,放在下钻明细中查看,不在物料行上并排平铺。',
fields: [
{ name: '物料编码 / 物料名称', source: '筛选条件。', purpose: '只看某个物料', fill: '输入编码或名称,可空=全部' },
{ name: '库存类型', source: '单选(全部/结构件/精密件)。', purpose: '按管理粒度过滤', fill: '全部 / 结构件(批次) / 精密件(SN)' },
@@ -68,7 +68,8 @@ export const helpInventory = {
{ name: '物料汇总-分布区域', source: '系统统计该物料当前占用了哪些区域。', purpose: '一个物料可跨多个区域存放(批次可拆分),物料行不写死单一区域', fill: '如 Z02、Z05;点行下钻可看每个批次/SN 具体在哪个区' },
{ name: '库存明细-区域/质量/入库时间', source: '仅「库存明细」视图生效的筛选。', purpose: '在物料基础上再按存放区/质量状态/入库时间收窄', fill: '可组合筛选' },
{ name: '批次/SN 明细(下钻)', source: '点击任一行弹出抽屉。', purpose: '看该物料的每个批次/SN 所在区域、质量状态、锁定与来源入库单', fill: '抽屉内分页展示;点入库单号可跳转入库页' },
{ name: '导出', source: '按钮。后端按当前筛选全量导出 xlsx。', purpose: '把结果导出存档', fill: '物料汇总视图导出「物料汇总.xlsx」;库存明细视图导出「库存汇总.xlsx」' }
{ name: 'ID / 创建时间', source: '物料汇总行取物料主表(material),区域汇总行取区域主表(zone)。', purpose: '行标识与主档建立时间,用于追溯与对账', fill: '系统自动带出,只读;物料档案/区域缺失时显示 -' },
{ name: '导出', source: '按钮。后端按当前筛选全量导出 xlsx。', purpose: '把结果导出存档', fill: '物料汇总视图导出「物料汇总.xlsx」;库存明细视图导出「库存汇总.xlsx」;区域汇总视图导出「区域汇总.xlsx」' }
]
}
@@ -167,22 +167,22 @@ function onLogout() {
:class="{ 'drawer': isMobile, 'drawer-open': isMobile && mobileOpen }">
<div class="brand">
<el-icon :size="22"><Box /></el-icon>
<span v-show="!isCollapse">库房客户端</span>
<span v-show="!isCollapse">WMS库房管理</span>
</div>
<el-menu :key="menuKey" :default-active="route.path" :default-openeds="defaultOpeneds" class="menu"
background-color="#001529" text-color="#c2cad8" active-text-color="#ffffff" router
background-color="#001529" text-color="#a6adb4" active-text-color="#409eff" router
:collapse="isCollapse && !isMobile">
<el-menu-item v-if="showTopMenu" :index="topMenu.path">
<el-icon><component :is="topMenu.icon" /></el-icon>
<el-icon style="margin-right:8px"><component :is="topMenu.icon" /></el-icon>
<span>{{ topMenu.title }}</span>
</el-menu-item>
<el-sub-menu v-for="g in menus" :key="g.title" :index="'group:' + g.title">
<template #title>
<el-icon><component :is="g.icon" /></el-icon>
<el-icon style="margin-right:8px"><component :is="g.icon" /></el-icon>
<span>{{ g.title }}</span>
</template>
<el-menu-item v-for="m in g.children" :key="m.path" :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<el-icon style="margin-right:8px"><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</el-menu-item>
</el-sub-menu>
@@ -231,10 +231,9 @@ function onLogout() {
.drawer-open { transform: translateX(0); }
.drawer-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 1000; }
.brand {
display: flex; align-items: center; gap: 8px;
height: 56px; padding: 0 16px;
color: #fff; font-weight: 600; font-size: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 60px;
color: #fff; font-weight: 600; font-size: 17px;
white-space: nowrap;
}
.menu { border-right: none; }
@@ -162,6 +162,7 @@ onMounted(() => { loadDocks(); loadTasks() })
<el-button @click="taskPage.current = 1; loadTasks()">查询</el-button>
</div>
<el-table :data="tasks" v-loading="loadingTasks" border stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="taskNo" label="任务号" width="150" />
<el-table-column prop="sourceDock" label="源" width="80" align="center" />
<el-table-column prop="targetDock" label="目标" width="80" align="center" />
@@ -186,6 +187,9 @@ onMounted(() => { loadDocks(); loadTasks() })
<el-table-column label="到位时间" width="150">
<template #default="{ row }">{{ row.arrivedAt ? fmtTime(row.arrivedAt) : '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="155" align="center">
<template #default="{ row }">{{ row.createdAt ? fmtTime(row.createdAt) : '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="下发人" width="100" />
<el-table-column prop="hikTaskCode" label="RCS任务码" width="150" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
@@ -9,6 +9,7 @@ import { fmtTime } from '../utils/format'
import { exportXlsxFetch } from '../utils/export'
import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import MaterialSelect from '../components/MaterialSelect.vue'
import { helpInbound } from '../help'
const route = useRoute()
@@ -17,20 +18,14 @@ const operator = getRealName()
// ===== 多 Tab:结构件入库 / 精密件 SN 入库 在前(常操作),入库记录在后 =====
const activeTab = ref('batch') // batch=结构件入库 / sn=精密件SN入库 / records=入库记录
// 公共字典:物料 / 区域
const materials = ref([])
// 公共字典:区域(物料下拉由 MaterialSelect 组件按需远程搜索,最近 100 条)
const zones = ref([])
async function loadDicts() {
if (materials.value.length && zones.value.length) return
const [ms, zs] = await Promise.all([request.get('/material/list'), request.get('/zone/picker')])
materials.value = Array.isArray(ms) ? ms : ms?.list || []
if (zones.value.length) return
const zs = await request.get('/zone/picker')
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
// 按当前页过滤物料:结构件页只显示批次管理(manageMode=1)SN 页只显示序列号管理(manageMode=2)
const batchMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 1))
const snMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 2))
/* ===================== Tab1 结构件入库(批次,支持多批到货追加) ===================== */
const batchRef = ref()
const submitting1 = ref(false)
@@ -51,11 +46,6 @@ async function submitBatch() {
ElMessage.warning('数量必须大于 0')
return
}
const mat1 = materials.value.find(m => m.code === form1.materialCode)
if (mat1 && Number(mat1.manageMode) === 2) {
ElMessage.warning('该物料为精密件(按 SN 管理),请切换到「精密件 SN 入库」页扫码录入后再提交')
return
}
submitting1.value = true
try {
const data = await request.post('/inbound/create', {
@@ -117,11 +107,6 @@ async function submitSn() {
ElMessage.warning('请先扫码录入 SN(输入后点击【录入到列表】)')
return
}
const mat2 = materials.value.find(m => m.code === form2.materialCode)
if (mat2 && Number(mat2.manageMode) === 1) {
ElMessage.warning('该物料为结构件(按批次管理),请切换到「结构件入库」页按数量入库')
return
}
submitting2.value = true
try {
const data = await request.post('/inbound/create', {
@@ -299,10 +284,7 @@ onMounted(() => {
<el-card shadow="never">
<el-form ref="batchRef" :model="form1" :rules="rules1" label-width="110px" style="max-width:560px">
<el-form-item label="物料" prop="materialCode">
<el-select v-model="form1.materialCode" filterable placeholder="选择结构件物料" style="width:100%">
<el-option v-for="m in batchMaterials" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="form1.materialCode" :manage-mode="1" placeholder="选择结构件物料" />
</el-form-item>
<el-form-item label="批次号">
<el-input v-model="form1.batchNo" placeholder="留空则自动生成" clearable />
@@ -355,10 +337,7 @@ onMounted(() => {
<el-card shadow="never">
<el-form label-width="110px" style="max-width:680px">
<el-form-item label="物料" required>
<el-select v-model="form2.materialCode" filterable placeholder="请选择精密件物料必填" style="width:100%">
<el-option v-for="m in snMaterials" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="form2.materialCode" :manage-mode="2" placeholder="请选择精密件物料必填" />
</el-form-item>
<el-form-item label="区域" required>
<el-select v-model="form2.zoneCode" placeholder="请选择入库区域必填" clearable style="width:100%">
@@ -451,6 +430,7 @@ onMounted(() => {
<!-- 入库单列表(一单一行,结构件/精密件同表) -->
<el-card shadow="never" header="入库单每行一笔入库明细在右侧抽屉查看该单的行">
<el-table :data="list" border size="small" v-loading="loadingRec" empty-text="暂无入库数据">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="inboundNo" label="入库单号" min-width="160" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
@@ -38,17 +38,22 @@ const inputPlaceholder = computed(() =>
: '扫入或粘贴 SN 序列号,可用 逗号/分号/空格/回车 分隔,一次可录多条'
)
// 库存拾取器:直接选已存在的批次号/SN,避免手输(垃圾设计)
// 库存拾取器:直接选已存在的批次号/SN,避免手输
// 规范:库存量不确定 → 只加载最近 100 条(created_at desc),输入走远程模糊搜索(keyword 匹配 物料编码/批次号/SN)
const pickerOptions = ref([])
async function loadPicker() {
const pickerLoading = ref(false)
async function loadPicker(kw = '') {
pickerLoading.value = true
try {
const mm = targetType.value === 'BATCH' ? 1 : 2
const data = await request.get('/stock/query', {
params: { manageMode: mm, page: 1, pageSize: 500 }
params: { manageMode: mm, keyword: kw || '', page: 1, pageSize: 100 }
})
pickerOptions.value = data?.list || []
} catch (e) {
pickerOptions.value = []
} finally {
pickerLoading.value = false
}
}
@@ -208,7 +213,7 @@ onMounted(() => {
<el-form label-width="96px">
<!-- 目标类型直接改变输入框标题与占位直观体现当前录入对象 -->
<el-form-item label="目标类型">
<el-radio-group v-model="targetType" @change="loadPicker">
<el-radio-group v-model="targetType" @change="() => loadPicker()">
<el-radio-button value="BATCH">批次 (结构件)</el-radio-button>
<el-radio-button value="SN">序列号 SN (精密件)</el-radio-button>
</el-radio-group>
@@ -239,13 +244,16 @@ onMounted(() => {
<el-divider content-position="left">待检编号可扫/选多条统一提交</el-divider>
<!-- 从库存直接拾取避免手输批次号 -->
<!-- 从库存直接拾取避免手输批次号远程模糊搜索最近 100 -->
<el-form-item :label="`从库存选${inputLabel}`">
<el-select
v-model="pickerValue"
filterable
remote
clearable
:placeholder="`选择已存在的${inputLabel}(来自库存)`"
:remote-method="loadPicker"
:loading="pickerLoading"
:placeholder="`输入批次号/SN/物料编码搜索(最近100条)`"
style="width:100%"
@change="addFromPicker"
>
@@ -190,7 +190,7 @@ function gotoInbound(no) {
router.push({ path: '/inbound', query: { inboundNo: no } })
}
// 导出:按当前视图导出对应粒度(物料汇总/库存明细)
// 导出:按当前视图导出对应粒度(物料汇总 / 库存明细 / 区域汇总
async function exportCsv() {
try {
if (viewTab.value === 'material') {
@@ -202,6 +202,10 @@ async function exportCsv() {
}, '物料汇总.xlsx')
return
}
if (viewTab.value === 'summary') {
await exportXlsxFetch('/api/stock/export', { view: 'zone' }, '区域汇总.xlsx')
return
}
await exportXlsxFetch('/api/stock/export', {
materialCode: filters.materialCode.trim(),
materialName: filters.materialName.trim(),
@@ -282,6 +286,9 @@ onMounted(() => {
<el-card v-if="viewTab === 'material'" shadow="never" header="物料汇总(一物料一行:总数 + 质量分布 + 分布区域,点击行下钻批次/SN 明细)">
<el-table :data="matRows" border size="small" v-loading="matLoading" empty-text="暂无库存数据"
@row-click="openDetails" row-key="materialCode">
<el-table-column label="ID" prop="id" width="70" align="center">
<template #default="{ row }">{{ row.id || '-' }}</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="140">
<template #default="{ row }">
<span class="link">{{ row.materialCode }}</span>
@@ -333,6 +340,9 @@ onMounted(() => {
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="物料创建时间" width="155" align="center">
<template #default="{ row }">{{ row.materialCreatedAt ? fmtTime(row.materialCreatedAt) : '-' }}</template>
</el-table-column>
<el-table-column label="最近入库时间" width="155" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
@@ -405,8 +415,18 @@ onMounted(() => {
</el-card>
<!-- ============ 视图三:区域汇总 ============ -->
<el-card v-if="viewTab === 'summary'" shadow="never" header="各区域库存数量 区域 + 质量状态 + 类型">
<el-card v-if="viewTab === 'summary'" shadow="never">
<template #header>
<div style="display:flex;align-items:center;justify-content:space-between">
<span>各区域库存数量(按 区域 + 质量状态 + 类型)</span>
<el-button v-if="can('inventory:export')" :disabled="!summaryRows.length" size="small"
@click="exportCsv">导出区域汇总</el-button>
</div>
</template>
<el-table :data="summaryRows" border size="small" v-loading="summaryLoading" empty-text="暂无数据">
<el-table-column label="ID" prop="id" width="70" align="center">
<template #default="{ row }">{{ row.id || '-' }}</template>
</el-table-column>
<el-table-column prop="zoneCode" label="区域编码" width="110" />
<el-table-column prop="zoneName" label="区域名称" min-width="140" />
<el-table-column prop="quality" label="质量状态" width="110" align="center">
@@ -417,6 +437,12 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="type" label="类型" width="100" align="center" />
<el-table-column prop="qty" label="数量" width="120" align="center" />
<el-table-column label="区域创建时间" width="155" align="center">
<template #default="{ row }">{{ row.zoneCreatedAt ? fmtTime(row.zoneCreatedAt) : '-' }}</template>
</el-table-column>
<el-table-column label="最近入库时间" width="155" align="center">
<template #default="{ row }">{{ row.lastCreatedAt ? fmtTime(row.lastCreatedAt) : '-' }}</template>
</el-table-column>
</el-table>
</el-card>
@@ -45,7 +45,7 @@ async function onSubmit() {
<div class="login-page">
<el-card class="card">
<div class="head">
<h2>库房客户端</h2>
<h2>WMS库房管理</h2>
<p>请使用 WMS 账号登录</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @keyup.enter="onSubmit">
@@ -8,6 +8,7 @@ import { fmtTime } from '../utils/format'
import { exportXlsxFetch } from '../utils/export'
import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import MaterialSelect from '../components/MaterialSelect.vue'
import { helpOutbound } from '../help'
const activeTab = ref('prep') // prep=备料出库 / general=通用出库 / records=出库记录
@@ -98,7 +99,7 @@ async function queryPrep() {
}
/* ===================== 通用出库(手动,不依赖工单) ===================== */
const materials = ref([])
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
const zones = ref([])
const genForm = reactive({ materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '', boxNo: '', contractNo: '', remark: '' })
const snInput = ref('')
@@ -120,9 +121,9 @@ function addSnLines() {
function removeSn(i) { snList.value.splice(i, 1) }
async function loadDicts() {
if (materials.value.length) return
const [ms, zs] = await Promise.all([request.get('/material/list'), request.get('/zone/picker')])
materials.value = Array.isArray(ms) ? ms : ms?.list || []
if (zones.value.length) return
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条),此处只加载区域
const zs = await request.get('/zone/picker')
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
@@ -278,9 +279,7 @@ onMounted(loadDicts)
<el-card shadow="never" class="mb12" header="通用出库(不依赖工单:退料 / 样品 / 报废 / 发货)">
<el-form label-width="96px" style="max-width:880px">
<el-form-item label="物料" required>
<el-select v-model="genForm.materialCode" filterable placeholder="选择物料" style="width:100%">
<el-option v-for="m in materials" :key="m.id" :value="m.code" :label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="genForm.materialCode" placeholder="选择物料" />
</el-form-item>
<el-form-item label="出库类型">
<el-radio-group v-model="genForm.mode">
@@ -378,9 +377,7 @@ onMounted(loadDicts)
</el-select>
</el-form-item>
<el-form-item label="物料">
<el-select v-model="recFilters.materialCode" filterable clearable placeholder="物料" style="width:180px">
<el-option v-for="m in materials" :key="m.id" :value="m.code" :label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="recFilters.materialCode" placeholder="物料" width="180px" />
</el-form-item>
<el-form-item label="箱号">
<el-input v-model="recFilters.boxNo" placeholder="按箱号查询" clearable style="width:160px" @keyup.enter="searchRec" />
@@ -6,16 +6,13 @@ import request from '../utils/request'
import { fmtTime } from '../utils/format'
import { getRealName } from '../utils/auth'
import PageHelp from '../components/PageHelp.vue'
import MaterialSelect from '../components/MaterialSelect.vue'
import { helpSemi } from '../help'
/* ---------- 半成品/成品入库(携带已完成工序,连续扫码) ---------- */
const materials = ref([])
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
onMounted(async () => {
try {
const ms = await request.get('/material/list')
materials.value = Array.isArray(ms) ? ms : ms?.list || []
} catch { /* 字典加载失败不阻塞入库 */ }
onMounted(() => {
loadList()
})
@@ -129,10 +126,7 @@ function search() {
<el-tab-pane label="半成品/成品入库">
<el-form label-width="110px" style="max-width:680px">
<el-form-item label="物料">
<el-select v-model="inForm.materialCode" filterable placeholder="选择物料编码" style="width:100%">
<el-option v-for="m in materials" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="inForm.materialCode" placeholder="选择物料编码" />
</el-form-item>
<el-form-item label="已完成工序">
<el-input v-model="inForm.completedProcess" placeholder="如:1,3,5(可空)" clearable />
@@ -200,6 +194,7 @@ function search() {
</el-form-item>
</el-form>
<el-table :data="list" border size="small" v-loading="loading" empty-text="暂无记录">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="sn" label="SN" min-width="200" />
<el-table-column prop="materialCode" label="物料编码" min-width="140" />
<el-table-column prop="completedProcess" label="已完成工序" min-width="120">
@@ -6,6 +6,7 @@ import request from '../utils/request'
import { fmtTime } from '../utils/format'
import { getRealName } from '../utils/auth'
import PageHelp from '../components/PageHelp.vue'
import MaterialSelect from '../components/MaterialSelect.vue'
import { helpStocktake } from '../help'
/* ---------- 发起盘点(可选范围:全库 / 按区域 / 按物料) ---------- */
@@ -21,19 +22,14 @@ const scope = reactive({
qualityStatus: '' // 空=全部
})
// 发起范围所需的字典
// 发起范围所需的字典(物料下拉由 MaterialSelect 组件按需远程搜索,最近 100 条)
const zonesDict = ref([])
const materialsDict = ref([])
async function loadScopeDicts() {
try {
const z = await request.get('/zone/picker')
const arr = Array.isArray(z) ? z : (z?.list || [])
zonesDict.value = arr.map(x => x.zoneCode || x.code).filter(Boolean)
} catch { /* 忽略 */ }
try {
const m = await request.get('/material/query', { params: { page: 1, pageSize: 500 } })
materialsDict.value = (m?.list || []).map(x => ({ code: x.code, name: x.name }))
} catch { /* 忽略 */ }
}
function openScope() {
@@ -222,11 +218,8 @@ const currentStep = computed(() => {
</el-form-item>
<template v-if="scope.type === 'material'">
<el-form-item label="物料">
<el-select v-model="scope.materialCodes" multiple filterable clearable
placeholder="选择盘点物料(可多选" style="width:100%">
<el-option v-for="m in materialsDict" :key="m.code"
:value="m.code" :label="`${m.code} ${m.name || ''}`" />
</el-select>
<MaterialSelect v-model="scope.materialCodes" multiple
placeholder="选择盘点物料(可多选,输入可搜索)" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="scope.manageMode" clearable placeholder="全部类型" style="width:100%">
@@ -319,6 +312,7 @@ const currentStep = computed(() => {
</el-form-item>
</el-form>
<el-table :data="history" border size="small" empty-text="暂无盘点记录">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="stocktakeNo" label="盘点单号" min-width="200" />
<el-table-column prop="operator" label="操作人" width="110" align="center">
<template #default="{ row }">{{ row.operator || '-' }}</template>
@@ -22,28 +22,27 @@ async function loadRoles() {
} catch { /* 无权限时不阻断用户页 */ }
}
const all = ref([]) // 全量(无分页全量拉取,页面前端分页)
const list = ref([])
const total = ref(0)
const loading = ref(false)
const page = reactive({ current: 1, size: 20 })
// 后端真分页(created_at desc 最新置顶),禁止前端全量拉取后本地分页
async function loadUsers() {
loading.value = true
try {
const data = await request.get('/user/list')
all.value = data?.list || []
sliceList()
const data = await request.get('/user/list', {
params: { page: page.current, pageSize: page.size }
})
list.value = data?.list || []
total.value = data?.total || 0
} finally {
loading.value = false
}
}
function sliceList() {
const start = (page.current - 1) * page.size
list.value = all.value.slice(start, start + page.size)
}
function onPage() {
sliceList()
loadUsers()
}
function roleName(row) {
@@ -185,6 +184,9 @@ onMounted(() => {
<el-table-column label="最近登录" width="150" align="center">
<template #default="{ row }">{{ row.lastLoginAt ? fmtTime(row.lastLoginAt) : '从未' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="155" align="center">
<template #default="{ row }">{{ row.createdAt ? fmtTime(row.createdAt) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="can('user:edit')" link type="primary" size="small" :disabled="row.username === 'admin'"
@@ -196,8 +198,8 @@ onMounted(() => {
</el-table>
<el-pagination v-model:current-page="page.current" v-model:page-size="page.size" :page-sizes="[10, 20, 50]"
:total="all.length" background layout="total, sizes, prev, pager, next, jumper" style="margin-top:12px"
@size-change="onPage" @current-change="onPage" />
:total="total" background layout="total, sizes, prev, pager, next, jumper" style="margin-top:12px"
@size-change="() => { page.current = 1; loadUsers() }" @current-change="onPage" />
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑账号' : '新增账号'" width="520px"
@@ -44,7 +44,7 @@ router.beforeEach((to) => {
})
router.afterEach((to) => {
document.title = to.meta?.title ? `${to.meta.title} - 库房客户端` : '库房客户端'
document.title = to.meta?.title ? `${to.meta.title} - WMS库房管理` : 'WMS库房管理'
})
export default router
+3 -3
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>库房客户端</title>
<title>WMS库房管理</title>
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
<script type="module" crossorigin src="/assets/index-BU25nTUO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3pBbrjFT.css">
<script type="module" crossorigin src="/assets/index-yykpqais.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DTC9tJqp.css">
</head>
<body>
<div id="app"></div>