From ed8d49d9a0d6ba59bf2b1d221783f8b8029cf43d Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Wed, 15 Apr 2026 10:39:19 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=A4=9A=E9=A1=B9=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=E2=80=94=20=E6=BB=B4=E6=95=B0=E6=A1=86/=E9=9B=B6=E5=94=AE?= =?UTF-8?q?=E4=BB=B7=E6=98=BE=E7=A4=BA/=E7=B2=BE=E6=B2=B9=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=90=8D=E4=BF=9D=E5=AD=98/=E5=86=8D=E6=AC=A1?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 编辑配方的滴数输入框从 42px 加宽到 58px,确保 50.5 不被 spinner 遮挡 2. 配方卡片在零售价==会员价时也显示零售价(之前因 retail>cost 过滤掉) 3. 精油价目英文名保存后被静态 OIL_CARDS 覆盖,把 getEnglishName 的优先级改 为先用 DB meta.enName,解决温柔呵护/仕女呵护等显示不更新的问题 4. 再次审核 tag 的配方被非管理员修改时,给管理员发通知,内容含前后 diff 5. 对应 vitest + cypress 测试各一组 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/main.py | 34 +++++++ frontend/cypress/e2e/re-review-notify.cy.js | 92 +++++++++++++++++++ frontend/src/__tests__/multiFixIssues.test.js | 87 ++++++++++++++++++ .../src/components/RecipeDetailOverlay.vue | 8 +- frontend/src/stores/oils.js | 6 +- frontend/src/views/OilReference.vue | 8 +- frontend/src/views/RecipeManager.vue | 2 +- 7 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 frontend/cypress/e2e/re-review-notify.cy.js create mode 100644 frontend/src/__tests__/multiFixIssues.test.js diff --git a/backend/main.py b/backend/main.py index f70e706..f951b6f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -887,6 +887,11 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current conn.close() raise HTTPException(409, "此配方已被其他人修改,请刷新后重试") + # Snapshot before state for re-review diff notification + before_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + before_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()] + before_tags = set(r["tag_name"] for r in c.execute("SELECT tag_name FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)).fetchall()) + if update.name is not None: c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id)) # Re-translate en_name if name changed and no explicit en_name provided @@ -925,6 +930,35 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id, rname["name"] if rname else update.name, json.dumps({"changed": "、".join(changed)}, ensure_ascii=False) if changed else None) + + # Notify admin when non-admin user edits a recipe tagged 再次审核 + after_tags = before_tags if update.tags is None else set(update.tags) + needs_review = "再次审核" in (before_tags | after_tags) + if user.get("role") != "admin" and needs_review and changed: + after_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + after_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()] + diff_lines = [] + def _fmt_ings(ings): + return "、".join(f"{i['oil_name']} {i['drops']}" for i in ings) or "(空)" + if update.name is not None and before_row["name"] != after_row["name"]: + diff_lines.append(f"名称:{before_row['name']} → {after_row['name']}") + if update.ingredients is not None and before_ings != after_ings: + diff_lines.append(f"成分:{_fmt_ings(before_ings)} → {_fmt_ings(after_ings)}") + if update.tags is not None and before_tags != after_tags: + diff_lines.append(f"标签:{'、'.join(sorted(before_tags)) or '(空)'} → {'、'.join(sorted(after_tags)) or '(空)'}") + if update.note is not None and (before_row["note"] or "") != (after_row["note"] or ""): + diff_lines.append(f"备注:{before_row['note'] or '(空)'} → {after_row['note'] or '(空)'}") + if update.en_name is not None and (before_row["en_name"] or "") != (after_row["en_name"] or ""): + diff_lines.append(f"英文名:{before_row['en_name'] or '(空)'} → {after_row['en_name'] or '(空)'}") + if diff_lines: + editor = user.get("display_name") or user.get("username") or f"user#{user['id']}" + title = f"📝 再次审核配方被修改:{after_row['name']}" + body = f"{editor} 修改了配方「{after_row['name']}」:\n\n" + "\n".join(diff_lines) + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", title, body), + ) + conn.commit() conn.close() return {"ok": True} diff --git a/frontend/cypress/e2e/re-review-notify.cy.js b/frontend/cypress/e2e/re-review-notify.cy.js new file mode 100644 index 0000000..140bbcc --- /dev/null +++ b/frontend/cypress/e2e/re-review-notify.cy.js @@ -0,0 +1,92 @@ +// Verifies that when a non-admin edits a recipe tagged 再次审核, +// an admin-targeted notification is created containing a before/after diff. +describe('Re-review notification on non-admin edit', () => { + let adminToken + let viewerToken + let recipeId + + before(() => { + cy.getAdminToken().then(t => { + adminToken = t + const uname = 'editor_' + Date.now() + cy.request({ + method: 'POST', url: '/api/register', + body: { username: uname, password: 'pw12345678' } + }).then(res => { + viewerToken = res.body.token + // Look up user id via admin /api/users, then promote to editor + cy.request({ url: '/api/users', headers: { Authorization: `Bearer ${adminToken}` } }) + .then(r => { + const u = r.body.find(x => x.username === uname) + cy.request({ + method: 'PUT', url: `/api/users/${u.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + body: { role: 'editor' }, + }) + }) + }) + }) + }) + + it('creates admin notification with diff lines', () => { + // Editor creates their own recipe tagged 再次审核 + cy.request({ + method: 'POST', url: '/api/recipes', + headers: { Authorization: `Bearer ${viewerToken}` }, + body: { + name: 're-review-fixture-' + Date.now(), + ingredients: [{ oil_name: '薰衣草', drops: 3 }], + tags: ['再次审核'], + }, + }).then(res => { + recipeId = res.body.id + expect(recipeId).to.be.a('number') + }) + + // Mark notifications read so we can detect the new one + cy.request({ + method: 'POST', url: '/api/notifications/read-all', + headers: { Authorization: `Bearer ${adminToken}` }, body: {}, + }) + + // Non-admin edits the recipe + cy.then(() => { + cy.request({ + method: 'PUT', url: `/api/recipes/${recipeId}`, + headers: { Authorization: `Bearer ${viewerToken}` }, + body: { + ingredients: [{ oil_name: '薰衣草', drops: 5 }, { oil_name: '柠檬', drops: 2 }], + note: '新备注', + }, + }).then(r => expect(r.status).to.eq(200)) + }) + + // Admin sees a new unread notification mentioning the recipe and diff + cy.then(() => { + cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } }) + .then(res => { + const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改')) + expect(unread.length).to.be.greaterThan(0) + expect(unread[0].body).to.match(/成分|备注/) + expect(unread[0].body).to.contain('→') + }) + }) + }) + + it('admin edits do NOT create re-review notification', () => { + cy.request({ + method: 'POST', url: '/api/notifications/read-all', + headers: { Authorization: `Bearer ${adminToken}` }, body: {}, + }) + cy.request({ + method: 'PUT', url: `/api/recipes/${recipeId}`, + headers: { Authorization: `Bearer ${adminToken}` }, + body: { note: '管理员备注' }, + }) + cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } }) + .then(res => { + const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改')) + expect(unread.length).to.eq(0) + }) + }) +}) diff --git a/frontend/src/__tests__/multiFixIssues.test.js b/frontend/src/__tests__/multiFixIssues.test.js new file mode 100644 index 0000000..6d2037c --- /dev/null +++ b/frontend/src/__tests__/multiFixIssues.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' + +// Replicates the fixed fmtCostWithRetail logic: retail shown whenever any ingredient +// has a retail price stored (even when it equals the member price). +function fmtCostWithRetail(ingredients, oilsMeta) { + const cost = ingredients.reduce((s, i) => { + const m = oilsMeta[i.oil] + return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0) + }, 0) + const retail = ingredients.reduce((s, i) => { + const m = oilsMeta[i.oil] + if (m && m.retailPrice && m.dropCount) return s + (m.retailPrice / m.dropCount) * i.drops + return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0) + }, 0) + const anyRetail = ingredients.some(i => { + const m = oilsMeta[i.oil] + return m && m.retailPrice && m.dropCount + }) + if (anyRetail && retail > 0) { + return { cost: '¥ ' + cost.toFixed(2), retail: '¥ ' + retail.toFixed(2), hasRetail: true } + } + return { cost: '¥ ' + cost.toFixed(2), retail: null, hasRetail: false } +} + +describe('fmtCostWithRetail — retail price display', () => { + it('shows retail when retail > member', () => { + const meta = { '玫瑰': { bottlePrice: 100, retailPrice: 150, dropCount: 10 } } + const r = fmtCostWithRetail([{ oil: '玫瑰', drops: 5 }], meta) + expect(r.hasRetail).toBe(true) + expect(r.retail).toBe('¥ 75.00') + }) + + it('still shows retail when retail === member (regression: 带玫瑰护手霜 case)', () => { + const meta = { '玫瑰护手霜': { bottlePrice: 300, retailPrice: 300, dropCount: 50 } } + const r = fmtCostWithRetail([{ oil: '玫瑰护手霜', drops: 5 }], meta) + expect(r.hasRetail).toBe(true) + expect(r.cost).toBe('¥ 30.00') + expect(r.retail).toBe('¥ 30.00') + }) + + it('no retail when ingredient has no retail price', () => { + const meta = { '薰衣草': { bottlePrice: 100, retailPrice: null, dropCount: 10 } } + const r = fmtCostWithRetail([{ oil: '薰衣草', drops: 5 }], meta) + expect(r.hasRetail).toBe(false) + expect(r.retail).toBeNull() + }) +}) + +// getEnglishName priority fix — DB en_name must beat static card override. +function getEnglishName(name, oilsMeta, cards, aliases, oilEnFn) { + const meta = oilsMeta[name] + if (meta?.enName) return meta.enName + if (cards[name]?.en) return cards[name].en + if (aliases[name] && cards[aliases[name]]?.en) return cards[aliases[name]].en + const base = name.replace(/呵护$/, '') + if (base !== name && cards[base]?.en) return cards[base].en + return oilEnFn ? oilEnFn(name) : '' +} + +describe('getEnglishName — DB wins over static card', () => { + const cards = { + '温柔呵护': { en: 'Soft Talk' }, + '椒样薄荷': { en: 'Peppermint' }, + '西班牙牛至': { en: 'Oregano' }, + } + const aliases = { '仕女呵护': '温柔呵护', '薄荷呵护': '椒样薄荷', '牛至呵护': '西班牙牛至' } + + it('uses DB en_name over static card en (温柔呵护 regression)', () => { + const meta = { '温柔呵护': { enName: 'Clary Calm' } } + expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Clary Calm') + }) + + it('uses DB en_name over aliased card en (仕女呵护 regression)', () => { + const meta = { '仕女呵护': { enName: 'Soft Talk Touch' } } + expect(getEnglishName('仕女呵护', meta, cards, aliases)).toBe('Soft Talk Touch') + }) + + it('falls back to static card when DB en_name is empty', () => { + const meta = { '温柔呵护': { enName: '' } } + expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Soft Talk') + }) + + it('alias still works as fallback', () => { + const meta = { '牛至呵护': {} } + expect(getEnglishName('牛至呵护', meta, cards, aliases)).toBe('Oregano') + }) +}) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index 88a9b76..0692035 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -66,7 +66,7 @@ {{ getCardOilName(ing.oil) }} {{ ing.drops }} {{ oilsStore.unitLabelPlural(ing.oil, ing.drops, cardLang) }} {{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }} - {{ hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }} + {{ hasRetailForOil(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }} @@ -591,7 +591,7 @@ function getCardRecipeName() { } const cardHasAnyRetail = computed(() => - cardIngredients.value.some(ing => hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil)) + cardIngredients.value.some(ing => hasRetailForOil(ing.oil)) ) const cardTitleSize = computed(() => { @@ -1699,8 +1699,8 @@ async function saveRecipe() { } .editor-drops { - width: 42px; - padding: 5px 2px; + width: 58px; + padding: 5px 4px 5px 6px; border: 1.5px solid var(--border, #e0d4c0); border-radius: 8px; font-size: 13px; diff --git a/frontend/src/stores/oils.js b/frontend/src/stores/oils.js index 256b5fe..490a78c 100644 --- a/frontend/src/stores/oils.js +++ b/frontend/src/stores/oils.js @@ -50,7 +50,11 @@ export const useOilsStore = defineStore('oils', () => { const cost = calcCost(ingredients) const retail = calcRetailCost(ingredients) const costStr = fmtPrice(cost) - if (retail > cost) { + const anyRetail = ingredients.some(i => { + const m = oilsMeta.value[i.oil] + return m && m.retailPrice && m.dropCount + }) + if (anyRetail && retail > 0) { return { cost: costStr, retail: fmtPrice(retail), hasRetail: true } } return { cost: costStr, retail: null, hasRetail: false } diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index df6a3a2..dadd641 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -670,12 +670,12 @@ function getMeta(name) { } function getEnglishName(name) { - // 1. Oil card has priority - const card = getOilCard(name) - if (card && card.en) return card.en - // 2. Stored en_name in meta + // 1. User-edited en_name in DB wins — prevents saves being masked by static cards const meta = oils.oilsMeta[name] if (meta?.enName) return meta.enName + // 2. Oil card fallback + const card = getOilCard(name) + if (card && card.en) return card.en // 3. Static translation map return oilEn(name) } diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 5f17e50..042ce8b 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -2114,7 +2114,7 @@ watch(() => recipeStore.recipes, () => { .editor-table th { text-align: center; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; } .editor-table th:first-child { text-align: left; } .editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; } -.editor-drops { width: 42px; padding: 5px 2px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; } +.editor-drops { width: 58px; padding: 5px 4px 5px 6px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; } .editor-drops:focus { border-color: #7ec6a4; } .drops-with-unit { display: flex; align-items: center; gap: 2px; } .unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; } From 50751ed9bea628c00943bde2c0cef4296f387e95 Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Wed, 15 Apr 2026 21:03:25 +0000 Subject: [PATCH 2/2] ci: retrigger after backend crash flake