Compare commits

..

1 Commits

Author SHA1 Message Date
ed8d49d9a0 fix: 多项修复 — 滴数框/零售价显示/精油英文名保存/再次审核通知
Some checks failed
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 25s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
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) <noreply@anthropic.com>
2026-04-15 10:39:19 +00:00
7 changed files with 227 additions and 10 deletions

View File

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

View File

@@ -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)
})
})
})

View File

@@ -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')
})
})

View File

@@ -66,7 +66,7 @@
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
<span class="ec-drops">{{ ing.drops }} {{ oilsStore.unitLabelPlural(ing.oil, ing.drops, cardLang) }}</span>
<span class="ec-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
</li>
</ul>
@@ -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;

View File

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

View File

@@ -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)
}

View File

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