fix(goauto): record SYB filter hits per rule, not per kind #269
UpdateHits wrote one aggregate per kind, so every structural rule stored
the same number. The disable-confirmation dialog quotes that number
("停用「#」后…约 N 条明细会恢复入库"), and on real data "#" matches 8498
rows while "-" matches 6392 — the dialog would have warned with a figure
2100 rows off for one of them.
Match now returns the rule that matched instead of a char/keyword pair,
so the caller can attribute each hit to one rule, and UpdateHits writes
every rule's own count. Rules that matched nothing are written as 0 so a
previous run's number is never left behind.
The management page is reformatted to the repo's usual Vue layout and
gains the search / 只看启用 / 重置 controls the approved prototype shows.
Cancelling the disable confirmation now just reverts the switch instead
of throwing an unhandled rejection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -57,6 +57,10 @@ type SyncReport struct {
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped"`
|
||||
// filterHits accumulates hits per filter rule id so each rule's own
|
||||
// LastHitCount can be written back. It is unexported because it is run
|
||||
// bookkeeping, not part of the report shown to people or stored as JSON.
|
||||
filterHits sybproductfilter.HitCounter
|
||||
// ShopBreakdown separates accepted and skipped orders for every shop. Without
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
@@ -122,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
// run's effective allow-list differ from the snapshot recorded on its row.
|
||||
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed),
|
||||
filterHits: sybproductfilter.HitCounter{}}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
@@ -211,7 +216,7 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
}
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.CharFilterSkipped, report.KeywordFilterSkipped, report.FinishedAt); err != nil {
|
||||
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, errors.Join(failures...)
|
||||
@@ -372,12 +377,20 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
if charHit, keywordHit := filters.Match(variation); charHit || keywordHit {
|
||||
if charHit {
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
report.KeywordFilterSkipped++
|
||||
}
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
|
||||
@@ -192,30 +192,59 @@ func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
func (r RuleSet) Match(v string) (char, keyword bool) {
|
||||
|
||||
// Match reports which enabled rule first matched v, or nil when none did.
|
||||
//
|
||||
// It returns the matching rule rather than a pair of booleans because the
|
||||
// caller has to attribute the hit to one specific rule: every rule keeps its
|
||||
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
|
||||
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
|
||||
// char/keyword booleans would force every rule of a kind to share one
|
||||
// aggregate, and the dialog would then warn with the wrong figure — on real
|
||||
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
|
||||
// reported as the same number.
|
||||
//
|
||||
// Structural rules are checked before keyword rules so a hit is attributed the
|
||||
// way the page presents the two sections, and an empty variationSku never
|
||||
// matches: it is a product with no supplier code yet, not a 档口 item.
|
||||
func (r RuleSet) Match(v string) *models.SYBProductFilter {
|
||||
if v == "" {
|
||||
return false, false
|
||||
return nil
|
||||
}
|
||||
n := sybshop.Normalize(v)
|
||||
for _, x := range r.Chars {
|
||||
for i, x := range r.Chars {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return true, false
|
||||
return &r.Chars[i]
|
||||
}
|
||||
}
|
||||
for _, x := range r.Keywords {
|
||||
for i, x := range r.Keywords {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return false, true
|
||||
return &r.Keywords[i]
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
return nil
|
||||
}
|
||||
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, charHits, keywordHits int, at time.Time) error {
|
||||
for _, x := range append(r.Chars, r.Keywords...) {
|
||||
count := charHits
|
||||
if x.Kind == "keyword" {
|
||||
count = keywordHits
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).Updates(map[string]any{"last_hit_count": count, "last_hit_at": at}).Error; err != nil {
|
||||
|
||||
// HitCounter accumulates per-rule hits over one sync run, keyed by rule id.
|
||||
type HitCounter map[uint64]int
|
||||
|
||||
// Add records one hit against the rule that matched.
|
||||
func (h HitCounter) Add(rule *models.SYBProductFilter) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
h[rule.ID]++
|
||||
}
|
||||
|
||||
// UpdateHits writes each rule's own hit count back to its row.
|
||||
//
|
||||
// Every rule in the set is written, including those with zero hits: leaving a
|
||||
// previous run's number in place would make the confirmation dialog quote a
|
||||
// figure that no longer describes the current data.
|
||||
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error {
|
||||
for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) {
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).
|
||||
Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
@@ -21,24 +22,89 @@ func testDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
|
||||
func TestMatchORAndNormalization(t *testing.T) {
|
||||
r := RuleSet{Chars: []models.SYBProductFilter{{NormalizedKeyword: "-"}, {NormalizedKeyword: "#"}}, Keywords: []models.SYBProductFilter{{NormalizedKeyword: "cvc短袖220斤"}}}
|
||||
r := RuleSet{
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
|
||||
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
|
||||
}
|
||||
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
|
||||
// production data that difference is 2118 rows (#269).
|
||||
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
|
||||
c, k := r.Match(v)
|
||||
if !c || k {
|
||||
t.Fatalf("%q should char-match", v)
|
||||
hit := r.Match(v)
|
||||
if hit == nil || hit.Kind != "char" {
|
||||
t.Fatalf("%q should match a structural rule, got %v", v, hit)
|
||||
}
|
||||
if hit.ID != 2 {
|
||||
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
|
||||
}
|
||||
}
|
||||
if c, k := r.Match(""); c || k {
|
||||
t.Fatal("empty variationSku must not match")
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
|
||||
}
|
||||
if c, k := r.Match(" CVC短袖220斤 "); c || !k {
|
||||
t.Fatal("full-width/space keyword should match")
|
||||
if hit := r.Match(""); hit != nil {
|
||||
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
|
||||
}
|
||||
if _, k := r.Match("CVC短袖220斤"); k != true {
|
||||
t.Fatal("lowercase keyword should match")
|
||||
if hit := r.Match("PDD0582"); hit != nil {
|
||||
t.Fatalf("own product code must not match, got %v", hit)
|
||||
}
|
||||
if _, k := r.Match("純棉"); k {
|
||||
t.Fatal("normalization must not simplify traditional Chinese")
|
||||
if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("纯棉"); hit != nil {
|
||||
t.Fatal("normalization must not convert traditional to simplified Chinese")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
|
||||
// aggregate per kind would make the disable-confirmation dialog quote the same
|
||||
// figure for "#" and "-" even though they match very different numbers of rows.
|
||||
func TestUpdateHitsIsPerRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(set.Chars) != 2 {
|
||||
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
|
||||
}
|
||||
byKeyword := map[string]uint64{}
|
||||
for _, x := range set.Chars {
|
||||
byKeyword[x.Keyword] = x.ID
|
||||
}
|
||||
hits := HitCounter{}
|
||||
for i := 0; i < 8498; i++ {
|
||||
hits[byKeyword["#"]]++
|
||||
}
|
||||
for i := 0; i < 6392; i++ {
|
||||
hits[byKeyword["-"]]++
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]int{"#": 8498, "-": 6392}
|
||||
for _, x := range rows {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword)
|
||||
}
|
||||
if w, ok := want[x.Keyword]; ok {
|
||||
if *x.LastHitCount != w {
|
||||
t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *x.LastHitCount != 0 {
|
||||
t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount)
|
||||
}
|
||||
if x.LastHitAt == nil {
|
||||
t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,319 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper><el-card class="page-card" shadow="never">
|
||||
<el-alert v-if="!isAdmin" title="当前账号只有查看权限。新增、编辑、启用、停用和删除关键词需要管理员操作。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert title="关键词匹配 variationSku,不匹配商品标题。" type="info" :closable="false" show-icon class="notice" />
|
||||
<section><h3>结构过滤(不可增删,仅可停用)</h3><p class="muted">这两个字符是识别档口商品的判据,命中即不入库。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" border stripe><el-table-column label="字符" prop="keyword" width="180"/><el-table-column label="状态" width="150"><template #default="{row}"><el-switch v-if="isAdmin" v-model="row.enabled" @change="toggle(row)"/><el-tag v-else :type="row.enabled?'success':'info'">{{row.enabled?'已启用':'已停用'}}</el-tag></template></el-table-column><el-table-column label="最近命中" width="140"><template #default="{row}">{{ row.lastHitCount == null ? '尚未同步' : row.lastHitCount }}</template></el-table-column><el-table-column label="最近变更" min-width="220"><template #default="{row}">{{ row.lastChangedAt ? `${format(row.lastChangedAt)} ${row.lastChangedBy || ''} ${row.enabled ? '启用' : '停用'}` : '尚未记录' }}</template></el-table-column><el-table-column label="操作" width="110"><template #default><span class="muted">不可删除</span></template></el-table-column></el-table></section>
|
||||
<section class="keyword-section"><div class="section-head"><div><h3>关键词过滤</h3><p class="muted">在结构过滤之后执行,命中数为结构过滤未覆盖的部分。</p></div><el-button v-if="isAdmin" type="primary" @click="editor.open=true">新增关键词</el-button></div>
|
||||
<el-table v-loading="loading" :data="keywords" border stripe empty-text="还没有配置过滤关键词。结构过滤(variationSku 含 - 或 #)仍然生效,本页只影响额外的关键词过滤。"><el-table-column label="关键词" prop="keyword" min-width="260"/><el-table-column label="状态" width="140"><template #default="{row}"><el-switch v-if="isAdmin" v-model="row.enabled" @change="toggle(row)"/><el-tag v-else :type="row.enabled?'success':'info'">{{row.enabled?'已启用':'已停用'}}</el-tag></template></el-table-column><el-table-column label="最近命中" width="130"><template #default="{row}">{{ row.lastHitCount == null ? '尚未同步' : row.lastHitCount }}</template></el-table-column><el-table-column label="备注" prop="note" min-width="180"><template #default="{row}">{{ row.note || '—' }}</template></el-table-column><el-table-column label="操作" width="100"><template #default="{row}"><el-button v-if="isAdmin" type="danger" link @click="remove(row)">删除</el-button><span v-else class="muted">只读</span></template></el-table-column></el-table></section>
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px"><el-form label-position="top"><el-form-item label="关键词"><el-input v-model="editor.keyword" maxlength="200" /></el-form-item><el-form-item label="备注"><el-input v-model="editor.note" /></el-form-item></el-form><template #footer><el-button @click="editor.open=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog>
|
||||
</el-card></template></BasicLayout>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="load">
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="query.keyword" clearable placeholder="字符或关键词" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<el-form-item label="只看启用">
|
||||
<el-switch v-model="query.enabledOnly" @change="load" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!isAdmin"
|
||||
title="当前账号只有查看权限。新增、启用、停用和删除关键词需要管理员操作。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<p class="muted">这两个字符是识别档口商品的判据,命中即不入库。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
|
||||
<el-table-column label="字符" prop="keyword" width="180" />
|
||||
<el-table-column label="状态" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}结构过滤状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近变更" min-width="240">
|
||||
<template #default="{ row }">{{ changeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<!-- 结构规则不可增删(#269):这里必须是纯文字,不能是看起来可点的按钮。 -->
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default><span class="disabled-text">不可删除</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="keyword-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>关键词过滤</h3>
|
||||
<p class="muted">在结构过滤之后执行,命中数为结构过滤未覆盖的部分。</p>
|
||||
</div>
|
||||
<el-tooltip :disabled="isAdmin" content="只有管理员可以配置关键词" placement="bottom">
|
||||
<span>
|
||||
<el-button type="primary" :disabled="!isAdmin" @click="openEditor">新增关键词</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="keywords"
|
||||
row-key="id"
|
||||
border
|
||||
stripe
|
||||
empty-text="还没有配置关键词过滤。上方结构过滤仍然生效。"
|
||||
>
|
||||
<el-table-column label="关键词" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="primary">{{ row.keyword }}</div>
|
||||
<div class="muted">匹配时忽略首尾空白、全半角和大小写</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template #default="{ row }">{{ row.note || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isAdmin" type="danger" link @click="remove(row)">删除</el-button>
|
||||
<span v-else class="muted">只读</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。系统会去掉首尾空白、统一全半角并忽略大小写;「CVC短袖220斤」「CVC短袖220斤」「cvc短袖220斤」视为同一条,重复添加会被拒绝。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="editor.keyword" maxlength="200" show-word-limit placeholder="请输入关键词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注(可空)">
|
||||
<el-input v-model="editor.note" maxlength="200" placeholder="添加理由,可空" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="editor.saving" @click="editor.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listSybProductFilters, createSybProductFilter, setSybProductFilterEnabled, deleteSybProductFilter } from '@/api/goauto/syb-product-filters'
|
||||
export default { name:'GoAutoSybProductFilters', data(){return {loading:false,chars:[],keywords:[],editor:{open:false,keyword:'',note:''}}}, computed:{isAdmin(){return (this.$store.getters.roles||[]).includes('admin')}}, created(){this.load()}, methods:{ async load(){this.loading=true;try{const r=await listSybProductFilters({page:1,pageSize:200});const rows=r.data.items||[];this.chars=rows.filter(x=>x.kind==='char');this.keywords=rows.filter(x=>x.kind==='keyword')}finally{this.loading=false}}, format(v){return new Intl.DateTimeFormat('zh-CN',{dateStyle:'medium',timeStyle:'short'}).format(new Date(v))}, async toggle(row){if(row.kind==='char'&&!row.enabled){await ElMessageBox.confirm(`停用「${row.keyword}」后,SYB 同步将不再过滤含 ${row.keyword} 的档口商品。按最近一次同步数据估算,约 ${row.lastHitCount||0} 条明细会恢复入库。确认停用?`,'确认停用结构过滤',{type:'warning'}).catch(()=>{row.enabled=true;throw new Error('cancel')})}try{await setSybProductFilterEnabled(row.id,{enabled:row.enabled});ElMessage.success(row.enabled?'规则已启用':'规则已停用')}catch(e){row.enabled=!row.enabled; if(e.message!=='cancel')throw e}},async save(){if(!this.editor.keyword.trim())return;await createSybProductFilter({kind:'keyword',keyword:this.editor.keyword,note:this.editor.note||undefined});ElMessage.success('关键词已添加');this.editor={open:false,keyword:'',note:''};this.load()},async remove(row){await ElMessageBox.confirm(`删除关键词「${row.keyword}」?`,'删除关键词',{type:'warning'});await deleteSybProductFilter(row.id);ElMessage.success('关键词已删除');this.load()}}
|
||||
import {
|
||||
listSybProductFilters,
|
||||
createSybProductFilter,
|
||||
setSybProductFilterEnabled,
|
||||
deleteSybProductFilter
|
||||
} from '@/api/goauto/syb-product-filters'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProductFilters',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
chars: [],
|
||||
keywords: [],
|
||||
query: { keyword: '', enabledOnly: false },
|
||||
editor: { open: false, saving: false, keyword: '', note: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return (this.$store.getters.roles || []).includes('admin')
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await listSybProductFilters({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
keyword: this.query.keyword || undefined,
|
||||
enabledOnly: this.query.enabledOnly || undefined
|
||||
})
|
||||
const rows = (res.data.items || []).map(row => ({ ...row, updating: false }))
|
||||
this.chars = rows.filter(row => row.kind === 'char')
|
||||
this.keywords = rows.filter(row => row.kind === 'keyword')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.query = { keyword: '', enabledOnly: false }
|
||||
this.load()
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
},
|
||||
// 命中数由同步回写;从未同步过时不要显示 0,那会被读成「一条都没命中」。
|
||||
hitText(row) {
|
||||
return row.lastHitCount == null ? '等待同步记录' : `${row.lastHitCount} 条`
|
||||
},
|
||||
changeText(row) {
|
||||
if (!row.lastChangedAt) return '尚未记录'
|
||||
const who = row.lastChangedBy || '-'
|
||||
return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}`
|
||||
},
|
||||
// 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。
|
||||
// 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。
|
||||
async confirmDisableChar(row) {
|
||||
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
|
||||
await ElMessageBox.confirm(
|
||||
`停用「${row.keyword}」后,SYB 同步将不再过滤含 ${row.keyword} 的档口商品。按最近一次同步数据估算,${scale}明细会恢复入库。`,
|
||||
`确认停用结构过滤「${row.keyword}」?`,
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
},
|
||||
async toggle(row) {
|
||||
const target = row.enabled
|
||||
if (row.kind === 'char' && !target) {
|
||||
try {
|
||||
await this.confirmDisableChar(row)
|
||||
} catch (e) {
|
||||
row.enabled = true
|
||||
return
|
||||
}
|
||||
}
|
||||
row.updating = true
|
||||
try {
|
||||
await setSybProductFilterEnabled(row.id, { enabled: target })
|
||||
ElMessage.success(target ? '规则已启用' : '规则已停用')
|
||||
} catch (e) {
|
||||
row.enabled = !target
|
||||
throw e
|
||||
} finally {
|
||||
row.updating = false
|
||||
}
|
||||
},
|
||||
openEditor() {
|
||||
this.editor.open = true
|
||||
},
|
||||
resetEditor() {
|
||||
this.editor = { open: false, saving: false, keyword: '', note: '' }
|
||||
},
|
||||
async save() {
|
||||
const keyword = this.editor.keyword.trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请输入关键词')
|
||||
return
|
||||
}
|
||||
this.editor.saving = true
|
||||
try {
|
||||
await createSybProductFilter({ kind: 'keyword', keyword, note: this.editor.note || undefined })
|
||||
ElMessage.success('关键词已添加')
|
||||
this.editor.open = false
|
||||
this.load()
|
||||
} finally {
|
||||
this.editor.saving = false
|
||||
}
|
||||
},
|
||||
async remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除关键词「${row.keyword}」?`, '删除关键词', { type: 'warning' })
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
await deleteSybProductFilter(row.id)
|
||||
ElMessage.success('关键词已删除')
|
||||
this.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>.page-card{min-height:calc(100vh - 124px)}.notice{margin-bottom:16px}section{margin-bottom:28px}h3{margin:0 0 8px}.muted,.scope-note{font-size:12px;color:#606266}.section-head{display:flex;justify-content:space-between;align-items:center}.keyword-section{border-top:1px solid #ebeef5;padding-top:20px}.scope-note{margin-top:12px}</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card {
|
||||
min-height: calc(100vh - 124px);
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.primary {
|
||||
color: #303133;
|
||||
}
|
||||
.muted,
|
||||
.scope-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.disabled-text {
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.keyword-section {
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.scope-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user