feat(purchase): surface image-search-linked marker in purchase views (#279)

Item 5 of #279 required bringing the image-search auto-link annotation
into the purchase-facing views, display-only, without gating anything.

- BatchPreviewItem and AdminTaskItem now carry imageSearchLinked, sourced
  read-only from shopee_product.image_search_linked (already loaded in
  the batch preview dataset; bulk-loaded for the task list/detail views).
- Batch purchase preview dialog and purchase task list/detail now show a
  "图搜未核" tag plus a restrained summary hint when applicable.
- Does not touch Eligible/ReasonCode/Reason/NextAction or the #283
  mappingTargetsValid gate; regression test confirms an ineligible row's
  outcome is unchanged when imageSearchLinked is true.

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-15 10:35:22 +08:00
co-authored by Claude Opus 5
parent a16e6920b7
commit c2c0bae056
5 changed files with 135 additions and 6 deletions
+41
View File
@@ -76,6 +76,7 @@ type AdminTaskItem struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
}
type AdminAttemptItem struct {
@@ -163,15 +164,48 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
}
items := make([]AdminTaskItem, 0, len(tasks))
for _, task := range tasks {
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.Matching = matching[task.ID]
if task.ShopeeProductID != nil {
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
}
items = append(items, item)
}
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
}
// loadImageSearchLinked bulk-loads the read-only "linked via image search, not
// manually verified" flag for purchase-task list/detail views (#279 item 5).
// It is display-only and must never influence eligibility or matching.
func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) {
result := make(map[uint64]bool)
ids := make([]uint64, 0, len(tasks))
seen := make(map[uint64]bool)
for _, task := range tasks {
if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] {
ids = append(ids, *task.ShopeeProductID)
seen[*task.ShopeeProductID] = true
}
}
if len(ids) == 0 {
return result, nil
}
var shopeeProducts []models.ShopeeProduct
if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil {
return nil, internal(err)
}
for _, shopee := range shopeeProducts {
result[shopee.ID] = shopee.ImageSearchLinked
}
return result, nil
}
func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) {
var task models.PurchaseTask
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
@@ -203,6 +237,13 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.Matching = matching
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
if err != nil {
return AdminDetailResponse{}, err
}
if task.ShopeeProductID != nil {
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
}
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
}
+2
View File
@@ -40,6 +40,7 @@ type BatchPreviewItem struct {
PDDProductID *uint64 `json:"pddProductId,omitempty"`
PDDGoodsID string `json:"pddGoodsId,omitempty"`
PDDTitle string `json:"pddTitle,omitempty"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
MappedColor string `json:"mappedColor,omitempty"`
@@ -388,6 +389,7 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
item.ImageSearchLinked = shopee.ImageSearchLinked
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
+85
View File
@@ -334,6 +334,91 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
}
}
// #279 item 5: purchase preview must surface (read-only) that a shopee<->PDD
// link was established by image search and not yet human-verified, without
// changing eligibility, reason codes or next actions in any way.
func TestBatchPreviewMarksImageSearchLinkedRowsWithoutAffectingEligibility(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
item := response.Items[0]
if !item.ImageSearchLinked {
t.Fatalf("expected imageSearchLinked=true for a link created by image search: %+v", item)
}
if !item.Eligible || item.ReasonCode != "" || item.Reason != "" || item.NextAction != "" {
t.Fatalf("imageSearchLinked marker must be display-only and must not change eligibility: %+v", item)
}
}
func TestBatchPreviewDoesNotMarkManuallyLinkedRows(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
// image_search_linked defaults to false for manually established links.
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
if response.Items[0].ImageSearchLinked {
t.Fatalf("manual link must not be marked imageSearchLinked: %+v", response.Items[0])
}
if !response.Items[0].Eligible {
t.Fatalf("manual link must remain eligible: %+v", response.Items[0])
}
}
// Regression: the imageSearchLinked flag must not change any existing
// eligibility outcome, including the well-covered ineligible paths.
func TestBatchPreviewImageSearchLinkedDoesNotChangeIneligibleOutcome(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
failed := fixture.syb
failed.ID = 0
failed.OrderCode = "SYB-FAILED-IMG"
failed.DetailID++
failed.ParseStatus = models.SYBParseStatusFailed
failed.TargetColor, failed.TargetSize = "", ""
if err := db.Create(&failed).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{failed.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
item := response.Items[0]
if item.Eligible || item.ReasonCode != "SYB_PARSE_FAILED" || item.NextAction != "reparse" {
t.Fatalf("image search linkage must not alter the existing ineligible outcome: %+v", item)
}
}
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
t.Helper()
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
@@ -19,7 +19,7 @@
<el-table-column label="任务" width="105"><template #default="{ row }"><div class="primary">CG-{{ row.id }}</div><el-tag size="small" effect="plain" :type="row.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(row.executionMode) }}</el-tag></template></el-table-column>
<el-table-column label="类型" width="110"><template #default="{ row }"><el-tag size="small" effect="plain" :type="taskTypeTag(row.taskType)">{{ taskTypeLabel(row.taskType) }}</el-tag></template></el-table-column>
<el-table-column label="蝦皮订单号" min-width="160"><template #default="{ row }"><span class="order-number">{{ row.shopeeOrderNoSnapshot || '—' }}</span></template></el-table-column>
<el-table-column label="SYB / 虾皮商品" min-width="220"><template #default="{ row }"><template v-if="row.taskType === 'stock'"><div class="muted">—</div></template><template v-else><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}</div><div class="muted">SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }}</div></template></template></el-table-column>
<el-table-column label="SYB / 虾皮商品" min-width="220"><template #default="{ row }"><template v-if="row.taskType === 'stock'"><div class="muted">—</div></template><template v-else><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></div><div class="muted">SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }}</div></template></template></el-table-column>
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
@@ -42,7 +42,7 @@
<section><h3>任务快照</h3><el-descriptions :column="2" border>
<el-descriptions-item label="任务类型">{{ taskTypeLabel(detail.task.taskType) }}</el-descriptions-item><el-descriptions-item label="SYB 商品">{{ detail.task.taskType === 'stock' ? '—' : `SYB-${detail.task.sybProductId || '—'}` }}</el-descriptions-item>
<el-descriptions-item label="蝦皮订单号">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeOrderNoSnapshot || '—') }}</el-descriptions-item><el-descriptions-item label="虾皮商品 ID">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeItemIdSnapshot || '—') }}</el-descriptions-item>
<el-descriptions-item label="虾皮店铺">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}</el-descriptions-item><el-descriptions-item label="虾皮标题">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}</el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="虾皮店铺">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}</el-descriptions-item><el-descriptions-item label="虾皮标题">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}<el-tag v-if="detail.task.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="PDD 标题" :span="2">{{ detail.task.pddTitleSnapshot || '未记录' }}</el-descriptions-item>
<el-descriptions-item label="目标规格">{{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}</el-descriptions-item><el-descriptions-item label="执行规格">{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
@@ -251,5 +251,5 @@ export default {
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
</style>
+4 -3
View File
@@ -78,7 +78,7 @@
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
<el-alert title="每条 SYB 商品分别创建一个采购任务和一个待付款 PDD 订单;系统永不支付。创建前服务端会再次逐条检查。" type="warning" :closable="false" show-icon class="notice" />
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span></div>
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span><span v-if="purchaseImageSearchLinkedCount" class="warning-text">其中 {{ purchaseImageSearchLinkedCount }} 条为图搜自动关联,尚未人工核对</span></div>
<el-form label-width="110px" class="purchase-settings">
<el-form-item label="Android 设备">
<el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" :disabled="purchaseDialog.saving" @change="changePurchaseDevice">
@@ -92,7 +92,7 @@
<el-alert v-if="purchaseDialog.previewError" :title="purchaseDialog.previewError" type="error" :closable="false" show-icon class="notice"><el-button link type="primary" @click="openPurchaseBatch">重试</el-button></el-alert>
<el-table :data="purchaseDialog.items" border size="small" max-height="380" empty-text="没有可创建的商品">
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }"><strong>{{ row.orderCode || `ID ${row.sybProductId}` }}</strong><div class="muted">{{ row.shopeeItemId || '未关联蝦皮商品' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}</div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
<el-table-column label="目标规格 / 数量" min-width="170"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity || 0 }}</template></el-table-column>
<el-table-column label="检查结果" min-width="240"><template #default="{ row }"><template v-if="row.eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(row) }}</div></template><template v-else><el-tag type="warning">跳过</el-tag><div class="purchase-reason">{{ row.reason }}</div></template></template></el-table-column>
</el-table>
@@ -261,6 +261,7 @@ export default {
},
computed: {
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
@@ -594,7 +595,7 @@ export default {
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
.purchase-note{margin:0 0 16px}
.drawer-body{padding:0 4px 24px}