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; }