Compare commits
4 Commits
fix/hourly
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f004a02cd | |||
| 50751ed9be | |||
| f34dd49dcb | |||
| ed8d49d9a0 |
@@ -887,6 +887,11 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
|
|||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(409, "此配方已被其他人修改,请刷新后重试")
|
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:
|
if update.name is not None:
|
||||||
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
|
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
|
# 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,
|
log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id,
|
||||||
rname["name"] if rname else update.name,
|
rname["name"] if rname else update.name,
|
||||||
json.dumps({"changed": "、".join(changed)}, ensure_ascii=False) if changed else None)
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
92
frontend/cypress/e2e/re-review-notify.cy.js
Normal file
92
frontend/cypress/e2e/re-review-notify.cy.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,6 +26,14 @@ describe('Recipe Search', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('searching by oil name returns recipes containing that oil', () => {
|
||||||
|
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
||||||
|
cy.wait(500)
|
||||||
|
cy.get('.search-results-section, .recipe-card', { timeout: 5000 }).should('exist')
|
||||||
|
// At least one result card should exist (any recipe using 薰衣草)
|
||||||
|
cy.get('.recipe-card').should('have.length.gte', 1)
|
||||||
|
})
|
||||||
|
|
||||||
it('clears search and restores all recipes', () => {
|
it('clears search and restores all recipes', () => {
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
||||||
|
|||||||
87
frontend/src/__tests__/multiFixIssues.test.js
Normal file
87
frontend/src/__tests__/multiFixIssues.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
57
frontend/src/__tests__/recipeSearchByOil.test.js
Normal file
57
frontend/src/__tests__/recipeSearchByOil.test.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Mirrors the exactResults matching rule in RecipeSearch.vue
|
||||||
|
function matches(recipes, q, oilEn = (s) => '') {
|
||||||
|
const query = (q || '').trim().toLowerCase()
|
||||||
|
if (!query) return []
|
||||||
|
const isEn = /^[a-zA-Z\s]+$/.test(query)
|
||||||
|
return recipes.filter(r => {
|
||||||
|
if (r.tags && r.tags.includes('已下架')) return false
|
||||||
|
const nameMatch = r.name.toLowerCase().includes(query)
|
||||||
|
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(query)
|
||||||
|
const oilEnMatch = isEn && (r.ingredients || []).some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(query))
|
||||||
|
const oilZhMatch = query.length >= 2 && (r.ingredients || []).some(ing => ing.oil.toLowerCase().includes(query))
|
||||||
|
const tagMatch = (r.tags || []).some(t => t.toLowerCase().includes(query))
|
||||||
|
return nameMatch || enNameMatch || oilEnMatch || oilZhMatch || tagMatch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipes = [
|
||||||
|
{ name: '助眠晚安', tags: [], ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '乳香', drops: 2 }] },
|
||||||
|
{ name: '提神醒脑', tags: [], ingredients: [{ oil: '椒样薄荷', drops: 2 }, { oil: '柠檬', drops: 3 }] },
|
||||||
|
{ name: '肩颈舒缓', tags: ['舒缓'], ingredients: [{ oil: '西班牙牛至', drops: 1 }, { oil: '椰子油', drops: 10 }] },
|
||||||
|
{ name: '感冒护理', tags: [], ingredients: [{ oil: '牛至呵护', drops: 2 }] },
|
||||||
|
{ name: '下架配方', tags: ['已下架'], ingredients: [{ oil: '薰衣草', drops: 1 }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Recipe search by oil name', () => {
|
||||||
|
it('finds recipes containing the oil (Chinese exact)', () => {
|
||||||
|
const r = matches(recipes, '薰衣草')
|
||||||
|
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds multiple recipes for a common oil', () => {
|
||||||
|
expect(matches(recipes, '牛至').map(x => x.name).sort()).toEqual(['感冒护理', '肩颈舒缓'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes 已下架 recipes', () => {
|
||||||
|
const r = matches(recipes, '薰衣草')
|
||||||
|
expect(r.some(x => x.name === '下架配方')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('single-char query does not match oil names (avoids noise)', () => {
|
||||||
|
const r = matches(recipes, '草')
|
||||||
|
expect(r).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still matches recipe name for short queries', () => {
|
||||||
|
const r = matches(recipes, '晚')
|
||||||
|
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches english oil name when query is english', () => {
|
||||||
|
const oilEn = (o) => ({ '薰衣草': 'Lavender', '乳香': 'Frankincense' }[o] || '')
|
||||||
|
const r = matches(recipes, 'Lavender', oilEn)
|
||||||
|
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
|
<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-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 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -591,7 +591,7 @@ function getCardRecipeName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardHasAnyRetail = computed(() =>
|
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(() => {
|
const cardTitleSize = computed(() => {
|
||||||
@@ -1699,8 +1699,8 @@ async function saveRecipe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-drops {
|
.editor-drops {
|
||||||
width: 42px;
|
width: 58px;
|
||||||
padding: 5px 2px;
|
padding: 5px 4px 5px 6px;
|
||||||
border: 1.5px solid var(--border, #e0d4c0);
|
border: 1.5px solid var(--border, #e0d4c0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
const cost = calcCost(ingredients)
|
const cost = calcCost(ingredients)
|
||||||
const retail = calcRetailCost(ingredients)
|
const retail = calcRetailCost(ingredients)
|
||||||
const costStr = fmtPrice(cost)
|
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: fmtPrice(retail), hasRetail: true }
|
||||||
}
|
}
|
||||||
return { cost: costStr, retail: null, hasRetail: false }
|
return { cost: costStr, retail: null, hasRetail: false }
|
||||||
|
|||||||
@@ -670,12 +670,12 @@ function getMeta(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEnglishName(name) {
|
function getEnglishName(name) {
|
||||||
// 1. Oil card has priority
|
// 1. User-edited en_name in DB wins — prevents saves being masked by static cards
|
||||||
const card = getOilCard(name)
|
|
||||||
if (card && card.en) return card.en
|
|
||||||
// 2. Stored en_name in meta
|
|
||||||
const meta = oils.oilsMeta[name]
|
const meta = oils.oilsMeta[name]
|
||||||
if (meta?.enName) return meta.enName
|
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
|
// 3. Static translation map
|
||||||
return oilEn(name)
|
return oilEn(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 th:first-child { text-align: left; }
|
||||||
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
|
.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; }
|
.editor-drops:focus { border-color: #7ec6a4; }
|
||||||
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
|
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
|
||||||
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }
|
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ function expandQuery(q) {
|
|||||||
return terms
|
return terms
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search results: exact matches (query in recipe name or tags, NOT oil names to avoid noise like 西班牙牛至)
|
// Search results: matches in recipe name, tags, oil names (zh + en)
|
||||||
const exactResults = computed(() => {
|
const exactResults = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return []
|
if (!searchQuery.value.trim()) return []
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
@@ -322,9 +322,10 @@ const exactResults = computed(() => {
|
|||||||
const nameMatch = r.name.toLowerCase().includes(q)
|
const nameMatch = r.name.toLowerCase().includes(q)
|
||||||
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(q)
|
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(q)
|
||||||
const oilEnMatch = isEn && r.ingredients.some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(q))
|
const oilEnMatch = isEn && r.ingredients.some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(q))
|
||||||
|
const oilZhMatch = q.length >= 2 && r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
|
||||||
const visibleTags = auth.canEdit ? (r.tags || []) : (r.tags || []).filter(t => !EDITOR_ONLY_TAGS.includes(t))
|
const visibleTags = auth.canEdit ? (r.tags || []) : (r.tags || []).filter(t => !EDITOR_ONLY_TAGS.includes(t))
|
||||||
const tagMatch = visibleTags.some(t => t.toLowerCase().includes(q))
|
const tagMatch = visibleTags.some(t => t.toLowerCase().includes(q))
|
||||||
return nameMatch || enNameMatch || oilEnMatch || tagMatch
|
return nameMatch || enNameMatch || oilEnMatch || oilZhMatch || tagMatch
|
||||||
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user