refactor(wms/mes): 统一多场景查询逻辑,移除关键字混搜

1. 移除所有多字段关键字混搜,改为各筛选字段独立查询
2. 调整excel导出相关逻辑,优化文件名处理与导入模板
3. 修复物料导入的类型解析与token获取逻辑
4. 更新依赖与前端页面文案、筛选参数
This commit is contained in:
SunYF
2026-09-08 13:43:08 +08:00
parent e852c7cd37
commit 6d8bb75696
16 changed files with 141 additions and 114 deletions
@@ -95,16 +95,16 @@ function defaultRange() {
} }
Object.assign(filters, defaultRange()) Object.assign(filters, defaultRange())
function keyword() { // 2026-09-08 禁止关键字混搜:编码/名称/类别各筛选框独立传参,后端各自模糊匹配
// 编码/名称/类别合并为 keyword(后端大小写折叠模糊匹配) function trim(s) { return (s || '').trim() }
return filters.code || filters.name || filters.category || ''
}
async function load() { async function load() {
const params = { const params = {
page: page.value, page: page.value,
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: keyword(), code: trim(filters.code),
name: trim(filters.name),
category: trim(filters.category),
isActive: filters.isActive, isActive: filters.isActive,
startDate: filters.startDate, startDate: filters.startDate,
endDate: filters.endDate endDate: filters.endDate
@@ -141,7 +141,8 @@ async function exportXlsx() {
filters.endDate = r.end filters.endDate = r.end
} }
const params = { const params = {
keyword: keyword(), isActive: filters.isActive, code: trim(filters.code), name: trim(filters.name), category: trim(filters.category),
isActive: filters.isActive,
startDate: filters.startDate, endDate: filters.endDate startDate: filters.startDate, endDate: filters.endDate
} }
const blob = await request.get('/product-types/export', { params, responseType: 'blob' }) const blob = await request.get('/product-types/export', { params, responseType: 'blob' })
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.2
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/redis/go-redis/v9 v9.8.0 github.com/redis/go-redis/v9 v9.8.0
github.com/xuri/excelize/v2 v2.8.1
github.com/zeromicro/go-zero v1.8.3 github.com/zeromicro/go-zero v1.8.3
go.opentelemetry.io/otel/trace v1.24.0 go.opentelemetry.io/otel/trace v1.24.0
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
@@ -52,7 +53,6 @@ require (
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
github.com/xuri/excelize/v2 v2.8.1 // indirect
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect
@@ -40,7 +40,7 @@ func ListProductTypesPageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
page := atoiDefault(q.Get("page"), 1) page := atoiDefault(q.Get("page"), 1)
pageSize := atoiDefault(q.Get("pageSize"), 20) pageSize := atoiDefault(q.Get("pageSize"), 20)
total, list, wmsOnline, err := logic.New(svcCtx).ListProductTypesPage(r.Context(), total, list, wmsOnline, err := logic.New(svcCtx).ListProductTypesPage(r.Context(),
q.Get("keyword"), q.Get("isActive"), page, pageSize) q.Get("code"), q.Get("name"), q.Get("category"), q.Get("isActive"), page, pageSize)
if err != nil { if err != nil {
httpx.Fail(w, 3001, err.Error()) httpx.Fail(w, 3001, err.Error())
return return
@@ -96,7 +96,7 @@ func ExportProductTypesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
b, err := svcCtx.Wms.ExportProductTypes(r.Context(), b, err := svcCtx.Wms.ExportProductTypes(r.Context(),
q.Get("keyword"), q.Get("isActive"), q.Get("startDate"), q.Get("endDate")) q.Get("code"), q.Get("name"), q.Get("category"), q.Get("isActive"), q.Get("startDate"), q.Get("endDate"))
if err != nil { if err != nil {
httpx.Fail(w, 3002, "导出失败(WMS 不可达):"+err.Error()) httpx.Fail(w, 3002, "导出失败(WMS 不可达):"+err.Error())
return return
+20 -13
View File
@@ -71,24 +71,25 @@ func (s *Service) DeleteProductType(ctx context.Context, id int) error {
// ListProductTypes 全量列表(下拉/兼容形态,id asc 新建在底部)。 // ListProductTypes 全量列表(下拉/兼容形态,id asc 新建在底部)。
// degraded=false 表示 WMS 不可达、数据来自本地只读缓存。 // degraded=false 表示 WMS 不可达、数据来自本地只读缓存。
func (s *Service) ListProductTypes(ctx context.Context) ([]wmsclient.ProductTypeRow, bool, error) { func (s *Service) ListProductTypes(ctx context.Context) ([]wmsclient.ProductTypeRow, bool, error) {
list, err := s.ctx.Wms.ProductTypes(ctx, "", "") list, err := s.ctx.Wms.ProductTypes(ctx, "")
if err == nil { if err == nil {
return list, true, nil return list, true, nil
} }
fallback, ferr := s.localProductTypes(ctx, "") fallback, ferr := s.localProductTypes(ctx, "", "", "")
if ferr != nil { if ferr != nil {
return nil, false, errors.New("WMS 不可达且本地缓存读取失败") return nil, false, errors.New("WMS 不可达且本地缓存读取失败")
} }
return fallback, false, nil return fallback, false, nil
} }
// ListProductTypesPage 分页+搜索(管理页真分页) // ListProductTypesPage 分页+搜索(管理页真分页)
func (s *Service) ListProductTypesPage(ctx context.Context, keyword, isActive string, page, pageSize int) (int64, []wmsclient.ProductTypeRow, bool, error) { // 2026-09-08 禁止关键字混搜:编码/名称/类别各自独立模糊。
total, list, err := s.ctx.Wms.ProductTypePage(ctx, keyword, isActive, page, pageSize) func (s *Service) ListProductTypesPage(ctx context.Context, code, name, category, isActive string, page, pageSize int) (int64, []wmsclient.ProductTypeRow, bool, error) {
total, list, err := s.ctx.Wms.ProductTypePage(ctx, code, name, category, isActive, page, pageSize)
if err == nil { if err == nil {
return total, list, true, nil return total, list, true, nil
} }
fallback, ferr := s.localProductTypes(ctx, keyword) fallback, ferr := s.localProductTypes(ctx, code, name, category)
if ferr != nil { if ferr != nil {
return 0, nil, false, errors.New("WMS 不可达且本地缓存读取失败") return 0, nil, false, errors.New("WMS 不可达且本地缓存读取失败")
} }
@@ -104,7 +105,7 @@ func (s *Service) ListProductTypesPage(ctx context.Context, keyword, isActive st
} }
func (s *Service) productTypeByID(ctx context.Context, id int) (*wmsclient.ProductTypeRow, error) { func (s *Service) productTypeByID(ctx context.Context, id int) (*wmsclient.ProductTypeRow, error) {
list, err := s.ctx.Wms.ProductTypes(ctx, "", "") list, err := s.ctx.Wms.ProductTypes(ctx, "")
if err == nil { if err == nil {
for i := range list { for i := range list {
if list[i].ID == id { if list[i].ID == id {
@@ -121,18 +122,24 @@ func (s *Service) productTypeByID(ctx context.Context, id int) (*wmsclient.Produ
return &wmsclient.ProductTypeRow{ID: pt.ID, Code: pt.Code, Name: pt.Name}, nil return &wmsclient.ProductTypeRow{ID: pt.ID, Code: pt.Code, Name: pt.Name}, nil
} }
func (s *Service) localProductTypes(ctx context.Context, keyword string) ([]wmsclient.ProductTypeRow, error) { // localProductTypes 本地只读缓存过滤(降级用):编码/名称/类别各自独立模糊匹配
func (s *Service) localProductTypes(ctx context.Context, code, name, category string) ([]wmsclient.ProductTypeRow, error) {
list, err := s.ctx.EntClient.ProductType.Query().Order(ent.Asc(producttype.FieldID)).All(ctx) list, err := s.ctx.EntClient.ProductType.Query().Order(ent.Asc(producttype.FieldID)).All(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
codeKw := strings.ToLower(strings.TrimSpace(code))
nameKw := strings.ToLower(strings.TrimSpace(name))
catKw := strings.ToLower(strings.TrimSpace(category))
rows := make([]wmsclient.ProductTypeRow, 0, len(list)) rows := make([]wmsclient.ProductTypeRow, 0, len(list))
kw := strings.ToLower(strings.TrimSpace(keyword))
for _, p := range list { for _, p := range list {
if kw != "" && if codeKw != "" && !strings.Contains(strings.ToLower(p.Code), codeKw) {
!strings.Contains(strings.ToLower(p.Code), kw) && continue
!strings.Contains(strings.ToLower(p.Name), kw) && }
!strings.Contains(strings.ToLower(p.Category), kw) { if nameKw != "" && !strings.Contains(strings.ToLower(p.Name), nameKw) {
continue
}
if catKw != "" && !strings.Contains(strings.ToLower(p.Category), catKw) {
continue continue
} }
rows = append(rows, wmsclient.ProductTypeRow{ rows = append(rows, wmsclient.ProductTypeRow{
+25 -13
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/url"
"time" "time"
) )
@@ -151,12 +152,9 @@ func (c *Client) putJSON(ctx context.Context, path string, body any) error {
return nil return nil
} }
// ProductTypes 全量产品型号(item_type=3id asc,新建在底部)。keyword/isActive 可空。 // ProductTypes 全量产品型号(item_type=3id asc,新建在底部)。isActive 可空。
func (c *Client) ProductTypes(ctx context.Context, keyword, isActive string) ([]ProductTypeRow, error) { func (c *Client) ProductTypes(ctx context.Context, isActive string) ([]ProductTypeRow, error) {
q := "/api/internal/product-types?" q := "/api/internal/product-types?"
if keyword != "" {
q += "keyword=" + keyword + "&"
}
if isActive != "" { if isActive != "" {
q += "isActive=" + isActive q += "isActive=" + isActive
} }
@@ -167,11 +165,18 @@ func (c *Client) ProductTypes(ctx context.Context, keyword, isActive string) ([]
return list, nil return list, nil
} }
// ProductTypePage 分页+搜索(产品型号管理页真分页) // ProductTypePage 分页+搜索(产品型号管理页真分页)
func (c *Client) ProductTypePage(ctx context.Context, keyword, isActive string, page, pageSize int) (int64, []ProductTypeRow, error) { // 2026-09-08 禁止关键字混搜:编码/名称/类别各自独立模糊。
func (c *Client) ProductTypePage(ctx context.Context, code, name, category, isActive string, page, pageSize int) (int64, []ProductTypeRow, error) {
q := fmt.Sprintf("/api/internal/product-types?page=%d&pageSize=%d", page, pageSize) q := fmt.Sprintf("/api/internal/product-types?page=%d&pageSize=%d", page, pageSize)
if keyword != "" { if code != "" {
q += "&keyword=" + keyword q += "&code=" + url.QueryEscape(code)
}
if name != "" {
q += "&name=" + url.QueryEscape(name)
}
if category != "" {
q += "&category=" + url.QueryEscape(category)
} }
if isActive != "" { if isActive != "" {
q += "&isActive=" + isActive q += "&isActive=" + isActive
@@ -205,11 +210,18 @@ func (c *Client) DeleteProductType(ctx context.Context, id int) error {
return c.post(ctx, "/api/internal/product-types/delete", map[string]any{"id": id}) return c.post(ctx, "/api/internal/product-types/delete", map[string]any{"id": id})
} }
// ExportProductTypes 拉取产品型号导出 xlsx 字节流(WMS 端执行统一导出时间规则) // ExportProductTypes 拉取产品型号导出 xlsx 字节流(WMS 端执行统一导出时间规则)
func (c *Client) ExportProductTypes(ctx context.Context, keyword, isActive, startDate, endDate string) ([]byte, error) { // 编码/名称/类别各自独立模糊筛选(禁止关键字混搜)。
func (c *Client) ExportProductTypes(ctx context.Context, code, name, category, isActive, startDate, endDate string) ([]byte, error) {
q := "/api/internal/product-types/export?" q := "/api/internal/product-types/export?"
if keyword != "" { if code != "" {
q += "keyword=" + keyword + "&" q += "code=" + url.QueryEscape(code) + "&"
}
if name != "" {
q += "name=" + url.QueryEscape(name) + "&"
}
if category != "" {
q += "category=" + url.QueryEscape(category) + "&"
} }
if isActive != "" { if isActive != "" {
q += "isActive=" + isActive + "&" q += "isActive=" + isActive + "&"
+1 -1
View File
@@ -6,7 +6,7 @@
<title>MES 产线控制</title> <title>MES 产线控制</title>
<!-- 系统图标:产线齿轮(内联 SVG,避免依赖外部文件) --> <!-- 系统图标:产线齿轮(内联 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'/><circle cx='16' cy='16' r='4.2' fill='none' stroke='%23fff' stroke-width='2'/><path d='M16 5v3M16 24v3M5 16h3M24 16h3M8.2 8.2l2.1 2.1M21.7 21.7l2.1 2.1M23.8 8.2l-2.1 2.1M10.3 21.7l-2.1 2.1' stroke='%23fff' stroke-width='2' stroke-linecap='round'/></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'/><circle cx='16' cy='16' r='4.2' fill='none' stroke='%23fff' stroke-width='2'/><path d='M16 5v3M16 24v3M5 16h3M24 16h3M8.2 8.2l2.1 2.1M21.7 21.7l2.1 2.1M23.8 8.2l-2.1 2.1M10.3 21.7l-2.1 2.1' stroke='%23fff' stroke-width='2' stroke-linecap='round'/></svg>">
<script type="module" crossorigin src="./assets/index-BDMAhlAo.js"></script> <script type="module" crossorigin src="./assets/index-_UVOpshD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-n7TXey5n.css"> <link rel="stylesheet" crossorigin href="./assets/index-n7TXey5n.css">
</head> </head>
<body> <body>
+3 -3
View File
@@ -32,9 +32,9 @@ export const helpInbound = {
}, },
{ {
title: '入库记录(页签3', title: '入库记录(页签3',
overview: '分页展示全部入库单,结构件与精密件同表(用"类型"列区分)。支持按入库单号、物料编码、类型、关键字、入库时间筛选;可按当前条件导出 xlsx。点行「明细」在右侧抽屉查看该单的批次号/SN 行(懒加载+分页,避免精密件上万条 SN 撑爆前端)。', overview: '分页展示全部入库单,结构件与精密件同表(用"类型"列区分)。支持按入库单号、物料编码、类型、物料名称、入库时间筛选(各字段独立,无关键字混搜);可按当前条件导出 xlsx。点行「明细」在右侧抽屉查看该单的批次号/SN 行(懒加载+分页,避免精密件上万条 SN 撑爆前端)。',
fields: [ fields: [
{ name: '查询条件', source: '本页签顶部筛选区。', purpose: '缩小列表范围', fill: '可组合:入库单号(模糊)/物料编码(模糊)/类型(全部·结构件·精密件)/关键字(物料名称或单号)/入库时间区间' }, { name: '查询条件', source: '本页签顶部筛选区。', purpose: '缩小列表范围', fill: '可组合:入库单号(模糊)/物料编码(模糊)/类型(全部·结构件·精密件)/物料名称(模糊)/入库时间区间' },
{ name: '明细', source: '行末「明细」按钮 → 右侧抽屉。', purpose: '查看该入库单的批次号/SN/数量明细', fill: '抽屉内分页展示,不回列表里嵌套子表' }, { name: '明细', source: '行末「明细」按钮 → 右侧抽屉。', purpose: '查看该入库单的批次号/SN/数量明细', fill: '抽屉内分页展示,不回列表里嵌套子表' },
{ name: '导出', source: '按钮。后端 /inbound/export 按当前条件返回全部(不分页)。', purpose: '把筛选结果一次性导出为 xlsx', fill: '点【导出】下载"入库管理.xlsx"' } { name: '导出', source: '按钮。后端 /inbound/export 按当前条件返回全部(不分页)。', purpose: '把筛选结果一次性导出为 xlsx', fill: '点【导出】下载"入库管理.xlsx"' }
] ]
@@ -141,7 +141,7 @@ export const helpLedger = {
fields: [ fields: [
{ name: '工单号', source: '筛选条件。数据来自 MES 工单。', purpose: '只看某个工单的领料台账', fill: '输入工单号(可空=查全部工单)' }, { name: '工单号', source: '筛选条件。数据来自 MES 工单。', purpose: '只看某个工单的领料台账', fill: '输入工单号(可空=查全部工单)' },
{ name: '物料编码', source: '筛选条件。', purpose: '按物料编码精确过滤', fill: '输入物料编码(可空)' }, { name: '物料编码', source: '筛选条件。', purpose: '按物料编码精确过滤', fill: '输入物料编码(可空)' },
{ name: '物料名称', source: '筛选条件。', purpose: '按物料名称模糊过滤', fill: '输入物料名称关键字(可空)' }, { name: '物料名称', source: '筛选条件。', purpose: '按物料名称模糊过滤', fill: '输入物料名称(可空)' },
{ name: '状态', source: '筛选条件。可选项:待领料 / 部分领料 / 领料完结。', purpose: '按领料进度过滤', fill: '待领料=还没出过库;部分领料=已出库但没领完;领料完结=缺口为 0(前两种均由"进行中"按已出库数实时派生)' }, { name: '状态', source: '筛选条件。可选项:待领料 / 部分领料 / 领料完结。', purpose: '按领料进度过滤', fill: '待领料=还没出过库;部分领料=已出库但没领完;领料完结=缺口为 0(前两种均由"进行中"按已出库数实时派生)' },
{ name: 'ID', source: '台账记录主键(系统生成,只读)。', purpose: '唯一标识一行台账,便于定位与对接', fill: '系统自动生成,无需填写' }, { name: 'ID', source: '台账记录主键(系统生成,只读)。', purpose: '唯一标识一行台账,便于定位与对接', fill: '系统自动生成,无需填写' },
{ name: '需求总量 / 已出库 / 缺口', source: '系统计算。需求总量来自 BOM;已出库随出库自动累加;缺口 = 需求总量 − 已出库。', purpose: '一眼看清某物料还差多少未领', fill: '自动;缺口为 0 即领料完结' }, { name: '需求总量 / 已出库 / 缺口', source: '系统计算。需求总量来自 BOM;已出库随出库自动累加;缺口 = 需求总量 − 已出库。', purpose: '一眼看清某物料还差多少未领', fill: '自动;缺口为 0 即领料完结' },
+9 -6
View File
@@ -6,6 +6,7 @@ import request from '../utils/request'
import { fmtTime } from '../utils/format' import { fmtTime } from '../utils/format'
import { can } from '../utils/perm' import { can } from '../utils/perm'
import { exportXlsxFetch } from '../utils/export' import { exportXlsxFetch } from '../utils/export'
import { getToken } from '../utils/auth'
import PageHelp from '../components/PageHelp.vue' import PageHelp from '../components/PageHelp.vue'
import { helpZone, helpMaterial } from '../help' import { helpZone, helpMaterial } from '../help'
@@ -237,16 +238,18 @@ async function onMaterialFile(e) {
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
const token = JSON.parse(localStorage.getItem('token') || '""') // 注意:token 是纯文本存储,不能用 JSON.parse(会对 JWT 抛 SyntaxError 误报"导入异常"
const res = await fetch('/api/material/import', { const res = await fetch('/api/material/import', {
method: 'POST', method: 'POST',
headers: { Authorization: 'Bearer ' + token }, headers: { Authorization: 'Bearer ' + getToken() },
body: fd body: fd
}) })
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => null)
const d = body?.data || {} const d = body?.data || {}
if (!res.ok || body?.code !== 0) { if (!res.ok || body?.code !== 0) {
ElMessage.error('导入失败:' + (body?.message || '未知错误')) // 业务/权限错误:优先展示后端中文 message;解析不出 body 时按 HTTP 状态给中文提示
const httpText = res.status === 401 ? '登录已过期,请重新登录' : `服务器错误(${res.status})`
ElMessage.error('导入失败:' + (body?.message || httpText))
return return
} }
if (d.errors && d.errors.length) { if (d.errors && d.errors.length) {
@@ -258,7 +261,7 @@ async function onMaterialFile(e) {
ElMessage.success(`导入成功 ${d.success || 0}`) ElMessage.success(`导入成功 ${d.success || 0}`)
await loadMaterials() await loadMaterials()
} catch (err) { } catch (err) {
ElMessage.error('导入异常:' + (err?.message || err)) ElMessage.error('导入失败:网络异常或登录已过期,请刷新页面重试')
} finally { } finally {
matImporting.value = false matImporting.value = false
} }
@@ -373,7 +376,7 @@ async function onMaterialFile(e) {
<el-button type="success" @click="openMatCreate">新增物料</el-button> <el-button type="success" @click="openMatCreate">新增物料</el-button>
<el-button v-if="can('material:import')" type="warning" :loading="matImporting" @click="pickMaterialFile">导入</el-button> <el-button v-if="can('material:import')" type="warning" :loading="matImporting" @click="pickMaterialFile">导入</el-button>
<el-button v-if="can('material:export')" :loading="matExporting" @click="exportMaterials">导出</el-button> <el-button v-if="can('material:export')" :loading="matExporting" @click="exportMaterials">导出</el-button>
<el-tooltip v-if="can('material:import')" content="Excel 模板首行表头物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(1结构件/2精密件) | 说明"> <el-tooltip v-if="can('material:import')" content="Excel 模板首行表头物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(结构件/精密件) | 说明">
<el-link type="info" :underline="false" style="margin-left:4px">导入说明</el-link> <el-link type="info" :underline="false" style="margin-left:4px">导入说明</el-link>
</el-tooltip> </el-tooltip>
<input ref="matFileRef" type="file" accept=".xlsx,.xls" style="display:none" @change="onMaterialFile" /> <input ref="matFileRef" type="file" accept=".xlsx,.xls" style="display:none" @change="onMaterialFile" />
+6 -6
View File
@@ -168,7 +168,7 @@ async function importExcel(mode, which) {
/* ===================== Tab3 入库记录(统一主表 + 抽屉明细,不分页内嵌子表) ===================== */ /* ===================== Tab3 入库记录(统一主表 + 抽屉明细,不分页内嵌子表) ===================== */
// 查询条件 // 查询条件
const filters = reactive({ inboundNo: '', materialCode: '', manageMode: '', keyword: '' }) const filters = reactive({ inboundNo: '', materialCode: '', manageMode: '', materialName: '' })
const dateRange = ref([]) // [开始日期, 结束日期] YYYY-MM-DD const dateRange = ref([]) // [开始日期, 结束日期] YYYY-MM-DD
const loadingRec = ref(false) const loadingRec = ref(false)
const list = ref([]) const list = ref([])
@@ -184,7 +184,7 @@ async function loadRecords() {
inboundNo: filters.inboundNo.trim(), inboundNo: filters.inboundNo.trim(),
materialCode: filters.materialCode.trim(), materialCode: filters.materialCode.trim(),
manageMode: filters.manageMode, manageMode: filters.manageMode,
keyword: filters.keyword.trim(), materialName: filters.materialName.trim(),
startDate: dateRange.value?.[0] || '', startDate: dateRange.value?.[0] || '',
endDate: dateRange.value?.[1] || '', endDate: dateRange.value?.[1] || '',
page: page.current, page: page.current,
@@ -206,7 +206,7 @@ function resetFilters() {
filters.inboundNo = '' filters.inboundNo = ''
filters.materialCode = '' filters.materialCode = ''
filters.manageMode = '' filters.manageMode = ''
filters.keyword = '' filters.materialName = ''
dateRange.value = [] dateRange.value = []
search() search()
} }
@@ -218,7 +218,7 @@ async function exportCsv() {
inboundNo: filters.inboundNo.trim(), inboundNo: filters.inboundNo.trim(),
materialCode: filters.materialCode.trim(), materialCode: filters.materialCode.trim(),
manageMode: filters.manageMode, manageMode: filters.manageMode,
keyword: filters.keyword.trim(), materialName: filters.materialName.trim(),
startDate: dateRange.value?.[0] || '', startDate: dateRange.value?.[0] || '',
endDate: dateRange.value?.[1] || '' endDate: dateRange.value?.[1] || ''
}, '入库管理.xlsx') }, '入库管理.xlsx')
@@ -410,8 +410,8 @@ onMounted(() => {
<el-option label="精密件" value="2" /> <el-option label="精密件" value="2" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="关键字"> <el-form-item label="物料名称">
<el-input v-model="filters.keyword" placeholder="物料名称/单号" clearable style="width:180px" @keyup.enter="search" /> <el-input v-model="filters.materialName" placeholder="物料名称(模糊)" clearable style="width:160px" @keyup.enter="search" />
</el-form-item> </el-form-item>
<el-form-item label="入库时间"> <el-form-item label="入库时间">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
+6 -3
View File
@@ -42,12 +42,15 @@ export async function exportXlsxFetch(path, params, fallbackName = '导出.xlsx'
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
throw new Error(body.message || '导出失败') throw new Error(body.message || '导出失败')
} }
// 从 Content-Disposition 解析文件名 // 从 Content-Disposition 解析文件名:优先 filename*=UTF-8''(后端放中文原名),兜底 filename(ASCII 名)
let filename = fallbackName let filename = fallbackName
const cd = res.headers.get('Content-Disposition') || '' const cd = res.headers.get('Content-Disposition') || ''
const m = cd.match(/filename="?([^";]+)"?/) let m = cd.match(/filename\*=UTF-8''([^;]+)/i)
if (m) { if (m) {
filename = decodeURIComponent(m[1]) filename = decodeURIComponent(m[1]) || filename
} else {
m = cd.match(/filename="?([^";]+)"?/)
if (m) filename = decodeURIComponent(m[1])
} }
const blob = await res.blob() const blob = await res.blob()
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
+3 -2
View File
@@ -69,11 +69,12 @@ func sendExcel(w http.ResponseWriter, filename string, headers []string, rows []
fail(w, http.StatusInternalServerError, "生成 Excel 失败: "+err.Error()) fail(w, http.StatusInternalServerError, "生成 Excel 失败: "+err.Error())
return return
} }
// RFC 5987:中文文件名用 filename*=UTF-8''...URL 编码),并提供 ASCII 兜底文件名 // RFC 5987:中文文件名用 filename*=UTF-8''...URL 编码)
// ASCII 兜底名恒为纯 ASCII(中文原样放 filename="" 会被浏览器按 latin-1 渲染成乱码),
// 前端优先解析 filename* 拿到中文原名。
asciiName := "export.xlsx" asciiName := "export.xlsx"
encName := "" encName := ""
if utf8.ValidString(filename) { if utf8.ValidString(filename) {
asciiName = strings.TrimSuffix(filename, ".xlsx") + ".xlsx"
encName = "filename*=UTF-8''" + url.QueryEscape(filename) encName = "filename*=UTF-8''" + url.QueryEscape(filename)
} }
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+3 -7
View File
@@ -245,13 +245,9 @@ func applyInboundFilters(q *ent.InboundOrderQuery, r *http.Request) *ent.Inbound
if v := r.URL.Query().Get("inboundType"); v != "" { if v := r.URL.Query().Get("inboundType"); v != "" {
q = q.Where(inboundorder.InboundTypeEQ(v)) q = q.Where(inboundorder.InboundTypeEQ(v))
} }
if v := r.URL.Query().Get("keyword"); v != "" { if v := r.URL.Query().Get("materialName"); v != "" {
kw := v // 2026-09-08 用户明确:禁止"关键字"多字段混搜,各字段独立筛选
q = q.Where(inboundorder.Or( q = q.Where(inboundorder.MaterialNameContainsFold(v))
inboundorder.MaterialCodeContainsFold(kw),
inboundorder.InboundNoContainsFold(kw),
inboundorder.MaterialNameContainsFold(kw),
))
} }
if v := r.URL.Query().Get("startDate"); v != "" { if v := r.URL.Query().Get("startDate"); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
+29 -10
View File
@@ -148,7 +148,7 @@ func queryMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
itemType := atoi(r.URL.Query().Get("itemType"), 0) itemType := atoi(r.URL.Query().Get("itemType"), 0)
startDate := r.URL.Query().Get("startDate") startDate := r.URL.Query().Get("startDate")
endDate := r.URL.Query().Get("endDate") endDate := r.URL.Query().Get("endDate")
keyword := r.URL.Query().Get("keyword") // 兼容旧版,作为物料编码/名称兜底 keyword := r.URL.Query().Get("keyword") // 仅供下拉选择框(MaterialSelect)远程搜索:编码/名称任一匹配;列表页禁用此混搜模式
q := ctx.EntClient.Material.Query() q := ctx.EntClient.Material.Query()
if materialCode != "" { if materialCode != "" {
@@ -191,7 +191,7 @@ func queryMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return return
} }
list, err := q.Order(ent.Asc("id")). list, err := q.Order(ent.Desc("id")).
Offset((page - 1) * pageSize). Offset((page - 1) * pageSize).
Limit(pageSize). Limit(pageSize).
All(ctx0()) All(ctx0())
@@ -316,13 +316,14 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if itemType != 0 { if itemType != 0 {
q = q.Where(material.ItemTypeEQ(itemType)) q = q.Where(material.ItemTypeEQ(itemType))
} }
list, err := q.Order(ent.Asc("id")).All(ctx0()) list, err := q.Order(ent.Desc("id")).All(ctx0())
if err != nil { if err != nil {
fail(w, http.StatusInternalServerError, err.Error()) fail(w, http.StatusInternalServerError, err.Error())
return return
} }
type exportRow struct { type exportRow struct {
ID int
Code string Code string
Name string Name string
ShortName string ShortName string
@@ -336,18 +337,18 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
rows := make([]exportRow, 0, len(list)) rows := make([]exportRow, 0, len(list))
for _, m := range list { for _, m := range list {
rows = append(rows, exportRow{ rows = append(rows, exportRow{
Code: m.Code, Name: m.Name, ShortName: m.ShortName, Spec: m.Spec, ID: m.ID, Code: m.Code, Name: m.Name, ShortName: m.ShortName, Spec: m.Spec,
Unit: m.Unit, ItemType: m.ItemType, ManageMode: m.ManageMode, Unit: m.Unit, ItemType: m.ItemType, ManageMode: m.ManageMode,
Description: m.Description, CreatedAt: m.CreatedAt, Description: m.Description, CreatedAt: m.CreatedAt,
}) })
} }
if r.URL.Query().Get("format") == "xlsx" { if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"物料编码", "名称", "简称", "规格", "单位", "品类", "类型", "说明", "创建时间"} headers := []string{"ID", "物料编码", "名称", "简称", "规格", "单位", "品类", "类型", "说明", "创建时间"}
matrix := make([][]any, 0, len(rows)) matrix := make([][]any, 0, len(rows))
for _, rr := range rows { for _, rr := range rows {
matrix = append(matrix, []any{ matrix = append(matrix, []any{
rr.Code, rr.Name, rr.ShortName, rr.Spec, rr.Unit, rr.ID, rr.Code, rr.Name, rr.ShortName, rr.Spec, rr.Unit,
itemTypeLabel(rr.ItemType), manageModeLabel(rr.ManageMode), itemTypeLabel(rr.ItemType), manageModeLabel(rr.ManageMode),
rr.Description, unixFmt(rr.CreatedAt), rr.Description, unixFmt(rr.CreatedAt),
}) })
@@ -359,8 +360,26 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
} }
} }
// parseManageMode 解析导入模板"类型"列:支持中文(结构件/精密件,含"(批次)/(SN)"后缀)
// 与旧数字格式(1/2)双向兼容;无法识别返回 0。
func parseManageMode(s string) int {
if s == "" {
return 0
}
if strings.Contains(s, "精密") || strings.EqualFold(s, "SN") {
return 2
}
if strings.Contains(s, "结构") || strings.Contains(s, "批次") {
return 1
}
if n := atoi(s, 0); n == 1 || n == 2 {
return n
}
return 0
}
// excelMaterialImportHandler 物料档案 Excel 批量导入 // excelMaterialImportHandler 物料档案 Excel 批量导入
// 模板(首行忽略表头):物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(1结构件/2精密件) | 说明 // 模板(首行忽略表头):物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(结构件/精密件) | 说明
// 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。 // 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。
// 阶段1 逐行解析+校验(编码必填且唯一 / 名称必填 / 类型合法) → 任一错则整体拒绝并列出错误行; // 阶段1 逐行解析+校验(编码必填且唯一 / 名称必填 / 类型合法) → 任一错则整体拒绝并列出错误行;
// 阶段2 全部通过 → 开启单事务整体提交,任一步失败整体回滚。 // 阶段2 全部通过 → 开启单事务整体提交,任一步失败整体回滚。
@@ -424,7 +443,7 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
pr.shortName = cell(2) pr.shortName = cell(2)
pr.spec = cell(3) pr.spec = cell(3)
pr.unit = cell(4) pr.unit = cell(4)
pr.manageMode = atoi(cell(5), 1) pr.manageMode = parseManageMode(cell(5))
pr.description = cell(6) pr.description = cell(6)
if pr.code == "" { if pr.code == "" {
@@ -435,8 +454,8 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
errs = append(errs, importErr{pr.row, pr.code, "名称为空"}) errs = append(errs, importErr{pr.row, pr.code, "名称为空"})
continue continue
} }
if pr.manageMode != 1 && pr.manageMode != 2 { if pr.manageMode == 0 {
errs = append(errs, importErr{pr.row, pr.code, "类型必须为 1(结构件) 或 2(精密件)"}) errs = append(errs, importErr{pr.row, pr.code, "类型无法识别,请填写「结构件」或「精密件」(兼容 1/2)"})
continue continue
} }
exists, _ := ctx.EntClient.Material.Query(). exists, _ := ctx.EntClient.Material.Query().
+25 -31
View File
@@ -21,29 +21,37 @@ func productTypeQuery(ctx *svc.ServiceContext) *ent.MaterialQuery {
return ctx.EntClient.Material.Query().Where(material.ItemTypeEQ(3)) return ctx.EntClient.Material.Query().Where(material.ItemTypeEQ(3))
} }
// applyProductTypeFilters 产品型号列表/导出统一筛选。
// 2026-09-08 用户明确:禁止"关键字"多字段混搜,编码/名称/类别各字段独立模糊(大小写折叠)。
// 注:类别存在 description 列。
func applyProductTypeFilters(q *ent.MaterialQuery, r *http.Request) *ent.MaterialQuery {
if v := r.URL.Query().Get("code"); v != "" {
q = q.Where(material.CodeContainsFold(v))
}
if v := r.URL.Query().Get("name"); v != "" {
q = q.Where(material.NameContainsFold(v))
}
if v := r.URL.Query().Get("category"); v != "" {
q = q.Where(material.DescriptionContainsFold(v))
}
switch v := r.URL.Query().Get("isActive"); v {
case "true":
q = q.Where(material.IsActiveEQ(true))
case "false":
q = q.Where(material.IsActiveEQ(false))
}
return q
}
// listProductTypesInternalHandler GET /api/internal/product-types // listProductTypesInternalHandler GET /api/internal/product-types
// 参数:keyword(编码/名称/类别模糊,大小写折叠)、isActive(true/false)、page、pageSize // 参数:code/name/category(各自独立模糊,大小写折叠)、isActive(true/false)、page、pageSize
// 无 page 参数时返回全量数组(兼容下拉场景);带 page 时返回 {total,list,page,pageSize} // 无 page 参数时返回全量数组(兼容下拉场景);带 page 时返回 {total,list,page,pageSize}
func listProductTypesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc { func listProductTypesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("keyword")
isActive := r.URL.Query().Get("isActive")
page := atoi(r.URL.Query().Get("page"), 0) page := atoi(r.URL.Query().Get("page"), 0)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20) pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
q := productTypeQuery(ctx) q := applyProductTypeFilters(productTypeQuery(ctx), r)
if keyword != "" {
q = q.Where(material.Or(
material.CodeContainsFold(keyword),
material.NameContainsFold(keyword),
material.DescriptionContainsFold(keyword),
))
}
if isActive == "true" {
q = q.Where(material.IsActiveEQ(true))
} else if isActive == "false" {
q = q.Where(material.IsActiveEQ(false))
}
if page <= 0 { if page <= 0 {
// 下拉/兼容模式:全量(基础档案量级小),id asc 新建在底部 // 下拉/兼容模式:全量(基础档案量级小),id asc 新建在底部
@@ -249,8 +257,6 @@ func deleteProductTypeInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc
// 全量导出(按筛选),执行统一导出时间硬规则 // 全量导出(按筛选),执行统一导出时间硬规则
func exportProductTypesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc { func exportProductTypesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("keyword")
isActive := r.URL.Query().Get("isActive")
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate")) startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
if rerr != nil { if rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error()) fail(w, http.StatusBadRequest, rerr.Error())
@@ -258,20 +264,8 @@ func exportProductTypesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc
} }
startUnix, endUnix := exportRangeUnix(startDate, endDate) startUnix, endUnix := exportRangeUnix(startDate, endDate)
q := productTypeQuery(ctx) q := applyProductTypeFilters(productTypeQuery(ctx), r)
q = q.Where(material.CreatedAtGTE(startUnix), material.CreatedAtLTE(endUnix)) q = q.Where(material.CreatedAtGTE(startUnix), material.CreatedAtLTE(endUnix))
if keyword != "" {
q = q.Where(material.Or(
material.CodeContainsFold(keyword),
material.NameContainsFold(keyword),
material.DescriptionContainsFold(keyword),
))
}
if isActive == "true" {
q = q.Where(material.IsActiveEQ(true))
} else if isActive == "false" {
q = q.Where(material.IsActiveEQ(false))
}
list, err := q.Order(ent.Asc("id")).All(ctx0()) list, err := q.Order(ent.Asc("id")).All(ctx0())
if err != nil { if err != nil {
fail(w, http.StatusInternalServerError, err.Error()) fail(w, http.StatusInternalServerError, err.Error())
-9
View File
@@ -98,7 +98,6 @@ func listZonesHandler(ctx *svc.ServiceContext) http.HandlerFunc {
status := r.URL.Query().Get("status") status := r.URL.Query().Get("status")
startDate := r.URL.Query().Get("startDate") startDate := r.URL.Query().Get("startDate")
endDate := r.URL.Query().Get("endDate") endDate := r.URL.Query().Get("endDate")
keyword := r.URL.Query().Get("keyword") // 兼容旧版,作为区域编码/名称兜底
q := ctx.EntClient.Zone.Query() q := ctx.EntClient.Zone.Query()
if zoneCode != "" { if zoneCode != "" {
@@ -111,14 +110,6 @@ func listZonesHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if status != "" { if status != "" {
q = q.Where(zone.StatusEQ(status)) q = q.Where(zone.StatusEQ(status))
} }
if keyword != "" {
q = q.Where(
zone.Or(
zone.ZoneCodeContainsFold(keyword),
zone.ZoneNameContainsFold(keyword),
),
)
}
if startDate != "" { if startDate != "" {
if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil { if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil {
q = q.Where(zone.CreatedAtGTE(t.Unix())) q = q.Where(zone.CreatedAtGTE(t.Unix()))
+1 -1
View File
@@ -6,7 +6,7 @@
<title>WMS库房管理</title> <title>WMS库房管理</title>
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) --> <!-- 系统图标:库房箱体(内联 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>"> <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-BpDCc7Aj.js"></script> <script type="module" crossorigin src="/assets/index-Bu1WX04e.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DTC9tJqp.css"> <link rel="stylesheet" crossorigin href="/assets/index-DTC9tJqp.css">
</head> </head>
<body> <body>