feat(goauto): parameterize purchase price guard (#146)

This commit is contained in:
QiuSW
2026-08-29 21:24:55 +08:00
parent 148ba96521
commit 56443c995b
7 changed files with 149 additions and 25 deletions
+16 -9
View File
@@ -97,7 +97,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewFromDataset(ctx, id, dataset, false)
item := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule))
item.applyProcessStage(processStageFromDataset(id, dataset, item))
if item.Eligible && deviceErr != nil {
item.Eligible = false
@@ -227,7 +227,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
return BatchCreateResponse{}, internal(err)
}
preview := s.previewOneDeterministic(ctx, id)
preview := s.previewOneDeterministic(ctx, id, purchasecontract.EffectivePriceGuard(currentRule))
if preview.Eligible && deviceErr != nil {
preview.Eligible = false
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr)
@@ -290,23 +290,29 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
}
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
return item
}
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
return s.previewFromDataset(ctx, id, dataset, true)
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
}
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64) BatchPreviewItem {
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceGuard) BatchPreviewItem {
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
return s.previewFromDataset(ctx, id, dataset, false)
return s.previewFromDataset(ctx, id, dataset, false, guard)
}
func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem {
@@ -329,7 +335,7 @@ func taskPointerValue(value *uint64) uint64 {
return *value
}
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool) BatchPreviewItem {
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceGuard) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
@@ -388,7 +394,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
}
}
}
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor)
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
if err != nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
return item
@@ -417,7 +423,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
return item
}
func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice int64, err error) {
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGuard) (reference, minPrice, maxPrice int64, err error) {
var dimensions []product.SpecDimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
@@ -444,7 +450,8 @@ func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice
}
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
low, high := prices[0], prices[len(prices)-1]
return high, low / 5, (high*3 + 1) / 2, nil
minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio)
return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil
}
func batchItemRequestID(batchRequestID string, sybProductID uint64) string {
+14
View File
@@ -43,6 +43,20 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
}
}
func TestPurchasePriceRangeUsesExactConfiguredRatiosAndLegacyDefaults(t *testing.T) {
raw := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1001},{"name":"白色","selectable":true,"priceCent":1003}]}]`
legacy := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)}
ref, minPrice, maxPrice, err := purchasePriceRange(raw, "", legacy)
if err != nil || ref != 1003 || minPrice != 200 || maxPrice != 1505 {
t.Fatalf("legacy range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
}
custom := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)}
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", custom)
if err != nil || ref != 1003 || minPrice != 333 || maxPrice != 2257 {
t.Fatalf("custom range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
}
}
func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
+7 -1
View File
@@ -5,6 +5,8 @@ import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"go-admin/app/goauto/purchaserule"
)
const (
@@ -61,8 +63,12 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
if err != nil {
return nil, err
}
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if err != nil {
return nil, err
}
for _, id := range ids {
preview := s.previewFromDataset(ctx, id, dataset, false)
preview := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule))
result[id] = processStageFromDataset(id, dataset, preview)
}
return result, nil
+11 -4
View File
@@ -8,6 +8,7 @@ import (
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"go-admin/app/goauto/purchaserule"
"go-admin/app/goauto/replacement"
@@ -254,19 +255,25 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
if err != nil {
return retryDecision{ReasonCode: CodeInternal, Reason: "服务端处理失败"}
}
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if err != nil {
code, message, _ := serviceErrorFields(err)
return retryDecision{ReasonCode: code, Reason: message}
}
guard := purchasecontract.EffectivePriceGuard(currentRule)
syb, found := dataset.sybByID[*task.SYBProductID]
if !found || syb.ShopeeProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found || shopee.PDDProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
pdd, found := dataset.pddByID[*shopee.PDDProductID]
if !found {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
@@ -274,7 +281,7 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
}
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
+57 -4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"regexp"
"sort"
"strings"
@@ -72,10 +73,50 @@ type SwipePlan struct {
}
type RuleSnapshot struct {
SchemaVersion int `json:"schemaVersion"`
RuleType string `json:"ruleType"`
RequiredCapabilities []string `json:"requiredCapabilities"`
Actions []Action `json:"actions"`
SchemaVersion int `json:"schemaVersion"`
RuleType string `json:"ruleType"`
RequiredCapabilities []string `json:"requiredCapabilities"`
Actions []Action `json:"actions"`
PriceGuard *PriceGuard `json:"priceGuard,omitempty"`
}
const (
RatioScale = int64(10_000)
DefaultMinRatio = int64(2_000)
DefaultMaxRatio = int64(15_000)
)
// PriceGuard uses fixed basis points internally so cent calculations never
// depend on binary floating-point rounding. JSON remains a regular decimal.
type PriceGuard struct {
MinRatio Ratio `json:"minRatio"`
MaxRatio Ratio `json:"maxRatio"`
}
type Ratio int64
func (r *Ratio) UnmarshalJSON(raw []byte) error {
var value float64
if err := json.Unmarshal(raw, &value); err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return errors.New("价格比例必须是数字")
}
scaled := value * float64(RatioScale)
if math.Abs(scaled-math.Round(scaled)) > 1e-7 {
return errors.New("价格比例最多支持 4 位小数")
}
*r = Ratio(math.Round(scaled))
return nil
}
func (r Ratio) MarshalJSON() ([]byte, error) {
return []byte(strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", float64(r)/float64(RatioScale)), "0"), ".")), nil
}
func EffectivePriceGuard(rule RuleSnapshot) PriceGuard {
if rule.PriceGuard == nil {
return PriceGuard{MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)}
}
return *rule.PriceGuard
}
func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
@@ -97,6 +138,18 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
if len(rule.Actions) == 0 || len(rule.Actions) > 64 {
return rule, errors.New("actions 必须包含 1..64 个类型化动作")
}
if rule.PriceGuard != nil {
minRatio, maxRatio := int64(rule.PriceGuard.MinRatio), int64(rule.PriceGuard.MaxRatio)
if minRatio < 1_000 || minRatio > 10_000 {
return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间")
}
if maxRatio < 10_000 || maxRatio > 30_000 {
return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间")
}
if minRatio > maxRatio {
return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio")
}
}
capabilities := make(map[string]bool, len(rule.RequiredCapabilities))
for _, capability := range rule.RequiredCapabilities {
if !capabilityPattern.MatchString(capability) {
@@ -1,6 +1,7 @@
package purchasecontract
import (
"encoding/json"
"reflect"
"strings"
"testing"
@@ -33,6 +34,38 @@ func TestTypeOnlyRuleRemainsCompatible(t *testing.T) {
}
}
func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) {
legacy, err := Validate(DefaultLiveRule(), "live")
if err != nil {
t.Fatal(err)
}
if guard := EffectivePriceGuard(legacy); int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 {
t.Fatalf("legacy defaults=%+v", guard)
}
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"minRatio":0.3333,"maxRatio":2.25},"actions":[{"type":"openProduct"}]}`)
rule, err := Validate(raw, "live")
if err != nil {
t.Fatal(err)
}
guard := EffectivePriceGuard(rule)
if int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 {
t.Fatalf("fixed ratios=%+v", guard)
}
encoded, err := json.Marshal(rule)
if err != nil || !strings.Contains(string(encoded), `"minRatio":0.3333`) || !strings.Contains(string(encoded), `"maxRatio":2.25`) {
t.Fatalf("encoded=%s err=%v", encoded, err)
}
}
func TestPriceGuardRejectsUnsafeBounds(t *testing.T) {
for _, guard := range []string{`{"minRatio":0.09,"maxRatio":1.5}`, `{"minRatio":0.2,"maxRatio":3.01}`, `{"minRatio":1,"maxRatio":0.9}`, `{"minRatio":0.12345,"maxRatio":1.5}`} {
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`)
if _, err := Validate(raw, "live"); err == nil {
t.Fatalf("accepted price guard %s", guard)
}
}
}
func TestParameterizedRuleRejectsUnknownFields(t *testing.T) {
tests := []string{
`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","selector":{"text":"打开"}}]}`,
+11 -7
View File
@@ -27,6 +27,10 @@
<el-dialog v-model="dialog.open" :title="dialog.ruleId ? '编辑采购规则' : '新建采购规则'" width="760px" destroy-on-close>
<el-form ref="form" :model="form" :rules="rules" label-position="top">
<el-form-item label="规则名称" prop="name"><el-input v-model="form.name" maxlength="120" show-word-limit /></el-form-item>
<div class="ratio-grid">
<el-form-item label="最低价比例"><el-input-number v-model="form.minRatio" :min="0.1" :max="1" :step="0.05" :precision="4" controls-position="right" /><div class="field-help">范围 0.1~1.0,默认 0.2;最低价向下取整到分。</div></el-form-item>
<el-form-item label="最高价比例"><el-input-number v-model="form.maxRatio" :min="1" :max="3" :step="0.05" :precision="4" controls-position="right" /><div class="field-help">范围 1.0~3.0,默认 1.5;最高价向上取整到分。</div></el-form-item>
</div>
<el-form-item label="规则 JSON" prop="contentText"><el-input v-model="form.contentText" type="textarea" :rows="18" class="json-editor" spellcheck="false" /><div class="field-help">仅支持 schemaVersion=1、ruleType=pddPurchase;服务端会再次执行严格字段和安全动作校验。</div></el-form-item>
</el-form>
<template #footer><el-button :disabled="dialog.saving" @click="dialog.open=false">取消</el-button><el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button></template>
@@ -39,22 +43,22 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules'
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] }
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] }
export default {
name: 'GoAutoPurchaseRules',
setup() { return { Plus, RefreshLeft, Search } },
data() {
const jsonRule = (_r, value, callback) => { try { const parsed = JSON.parse(value); if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1 || parsed.ruleType !== 'pddPurchase') return callback(new Error('请输入有效的 PDD 采购规则 JSON')); callback() } catch (_) { callback(new Error('请输入有效 JSON')) } }
return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', contentText: '' }, rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }}
return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: '' }, rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }}
},
created() { this.load() },
methods: {
async load() { this.loading = true; try { const response = await listPurchaseRules(this.query); this.items = response.data.items; this.total = response.data.total } finally { this.loading = false } },
search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, name: '' }; this.load() },
summary(content) { return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作` },
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', contentText: JSON.stringify(defaultRule, null, 2) } },
openEdit(row) { this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, contentText: JSON.stringify(row.content, null, 2) } },
async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content: JSON.parse(this.form.contentText) }; try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload); ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } },
summary(content) { const guard = content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作 · 价格 ${guard.minRatio}~${guard.maxRatio} 倍` },
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: JSON.stringify(defaultRule, null, 2) } },
openEdit(row) { const guard = row.content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, minRatio: guard.minRatio, maxRatio: guard.maxRatio, contentText: JSON.stringify(row.content, null, 2) } },
async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; if (this.form.minRatio > this.form.maxRatio) { ElMessage.error('最低价比例不能大于最高价比例'); return } this.dialog.saving = true; const content = JSON.parse(this.form.contentText); content.priceGuard = { minRatio: this.form.minRatio, maxRatio: this.form.maxRatio }; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload); ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } },
async setCurrent(row) { await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。`, '切换当前采购规则', { type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } },
async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() }
}
@@ -62,5 +66,5 @@ export default {
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;justify-content:space-between;gap:16px;margin-bottom:18px}.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}.page-heading p,.field-help{margin:0;color:#64748b;line-height:1.5}.search-form{margin:18px 0 16px;padding:16px 16px 0;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}:deep(.json-editor textarea){font-family:"Cascadia Code",Consolas,monospace;line-height:1.55}.field-help{margin-top:6px;font-size:13px}@media(max-width:768px){.page-heading{flex-direction:column}}
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;justify-content:space-between;gap:16px;margin-bottom:18px}.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}.page-heading p,.field-help{margin:0;color:#64748b;line-height:1.5}.search-form{margin:18px 0 16px;padding:16px 16px 0;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.ratio-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.ratio-grid :deep(.el-input-number){width:100%}:deep(.json-editor textarea){font-family:"Cascadia Code",Consolas,monospace;line-height:1.55}.field-help{margin-top:6px;font-size:13px}@media(max-width:768px){.page-heading{flex-direction:column}.ratio-grid{grid-template-columns:1fr}}
</style>