From e9a6543a8b860516caa6e51f1692fbcdf608bc26 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 25 May 2026 21:27:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E5=BA=93=E5=AD=98=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=94=AF=E6=8C=81=E6=8B=BC=E9=9F=B3/=E9=A6=96?= =?UTF-8?q?=E5=AD=97=E6=AF=8D=EF=BC=8C=E6=8C=89=E5=9B=9E=E8=BD=A6=E8=A7=A6?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 util.ToPinyin(),使用 go-pinyin 生成全拼和首字母 - Product model 新增 name_pinyin / name_initials 列(AutoMigrate) - 启动时自动回填存量商品拼音 - Create / Update / FindOrCreate 写入时同步生成拼音 - 库存搜索 SQL 加入拼音/首字母 LIKE 条件 - 前端去掉 300ms 防抖,改为回车/点击搜索按钮触发 Co-Authored-By: Claude Sonnet 4.6 --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/handler/inventory.go | 4 +- backend/internal/handler/product.go | 20 +++++--- backend/internal/model/product.go | 2 + backend/internal/util/pinyin.go | 48 +++++++++++++++++++ backend/main.go | 20 ++++++++ backend/testutil/setup.go | 4 +- .../inventory/inventory_list_screen.dart | 27 +++++------ 9 files changed, 105 insertions(+), 23 deletions(-) create mode 100644 backend/internal/util/pinyin.go diff --git a/backend/go.mod b/backend/go.mod index bd15232..9761573 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -44,6 +44,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mozillazg/go-pinyin v0.21.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cdbc850..5add4c7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -73,6 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao= +github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 92f29b1..a3ec62a 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) { inStock := c.Query("in_stock") if keyword != "" { - baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)" + baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)" like := "%" + keyword + "%" - args = append(args, like, like) + args = append(args, like, like, like, like) } if warehouseIDStr != "" { baseWhere += " AND inv.warehouse_id = ?" diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index c62dea9..1ab399d 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -15,6 +15,7 @@ import ( "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" ) type ProductHandler struct { @@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) { } product.ShopID = shopID product.PublicID = uuid.New().String() + product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name) // Auto-generate product code if not provided (e.g. P001, P002) // Retry up to 5 times on duplicate key to handle concurrent creates @@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) { return } + namePinyin, nameInitials := util.ToPinyin(req.Name) // 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段 if err := h.db.Model(&product).Updates(map[string]interface{}{ "code": req.Code, @@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) { "description": req.Description, "remark": req.Remark, "custom_fields": req.CustomFields, + "name_pinyin": namePinyin, + "name_initials": nameInitials, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) { var count int64 h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count) + namePinyin, nameInitials := util.ToPinyin(req.Name) product = model.Product{ - TenantBase: model.TenantBase{ShopID: shopID}, - PublicID: uuid.New().String(), - Name: req.Name, - Series: req.Series, - Spec: req.Spec, - Code: fmt.Sprintf("P%03d", count+1), + TenantBase: model.TenantBase{ShopID: shopID}, + PublicID: uuid.New().String(), + Name: req.Name, + Series: req.Series, + Spec: req.Spec, + Code: fmt.Sprintf("P%03d", count+1), + NamePinyin: namePinyin, + NameInitials: nameInitials, } if createErr := h.db.Create(&product).Error; createErr != nil { // Race condition: try to find the record created by another request diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go index e9d785f..6912497 100644 --- a/backend/internal/model/product.go +++ b/backend/internal/model/product.go @@ -24,6 +24,8 @@ type Product struct { Description string `gorm:"type:text" json:"description"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` Remark string `gorm:"size:500" json:"remark"` + NamePinyin string `gorm:"size:400;index" json:"-"` + NameInitials string `gorm:"size:100;index" json:"-"` Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"` diff --git a/backend/internal/util/pinyin.go b/backend/internal/util/pinyin.go new file mode 100644 index 0000000..c4f73d3 --- /dev/null +++ b/backend/internal/util/pinyin.go @@ -0,0 +1,48 @@ +package util + +import ( + "strings" + + gp "github.com/mozillazg/go-pinyin" +) + +var ( + fullArgs = func() gp.Args { + a := gp.NewArgs() + a.Style = gp.Normal + a.Fallback = func(r rune, _ gp.Args) []string { + return []string{strings.ToLower(string(r))} + } + return a + }() + initialArgs = func() gp.Args { + a := gp.NewArgs() + a.Style = gp.FirstLetter + a.Fallback = func(r rune, _ gp.Args) []string { + s := strings.ToLower(string(r)) + if s == "" { + return nil + } + return []string{string(s[0])} + } + return a + }() +) + +// ToPinyin converts a Chinese name to full pinyin and initials. +// Non-Chinese characters are kept as-is (lowercased). +// Example: "茅台酒" → ("maotaijiu", "mtj") +func ToPinyin(s string) (full, initials string) { + var fb, ib strings.Builder + for _, row := range gp.Pinyin(s, fullArgs) { + if len(row) > 0 { + fb.WriteString(row[0]) + } + } + for _, row := range gp.Pinyin(s, initialArgs) { + if len(row) > 0 { + ib.WriteString(row[0]) + } + } + return fb.String(), ib.String() +} diff --git a/backend/main.go b/backend/main.go index 1a70e07..05a1e99 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,6 +12,7 @@ import ( "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/router" + "github.com/wangjia/jiu/backend/internal/util" ) func main() { @@ -27,6 +28,9 @@ func main() { // 自动迁移(GORM AutoMigrate 只增不删,生产安全) autoMigrate(db) + // 回填存量商品的拼音索引(一次性,已有值的跳过) + backfillPinyin(db) + // 启动 Gin gin.SetMode(config.C.Server.Mode) r := gin.New() @@ -111,3 +115,19 @@ func autoMigrate(db *gorm.DB) { } log.Println("AutoMigrate completed") } + +func backfillPinyin(db *gorm.DB) { + var products []model.Product + db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products) + if len(products) == 0 { + return + } + for i := range products { + full, initials := util.ToPinyin(products[i].Name) + db.Model(&products[i]).Updates(map[string]interface{}{ + "name_pinyin": full, + "name_initials": initials, + }) + } + log.Printf("backfillPinyin: updated %d products", len(products)) +} diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index bcdc3a0..086c2f9 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -121,7 +121,9 @@ func SetupTestDB() *gorm.DB { min_stock INTEGER DEFAULT 0, description TEXT, custom_fields TEXT, - remark TEXT + remark TEXT, + name_pinyin TEXT DEFAULT '', + name_initials TEXT DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index d1f0105..d6303c2 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -28,21 +27,16 @@ class InventoryListScreen extends ConsumerStatefulWidget { class _InventoryListScreenState extends ConsumerState { final _searchCtrl = TextEditingController(); - Timer? _debounce; Set _filterWarehouse = {}; @override void dispose() { _searchCtrl.dispose(); - _debounce?.cancel(); super.dispose(); } - void _onSearchChanged(String value) { - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 300), () { - ref.read(inventoryListProvider.notifier).setKeyword(value); - }); + void _triggerSearch() { + ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim()); } Future _editRemark(BuildContext context, Inventory item) async { @@ -333,15 +327,20 @@ class _InventoryListScreenState extends ConsumerState { ), const Spacer(), SizedBox( - width: 200, + width: 220, child: TextField( controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索商品名/编码', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: '名称/编码/拼音,回车搜索', + prefixIcon: const Icon(Icons.search, size: 16), + hintStyle: const TextStyle(fontSize: 12), + suffixIcon: IconButton( + icon: const Icon(Icons.search, size: 16), + tooltip: '搜索', + onPressed: _triggerSearch, + ), ), - onChanged: _onSearchChanged, + onSubmitted: (_) => _triggerSearch(), ), ), ],