diff --git a/backend/main.py b/backend/main.py index 18a7bea..b469c36 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1094,6 +1094,9 @@ def delete_user(user_id: int, user=Depends(require_role("admin"))): @app.put("/api/users/{user_id}") def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin"))): conn = get_db() + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (user_id,)).fetchone() + old_role = target["role"] if target else "unknown" + target_name = (target["display_name"] or target["username"]) if target else "unknown" if body.role is not None: if body.role == "admin": conn.close() @@ -1101,8 +1104,15 @@ def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin conn.execute("UPDATE users SET role = ?, role_changed_at = datetime('now') 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)) - log_audit(conn, user["id"], "update_user", "user", user_id, None, - json.dumps({"role": body.role, "display_name": body.display_name})) + role_labels = {"admin": "管理员", "senior_editor": "高级编辑", "editor": "编辑", "viewer": "查看者"} + detail = {} + if body.role is not None and body.role != old_role: + detail["from_role"] = role_labels.get(old_role, old_role) + detail["to_role"] = role_labels.get(body.role, body.role) + if body.display_name is not None: + detail["display_name"] = body.display_name + log_audit(conn, user["id"], "update_user", "user", user_id, target_name, + json.dumps(detail, ensure_ascii=False)) conn.commit() conn.close() return {"ok": True} diff --git a/frontend/src/__tests__/newFeatures.test.js b/frontend/src/__tests__/newFeatures.test.js new file mode 100644 index 0000000..0150bae --- /dev/null +++ b/frontend/src/__tests__/newFeatures.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { parseMultiRecipes } from '../composables/useSmartPaste' +import { getPinyinInitials, matchesPinyinInitials } from '../composables/usePinyinMatch' + +const oilNames = ['薰衣草','茶树','柠檬','芳香调理','永久花','椒样薄荷','乳香','檀香','天竺葵','佛手柑','生姜'] + +// --------------------------------------------------------------------------- +// parseMultiRecipes +// --------------------------------------------------------------------------- +describe('parseMultiRecipes', () => { + it('parses single recipe with name', () => { + const results = parseMultiRecipes('舒缓放松,薰衣草3,茶树2', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('舒缓放松') + expect(results[0].ingredients).toHaveLength(2) + }) + + it('parses recipe with space-separated parts', () => { + const results = parseMultiRecipes('长高 芳香调理8 永久花10', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('长高') + expect(results[0].ingredients.length).toBeGreaterThanOrEqual(2) + }) + + it('parses recipe with concatenated name+oil', () => { + const results = parseMultiRecipes('长高芳香调理8永久花10', oilNames) + expect(results).toHaveLength(1) + expect(results[0].name).toBe('长高') + }) + + it('parses multiple recipes', () => { + const results = parseMultiRecipes('舒缓放松,薰衣草3,茶树2,提神醒脑,柠檬5', oilNames) + expect(results).toHaveLength(2) + expect(results[0].name).toBe('舒缓放松') + expect(results[1].name).toBe('提神醒脑') + }) + + it('handles recipe with no name', () => { + const results = parseMultiRecipes('薰衣草3,茶树2', oilNames) + expect(results).toHaveLength(1) + expect(results[0].ingredients).toHaveLength(2) + }) +}) + +// --------------------------------------------------------------------------- +// Pinyin matching +// --------------------------------------------------------------------------- +describe('getPinyinInitials', () => { + it('returns correct initials for common oils', () => { + expect(getPinyinInitials('薰衣草')).toBe('xyc') + expect(getPinyinInitials('茶树')).toBe('cs') + expect(getPinyinInitials('生姜')).toBe('sj') + }) + + it('handles 忍冬花', () => { + expect(getPinyinInitials('忍冬花呵护')).toBe('rdhhh') + }) +}) + +describe('matchesPinyinInitials', () => { + it('matches prefix only', () => { + expect(matchesPinyinInitials('生姜', 's')).toBe(true) + expect(matchesPinyinInitials('生姜', 'sj')).toBe(true) + expect(matchesPinyinInitials('茶树', 's')).toBe(false) // cs doesn't start with s + expect(matchesPinyinInitials('茶树', 'cs')).toBe(true) + }) + + it('does not match substring', () => { + expect(matchesPinyinInitials('茶树', 's')).toBe(false) + }) + + it('matches 忍冬花 with r', () => { + expect(matchesPinyinInitials('忍冬花呵护', 'r')).toBe(true) + expect(matchesPinyinInitials('忍冬花呵护', 'rdh')).toBe(true) + expect(matchesPinyinInitials('忍冬花呵护', 'l')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// EDITOR_ONLY_TAGS +// --------------------------------------------------------------------------- +describe('EDITOR_ONLY_TAGS', () => { + it('exports EDITOR_ONLY_TAGS from recipes store', async () => { + const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') + expect(EDITOR_ONLY_TAGS).toContain('已审核') + }) +}) diff --git a/frontend/src/components/RecipeCard.vue b/frontend/src/components/RecipeCard.vue index b2184b0..3e8b1de 100644 --- a/frontend/src/components/RecipeCard.vue +++ b/frontend/src/components/RecipeCard.vue @@ -20,11 +20,9 @@