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:
QiuSW
2026-09-11 16:27:13 +08:00
co-authored by Claude Opus 5
parent 652bf30f5a
commit 20f59b68bd
4 changed files with 451 additions and 43 deletions
+17 -4
View File
@@ -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)
+43 -14
View File
@@ -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>