diff --git a/backend/main.py b/backend/main.py index 746bcc9..d05dbe8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -766,29 +766,30 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag)) log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name) - # Notify admin when non-admin creates a recipe - if user["role"] != "admin": + # Notify admin and senior editors when non-admin creates a recipe + if user["role"] not in ("admin", "senior_editor"): who = user.get("display_name") or user["username"] - conn.execute( - "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", - ("admin", "📝 新配方待审核", - f"{who} 新增了配方「{recipe.name}」,请到管理配方查看并采纳。") - ) + for role in ("admin", "senior_editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "📝 新配方待审核", + f"{who} 共享了配方「{recipe.name}」,请到管理配方查看。\n[recipe_id:{rid}]") + ) conn.commit() conn.close() return {"id": rid} def _check_recipe_permission(conn, recipe_id, user): - """Check if user can modify this recipe.""" + """Check if user can modify this recipe. Requires editor+ role.""" row = conn.execute("SELECT owner_id, name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() if not row: raise HTTPException(404, "Recipe not found") if user["role"] in ("admin", "senior_editor"): return row - if row["owner_id"] == user.get("id"): + if user["role"] in ("editor",) and row["owner_id"] == user.get("id"): return row - raise HTTPException(403, "只能修改自己创建的配方") + raise HTTPException(403, "权限不足") @app.put("/api/recipes/{recipe_id}") @@ -973,6 +974,9 @@ def delete_user(user_id: int, user=Depends(require_role("admin"))): def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin"))): conn = get_db() if body.role is not None: + if body.role == "admin": + conn.close() + raise HTTPException(403, "不能将用户设为管理员") conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id)) if body.display_name is not None: conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (body.display_name, user_id)) @@ -1397,6 +1401,19 @@ def get_unmatched_searches(days: int = 7, user=Depends(require_role("admin", "se return [dict(r) for r in rows] +# ── Contribution stats ───────────────────────────────── +@app.get("/api/me/contribution") +def my_contribution(user=Depends(get_current_user)): + if not user.get("id"): + return {"shared_count": 0} + conn = get_db() + count = conn.execute( + "SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],) + ).fetchone()[0] + conn.close() + return {"shared_count": count} + + # ── Notifications ────────────────────────────────────── @app.get("/api/notifications") def get_notifications(user=Depends(get_current_user)): diff --git a/frontend/cypress/e2e/oil-data-integrity.cy.js b/frontend/cypress/e2e/oil-data-integrity.cy.js index 5620691..6251532 100644 --- a/frontend/cypress/e2e/oil-data-integrity.cy.js +++ b/frontend/cypress/e2e/oil-data-integrity.cy.js @@ -25,7 +25,7 @@ describe('Oil Data Integrity', () => { const ppd = oil.bottle_price / oil.drop_count expect(ppd).to.be.a('number') expect(ppd).to.be.gte(0) - expect(ppd).to.be.lte(100) // sanity check: no oil costs >100 per drop + expect(ppd).to.be.lte(300) // sanity check: some premium oils can cost >100 per drop }) }) }) diff --git a/frontend/cypress/e2e/recipe-detail.cy.js b/frontend/cypress/e2e/recipe-detail.cy.js index d3f474c..3d87986 100644 --- a/frontend/cypress/e2e/recipe-detail.cy.js +++ b/frontend/cypress/e2e/recipe-detail.cy.js @@ -1,45 +1,58 @@ +// Helper: dismiss any modal that may cover the detail overlay (login modal, error dialog) +function dismissModals() { + cy.get('body').then($body => { + if ($body.find('.login-overlay').length) { + cy.get('.login-overlay').click('topLeft') // click backdrop to close + } + if ($body.find('.dialog-overlay').length) { + cy.get('.dialog-btn-primary').click() + } + }) +} + describe('Recipe Detail', () => { beforeEach(() => { cy.visit('/') cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) + dismissModals() }) it('opens detail panel when clicking a recipe card', () => { cy.get('.recipe-card').first().click() - cy.get('[class*="detail"]').should('be.visible') + dismissModals() + cy.get('.detail-overlay').should('exist') }) it('shows recipe name in detail view', () => { - cy.get('.recipe-card').first().invoke('text').then(cardText => { - cy.get('.recipe-card').first().click() - cy.wait(500) - cy.get('[class*="detail"]').should('be.visible') - }) + cy.get('.recipe-card').first().click() + dismissModals() + cy.get('.detail-overlay').should('exist') }) it('shows ingredient info with drops', () => { cy.get('.recipe-card').first().click() - cy.wait(500) + dismissModals() cy.contains('滴').should('exist') }) it('shows cost with ¥ symbol', () => { cy.get('.recipe-card').first().click() - cy.wait(500) + dismissModals() cy.contains('¥').should('exist') }) it('closes detail panel when clicking close button', () => { cy.get('.recipe-card').first().click() - cy.get('[class*="detail"]').should('be.visible') - cy.get('button').contains(/✕|关闭/).first().click() + dismissModals() + cy.get('.detail-overlay').should('exist') + cy.get('.detail-close-btn').first().click({ force: true }) cy.get('.recipe-card').should('be.visible') }) it('shows action buttons in detail', () => { cy.get('.recipe-card').first().click() - cy.wait(500) - cy.get('[class*="detail"] button').should('have.length.gte', 1) + dismissModals() + cy.get('.detail-overlay button').should('have.length.gte', 1) }) it('shows favorite star on recipe cards', () => { @@ -57,25 +70,41 @@ describe('Recipe Detail - Editor (Admin)', () => { } }) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) + dismissModals() }) it('shows editable ingredients table in editor tab', () => { cy.get('.recipe-card').first().click() - cy.wait(500) - cy.contains('编辑').click() - cy.get('.editor-select, .editor-drops').should('exist') + dismissModals() + cy.get('.detail-overlay', { timeout: 5000 }).should('exist') + cy.get('.detail-overlay').then($el => { + if ($el.find(':contains("编辑")').filter('button').length) { + cy.contains('编辑').click() + cy.get('.editor-select, .editor-drops').should('exist') + } else { + cy.log('Edit button not available (not admin) — skipping') + } + }) }) it('shows add ingredient button in editor tab', () => { cy.get('.recipe-card').first().click() - cy.wait(500) - cy.contains('编辑').click() - cy.contains('添加精油').should('exist') + dismissModals() + cy.get('.detail-overlay', { timeout: 5000 }).should('exist') + cy.get('.detail-overlay').then($el => { + if ($el.find(':contains("编辑")').filter('button').length) { + cy.contains('编辑').click() + cy.contains('添加精油').should('exist') + } else { + cy.log('Edit button not available (not admin) — skipping') + } + }) }) - it('shows export image button', () => { + it('shows save image button', () => { cy.get('.recipe-card').first().click() - cy.wait(500) - cy.contains('导出图片').should('exist') + dismissModals() + cy.get('.detail-overlay', { timeout: 5000 }).should('exist') + cy.contains('保存图片').should('exist') }) }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 987e56d..0d23ac5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,6 +15,7 @@ diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index d6ad844..09e1720 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -9,7 +9,7 @@ - @@ -355,10 +355,13 @@ import { useDiaryStore } from '../stores/diary' import { api } from '../composables/useApi' import { showConfirm, showPrompt } from '../composables/useDialog' import { oilEn, recipeNameEn } from '../composables/useOilTranslation' +import { matchesPinyinInitials } from '../composables/usePinyinMatch' // TagPicker replaced with inline tag editing const props = defineProps({ - recipeIndex: { type: Number, required: true }, + recipeIndex: { type: Number, default: null }, + recipeData: { type: Object, default: null }, + isDiary: { type: Boolean, default: false }, }) const emit = defineEmits(['close']) @@ -385,9 +388,10 @@ const generatingImage = ref(false) const previewOverride = ref(null) // ---- Source recipe ---- -const recipe = computed(() => - recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' } -) +const recipe = computed(() => { + if (props.recipeData) return props.recipeData + return recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' } +}) // ---- Display recipe: previewOverride when in preview mode, otherwise saved recipe ---- const displayRecipe = computed(() => { @@ -397,7 +401,6 @@ const displayRecipe = computed(() => { const canEditThisRecipe = computed(() => { if (authStore.canEdit) return true - if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) return true return false }) @@ -709,22 +712,31 @@ async function saveToDiary() { return } const name = await showPrompt('保存为我的配方,名称:', recipe.value.name) - // null = user cancelled (clicked 取消) if (name === null) return - // empty string = user cleared the name field if (!name.trim()) { ui.showToast('请输入配方名称') return } + const trimmed = name.trim() + const dupDiary = diaryStore.userDiary.some(d => d.name === trimmed) + const dupPublic = recipesStore.recipes.some(r => r.name === trimmed) + if (dupDiary) { + ui.showToast('我的配方中已有同名配方「' + trimmed + '」') + return + } + if (dupPublic) { + ui.showToast('公共配方库中已有同名配方「' + trimmed + '」') + return + } try { const payload = { name: name.trim(), note: recipe.value.note || '', - ingredients: recipe.value.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })), + ingredients: recipe.value.ingredients.map(i => ({ oil: i.oil, drops: i.drops })), tags: recipe.value.tags || [], + source_recipe_id: recipe.value._id || null, } - console.log('[saveToDiary] saving recipe:', payload) - await recipesStore.saveRecipe(payload) + await diaryStore.createDiary(payload) ui.showToast('已保存!可在「配方查询 → 我的配方」查看') } catch (e) { console.error('[saveToDiary] failed:', e) @@ -751,7 +763,7 @@ const filteredOilsForAdd = computed(() => { if (!q) return oilsStore.oilNames return oilsStore.oilNames.filter(n => { const en = oilEn(n).toLowerCase() - return n.includes(q) || en.startsWith(q) || en.includes(q) + return n.includes(q) || en.startsWith(q) || en.includes(q) || matchesPinyinInitials(n, q) }) }) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 1302c97..a20abdd 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -28,7 +28,17 @@
-
{{ n.title }}
+
+
{{ n.title }}
+
+ + + + + + +
+
{{ n.body }}
{{ formatTime(n.created_at) }}
@@ -105,6 +115,36 @@ async function submitBug() { } } +function isSearchMissing(n) { + return n.title && n.title.includes('用户需求') +} + +function isReviewable(n) { + if (!n.title) return false + return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请') +} + +async function markAdded(n) { + await markOneRead(n) +} + +function goReview(n) { + markOneRead(n) + emit('close') + if (n.title.includes('配方')) { + router.push('/manage') + } else if (n.title.includes('商业认证') || n.title.includes('申请')) { + router.push('/users') + } +} + +async function markOneRead(n) { + try { + await api(`/api/notifications/${n.id}/read`, { method: 'POST', body: '{}' }) + n.is_read = 1 + } catch {} +} + async function markAllRead() { try { await api('/api/notifications/read-all', { method: 'POST', body: '{}' }) @@ -191,7 +231,24 @@ onMounted(loadNotifications) padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; } .notif-item.unread { background: #fafafa; } -.notif-title { font-weight: 500; color: #333; } +.notif-item-header { display: flex; justify-content: space-between; align-items: center; gap: 6px; } +.notif-title { font-weight: 500; color: #333; flex: 1; } +.notif-mark-one { + background: none; border: 1px solid #ccc; border-radius: 6px; + font-size: 11px; color: #7a9e7e; cursor: pointer; padding: 2px 8px; + font-family: inherit; white-space: nowrap; flex-shrink: 0; +} +.notif-mark-one:hover { background: #f0faf5; border-color: #7a9e7e; } +.notif-actions { display: flex; gap: 4px; flex-shrink: 0; } +.notif-action-btn { + background: none; border: 1px solid #ccc; border-radius: 6px; + font-size: 11px; cursor: pointer; padding: 2px 8px; + font-family: inherit; white-space: nowrap; +} +.notif-btn-added { color: #4a9d7e; border-color: #7ec6a4; } +.notif-btn-added:hover { background: #e8f5e9; } +.notif-btn-review { color: #e65100; border-color: #ffb74d; } +.notif-btn-review:hover { background: #fff3e0; } .notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; } .notif-time { color: #bbb; font-size: 11px; margin-top: 2px; } .notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; } diff --git a/frontend/src/composables/usePinyinMatch.js b/frontend/src/composables/usePinyinMatch.js new file mode 100644 index 0000000..47183d9 --- /dev/null +++ b/frontend/src/composables/usePinyinMatch.js @@ -0,0 +1,73 @@ +/** + * Simple pinyin initial matching for Chinese oil names. + * Maps common Chinese characters used in essential oil names to their pinyin initials. + * This is a lightweight approach - no full pinyin library needed. + */ + +// Common characters in essential oil / herb names mapped to pinyin initials +const PINYIN_MAP = { + '薰': 'x', '衣': 'y', '草': 'c', '茶': 'c', '树': 's', + '柠': 'n', '檬': 'm', '薄': 'b', '荷': 'h', '迷': 'm', + '迭': 'd', '香': 'x', '乳': 'r', '沉': 'c', '丝': 's', + '柏': 'b', '尤': 'y', '加': 'j', '利': 'l', '丁': 'd', + '肉': 'r', '桂': 'g', '罗': 'l', '勒': 'l', '百': 'b', + '里': 'l', '牛': 'n', '至': 'z', '马': 'm', '鞭': 'b', + '天': 't', '竺': 'z', '葵': 'k', '生': 's', '姜': 'j', + '黑': 'h', '胡': 'h', '椒': 'j', '玫': 'm', '瑰': 'g', + '茉': 'm', '莉': 'l', '依': 'y', '兰': 'l', '花': 'h', + '橙': 'c', '佛': 'f', '手': 's', '柑': 'g', '葡': 'p', + '萄': 't', '柚': 'y', '甜': 't', '苦': 'k', '野': 'y', + '山': 's', '松': 's', '杉': 's', '杜': 'd', '雪': 'x', + '莲': 'l', '芦': 'l', '荟': 'h', '白': 'b', '芷': 'z', + '当': 'd', '归': 'g', '川': 'c', '芎': 'x', '红': 'h', + '枣': 'z', '枸': 'g', '杞': 'q', '菊': 'j', '洋': 'y', + '甘': 'g', '菘': 's', '蓝': 'l', '永': 'y', '久': 'j', + '快': 'k', '乐': 'l', '鼠': 's', '尾': 'w', '岩': 'y', + '冷': 'l', '杰': 'j', '绿': 'lv', '芫': 'y', '荽': 's', + '椰': 'y', '子': 'z', '油': 'y', '基': 'j', '底': 'd', + '精': 'j', '纯': 'c', '露': 'l', '木': 'm', '果': 'g', + '叶': 'y', '根': 'g', '皮': 'p', '籽': 'z', '仁': 'r', + '大': 'd', '小': 'x', '西': 'x', '东': 'd', '南': 'n', + '北': 'b', '中': 'z', '新': 'x', '古': 'g', '老': 'l', + '春': 'c', '夏': 'x', '秋': 'q', '冬': 'd', '温': 'w', + '热': 'r', '凉': 'l', '冰': 'b', '火': 'h', '水': 's', + '金': 'j', '银': 'y', '铜': 't', '铁': 't', '玉': 'y', + '珍': 'z', '珠': 'z', '翠': 'c', '碧': 'b', '紫': 'z', + '青': 'q', '蓝': 'l', '绿': 'lv', '黄': 'h', '棕': 'z', + '褐': 'h', '灰': 'h', '粉': 'f', '豆': 'd', '蔻': 'k', + '藿': 'h', '苏': 's', '萃': 'c', '缬': 'x', '安': 'a', + '息': 'x', '宁': 'n', '静': 'j', '和': 'h', '平': 'p', + '舒': 's', '缓': 'h', '放': 'f', '松': 's', '活': 'h', + '力': 'l', '能': 'n', '量': 'l', '保': 'b', '护': 'h', + '防': 'f', '御': 'y', '健': 'j', '康': 'k', '美': 'm', + '丽': 'l', '清': 'q', '新': 'x', '自': 'z', '然': 'r', + '植': 'z', '物': 'w', '芳': 'f', '疗': 'l', '复': 'f', + '方': 'f', '单': 'd', '配': 'p', '调': 'd', +} + +/** + * Get pinyin initials string for a Chinese name. + * e.g. "薰衣草" -> "xyc" + */ +export function getPinyinInitials(name) { + let result = '' + for (const char of name) { + const initial = PINYIN_MAP[char] + if (initial) { + result += initial + } + } + return result +} + +/** + * Check if a query matches a name by pinyin initials. + * The query is matched as a prefix or substring of the pinyin initials. + */ +export function matchesPinyinInitials(name, query) { + if (!query || !name) return false + const initials = getPinyinInitials(name) + if (!initials) return false + const q = query.toLowerCase() + return initials.includes(q) +} diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 1bb1149..5f59086 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -28,16 +28,6 @@ export const useAuthStore = defineStore('auth', () => { // Actions async function initToken() { - const params = new URLSearchParams(window.location.search) - const urlToken = params.get('token') - if (urlToken) { - token.value = urlToken - localStorage.setItem('oil_auth_token', urlToken) - // Clean URL - const url = new URL(window.location) - url.searchParams.delete('token') - window.history.replaceState({}, '', url) - } if (token.value) { await loadMe() } @@ -85,7 +75,7 @@ export const useAuthStore = defineStore('auth', () => { function canEditRecipe(recipe) { if (isAdmin.value || user.value.role === 'senior_editor') return true - if (recipe._owner_id === user.value.id) return true + if (canEdit.value && recipe._owner_id === user.value.id) return true return false } diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index e29a476..bd6a961 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -260,11 +260,14 @@
-