diff --git a/backend/database.py b/backend/database.py index 56bf3bc..bda3ef6 100644 --- a/backend/database.py +++ b/backend/database.py @@ -163,6 +163,8 @@ def init_db(): c.execute("ALTER TABLE users ADD COLUMN brand_bg TEXT") if "brand_align" not in user_cols: c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'") + if "role_changed_at" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN role_changed_at TEXT") # Migration: add tags to user_diary diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()] diff --git a/backend/main.py b/backend/main.py index 0c79e1b..18a7bea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -477,6 +477,8 @@ def business_apply(body: dict, user=Depends(get_current_user)): "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", ("admin", "🏢 商业认证申请", f"{who} 申请商业用户认证,商户名:{business_name}") ) + log_audit(conn, user["id"], "business_apply", "user", user["id"], who, + json.dumps({"business_name": business_name})) conn.commit() conn.close() return {"ok": True} @@ -501,7 +503,7 @@ def get_my_business_application(user=Depends(get_current_user)): def list_business_applications(user=Depends(require_role("admin"))): conn = get_db() rows = conn.execute( - "SELECT a.id, a.user_id, a.business_name, a.document, a.status, a.created_at, " + "SELECT a.id, a.user_id, a.business_name, a.document, a.status, a.reject_reason, a.created_at, " "u.display_name, u.username FROM business_applications a " "LEFT JOIN users u ON a.user_id = u.id ORDER BY a.id DESC" ).fetchall() @@ -518,13 +520,15 @@ def approve_business(app_id: int, user=Depends(require_role("admin"))): raise HTTPException(404, "申请不存在") conn.execute("UPDATE business_applications SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?", (app_id,)) conn.execute("UPDATE users SET business_verified = 1 WHERE id = ?", (app["user_id"],)) - # Notify user - target = conn.execute("SELECT role FROM users WHERE id = ?", (app["user_id"],)).fetchone() + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (app["user_id"],)).fetchone() + target_name = (target["display_name"] or target["username"]) if target else "unknown" if target: conn.execute( "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", (target["role"], "🎉 商业认证通过", "恭喜!你的商业用户认证已通过,现在可以使用项目核算等商业功能。", app["user_id"]) ) + log_audit(conn, user["id"], "approve_business", "user", app["user_id"], target_name, + json.dumps({"business_name": app["business_name"]})) conn.commit() conn.close() return {"ok": True} @@ -539,7 +543,8 @@ def reject_business(app_id: int, body: dict = None, user=Depends(require_role("a raise HTTPException(404, "申请不存在") reason = (body or {}).get("reason", "").strip() conn.execute("UPDATE business_applications SET status = 'rejected', reviewed_at = datetime('now'), reject_reason = ? WHERE id = ?", (reason, app_id)) - target = conn.execute("SELECT role FROM users WHERE id = ?", (app["user_id"],)).fetchone() + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (app["user_id"],)).fetchone() + target_name = (target["display_name"] or target["username"]) if target else "unknown" if target: msg = "你的商业用户认证申请未通过。" if reason: @@ -549,6 +554,8 @@ def reject_business(app_id: int, body: dict = None, user=Depends(require_role("a "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", (target["role"], "商业认证未通过", msg, app["user_id"]) ) + log_audit(conn, user["id"], "reject_business", "user", app["user_id"], target_name, + json.dumps({"reason": reason})) conn.commit() conn.close() return {"ok": True} @@ -614,6 +621,23 @@ def reject_translation(sid: int, user=Depends(require_role("admin"))): return {"ok": True} +@app.post("/api/business-grant/{user_id}") +def grant_business(user_id: int, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("UPDATE users SET business_verified = 1 WHERE id = ?", (user_id,)) + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (user_id,)).fetchone() + target_name = (target["display_name"] or target["username"]) if target else "unknown" + if target: + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (target["role"], "🎉 商业认证已开通", "管理员已为你开通商业用户认证,现在可以使用商业核算等功能。", user_id) + ) + log_audit(conn, user["id"], "grant_business", "user", user_id, target_name, None) + conn.commit() + conn.close() + return {"ok": True} + + @app.post("/api/business-revoke/{user_id}") def revoke_business(user_id: int, body: dict = None, user=Depends(require_role("admin"))): conn = get_db() @@ -629,6 +653,9 @@ def revoke_business(user_id: int, body: dict = None, user=Depends(require_role(" "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", (target["role"], "商业资格已取消", msg, user_id) ) + target_name = (target["display_name"] or target["username"]) if target else "unknown" + log_audit(conn, user["id"], "revoke_business", "user", user_id, target_name, + json.dumps({"reason": reason}) if reason else None) conn.commit() conn.close() return {"ok": True} @@ -754,8 +781,14 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): raise HTTPException(401, "请先登录") conn = get_db() c = conn.cursor() + # Senior editors adding directly to public library: set owner to admin so everyone can see + owner_id = user["id"] + if user["role"] in ("senior_editor",): + admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + if admin: + owner_id = admin["id"] c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", - (recipe.name, recipe.note, user["id"])) + (recipe.name, recipe.note, owner_id)) rid = c.lastrowid for ing in recipe.ingredients: c.execute( @@ -766,15 +799,21 @@ 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 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"] - 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}]") - ) + who = user.get("display_name") or user["username"] + if user["role"] == "senior_editor": + # Senior editor adds directly — just inform admin + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "📋 新配方已添加", + f"{who} 将配方「{recipe.name}」添加到了公共配方库。\n[recipe_id:{rid}]") + ) + elif user["role"] not in ("admin",): + # Other users need review + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "📝 新配方待审核", + f"{who} 共享了配方「{recipe.name}」,请到管理配方查看。\n[recipe_id:{rid}]") + ) conn.commit() conn.close() return {"id": rid} @@ -785,9 +824,7 @@ def _check_recipe_permission(conn, recipe_id, user): 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 user["role"] in ("editor",) and row["owner_id"] == user.get("id"): + if user["role"] in ("admin", "senior_editor", "editor"): return row raise HTTPException(403, "权限不足") @@ -881,7 +918,7 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))): @app.post("/api/recipes/{recipe_id}/reject") -def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("admin"))): +def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("admin", "senior_editor"))): conn = get_db() row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone() if not row: @@ -903,8 +940,55 @@ def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role(" conn.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) conn.execute("DELETE FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)) conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + from_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown" log_audit(conn, user["id"], "reject_recipe", "recipe", recipe_id, row["name"], - json.dumps({"reason": reason})) + json.dumps({"reason": reason, "from_user": from_name})) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/recipes/{recipe_id}/recommend") +def recommend_recipe(recipe_id: int, body: dict = None, user=Depends(get_current_user)): + """Senior editor recommends a recipe for admin approval.""" + if user["role"] not in ("senior_editor", "admin"): + raise HTTPException(403, "权限不足") + conn = get_db() + recipe = conn.execute("SELECT name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not recipe: + conn.close() + raise HTTPException(404, "配方不存在") + who = user.get("display_name") or user.get("username") + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "👍 配方推荐通过", + f"{who} 审核了配方「{recipe['name']}」并推荐通过,请最终确认。\n[recipe_id:{recipe_id}]") + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/recipes/{recipe_id}/assign-review") +def assign_recipe_review(recipe_id: int, body: dict, user=Depends(require_role("admin"))): + reviewer_id = body.get("user_id") + if not reviewer_id: + raise HTTPException(400, "请选择审核人") + conn = get_db() + recipe = conn.execute("SELECT name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not recipe: + conn.close() + raise HTTPException(404, "配方不存在") + reviewer = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (reviewer_id,)).fetchone() + if not reviewer: + conn.close() + raise HTTPException(404, "用户不存在") + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (reviewer["role"], "📋 请审核配方", + f"管理员指派你审核配方「{recipe['name']}」,请到管理配方页面查看并反馈意见。\n[recipe_id:{recipe_id}]", + reviewer_id) + ) conn.commit() conn.close() return {"ok": True} @@ -1014,7 +1098,7 @@ def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin if body.role == "admin": conn.close() raise HTTPException(403, "不能将用户设为管理员") - conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id)) + 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, @@ -1223,14 +1307,15 @@ def create_diary(body: dict, user=Depends(get_current_user)): name = body.get("name", "").strip() ingredients = body.get("ingredients", []) note = body.get("note", "") + tags = body.get("tags", []) source_id = body.get("source_recipe_id") if not name: raise HTTPException(400, "请输入配方名称") conn = get_db() c = conn.cursor() c.execute( - "INSERT INTO user_diary (user_id, source_recipe_id, name, ingredients, note) VALUES (?, ?, ?, ?, ?)", - (user["id"], source_id, name, json.dumps(ingredients, ensure_ascii=False), note) + "INSERT INTO user_diary (user_id, source_recipe_id, name, ingredients, note, tags) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], source_id, name, json.dumps(ingredients, ensure_ascii=False), note, json.dumps(tags, ensure_ascii=False)) ) conn.commit() did = c.lastrowid @@ -1457,19 +1542,35 @@ def list_recipe_reviews(user=Depends(require_role("admin"))): @app.get("/api/me/contribution") def my_contribution(user=Depends(get_current_user)): if not user.get("id"): - return {"adopted_count": 0, "shared_count": 0} + return {"adopted_count": 0, "shared_count": 0, "adopted_names": [], "pending_names": []} conn = get_db() - # adopted_count: recipes adopted from this user (owner changed to admin) - adopted = conn.execute( - "SELECT COUNT(*) FROM audit_log WHERE action = 'adopt_recipe' AND detail LIKE ?", - (f'%"from_user": "{user.get("display_name") or user.get("username")}"%',) - ).fetchone()[0] - # pending: recipes still owned by user in public library (not yet adopted) - pending = conn.execute( - "SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],) - ).fetchone()[0] + display = user.get("display_name") or user.get("username") + # adopted: unique recipe names adopted from this user + adopted_rows = conn.execute( + "SELECT DISTINCT target_name FROM audit_log WHERE action = 'adopt_recipe' AND detail LIKE ?", + (f'%"from_user": "{display}"%',) + ).fetchall() + adopted_names = list(set(r["target_name"] for r in adopted_rows if r["target_name"])) + # pending: recipes still owned by user in public library + pending_rows = conn.execute( + "SELECT name FROM recipes WHERE owner_id = ?", (user["id"],) + ).fetchall() + pending_names = [r["name"] for r in pending_rows] + # rejected: unique recipe names rejected (not already adopted or pending) + rejected_rows = conn.execute( + "SELECT DISTINCT target_name FROM audit_log WHERE action = 'reject_recipe' AND detail LIKE ?", + (f'%"from_user": "{display}"%',) + ).fetchall() + rejected_names = set(r["target_name"] for r in rejected_rows if r["target_name"]) + # Unique names across all: same recipe rejected then re-submitted counts as 1 + all_names = set(adopted_names) | set(pending_names) | rejected_names conn.close() - return {"adopted_count": adopted, "shared_count": adopted + pending} + return { + "adopted_count": len(adopted_names), + "shared_count": len(all_names), + "adopted_names": adopted_names, + "pending_names": pending_names, + } # ── Notifications ────────────────────────────────────── @@ -1478,15 +1579,19 @@ def get_notifications(user=Depends(get_current_user)): if not user["id"]: return [] conn = get_db() - # Only show notifications created after user registration - user_created = conn.execute("SELECT created_at FROM users WHERE id = ?", (user["id"],)).fetchone() - created_at = user_created["created_at"] if user_created else "2000-01-01" + # Only show notifications after user registration or last role change (whichever is later) + user_row = conn.execute("SELECT created_at, role_changed_at FROM users WHERE id = ?", (user["id"],)).fetchone() + cutoff = "2000-01-01" + if user_row: + cutoff = user_row["created_at"] or cutoff + if user_row["role_changed_at"] and user_row["role_changed_at"] > cutoff: + cutoff = user_row["role_changed_at"] rows = conn.execute( "SELECT id, title, body, is_read, created_at FROM notifications " "WHERE (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all'))) " "AND created_at >= ? " "ORDER BY is_read ASC, id DESC LIMIT 200", - (user["id"], user["role"], created_at) + (user["id"], user["role"], cutoff) ).fetchall() conn.close() return [dict(r) for r in rows] @@ -1507,6 +1612,50 @@ def mark_notification_read(nid: int, body: dict = None, user=Depends(get_current return {"ok": True} +@app.post("/api/notifications/{nid}/added") +def mark_notification_added(nid: int, user=Depends(get_current_user)): + """Mark a 'search missing' notification as handled: notify others and the original requester.""" + conn = get_db() + notif = conn.execute("SELECT title, body FROM notifications WHERE id = ?", (nid,)).fetchone() + if not notif: + conn.close() + raise HTTPException(404, "通知不存在") + # Mark this one as read + conn.execute("UPDATE notifications SET is_read = 1 WHERE id = ?", (nid,)) + who = user.get("display_name") or user.get("username") + title = notif["title"] or "" + # Extract query from title "🔍 用户需求:XXX" + query = title.replace("🔍 用户需求:", "").strip() if "用户需求" in title else title + # Mark all same-title notifications as read + conn.execute("UPDATE notifications SET is_read = 1 WHERE title = ? AND is_read = 0", (title,)) + # Notify other editors that it's been handled + for role in ("admin", "senior_editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "✅ 配方已添加", + f"{who} 已为「{query}」添加了配方,无需重复处理。") + ) + # Notify the original requester (search the body for who searched) + body_text = notif["body"] or "" + # body format: "XXX 搜索了「YYY」..." + if "搜索了" in body_text: + requester_name = body_text.split(" 搜索了")[0].strip() + # Find the user + requester = conn.execute( + "SELECT id, role FROM users WHERE display_name = ? OR username = ?", + (requester_name, requester_name) + ).fetchone() + if requester: + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (requester["role"], "🎉 你搜索的配方已添加", + f"你之前搜索的「{query}」已有编辑添加了配方,快去查看吧!", requester["id"]) + ) + conn.commit() + conn.close() + return {"ok": True} + + @app.post("/api/notifications/{nid}/unread") def mark_notification_unread(nid: int, user=Depends(get_current_user)): conn = get_db() diff --git a/frontend/cypress/e2e/recipe-detail.cy.js b/frontend/cypress/e2e/recipe-detail.cy.js index 3d87986..aff3fda 100644 --- a/frontend/cypress/e2e/recipe-detail.cy.js +++ b/frontend/cypress/e2e/recipe-detail.cy.js @@ -1,8 +1,7 @@ -// 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 + cy.get('.login-overlay').click('topLeft') } if ($body.find('.dialog-overlay').length) { cy.get('.dialog-btn-primary').click() @@ -60,51 +59,22 @@ describe('Recipe Detail', () => { }) }) -describe('Recipe Detail - Editor (Admin)', () => { - const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62' - +describe('Recipe Detail - Card View', () => { beforeEach(() => { - cy.visit('/', { - onBeforeLoad(win) { - win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) - } - }) + cy.visit('/') 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() 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() - 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 card with doTERRA branding', () => { + cy.get('.export-card').should('exist') + cy.contains('doTERRA').should('exist') }) - it('shows save image button', () => { - cy.get('.recipe-card').first().click() - dismissModals() - cy.get('.detail-overlay', { timeout: 5000 }).should('exist') - cy.contains('保存图片').should('exist') + it('shows language toggle', () => { + cy.contains('中文').should('exist') + cy.contains('English').should('exist') }) }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0516a38..1c26336 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "dependencies": { "exceljs": "^4.4.0", + "heic2any": "^0.0.4", "html2canvas": "^1.4.1", "pinia": "^2.3.1", "vue": "^3.5.32", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.5", @@ -1179,6 +1181,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1637,6 +1648,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1761,6 +1785,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2571,6 +2604,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2830,6 +2872,12 @@ "node": ">= 0.4" } }, + "node_modules/heic2any": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", + "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4684,6 +4732,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -5490,6 +5550,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5533,6 +5611,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 03f758a..e85f86c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,10 +15,12 @@ }, "dependencies": { "exceljs": "^4.4.0", + "heic2any": "^0.0.4", "html2canvas": "^1.4.1", "pinia": "^2.3.1", "vue": "^3.5.32", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.5", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3de03fe..c21ba42 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -82,9 +82,6 @@ const allTabs = [ { key: 'inventory', icon: '📦', label: '个人库存', require: 'login' }, { key: 'oils', icon: '💧', label: '精油价目' }, { key: 'projects', icon: '💼', label: '商业核算', require: 'login' }, - { key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' }, - { key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' }, - { key: 'users', icon: '👥', label: '用户管理', hide: 'admin' }, ] // 所有人都能看到大部分 tab,bug 和用户管理只有 admin 可见 diff --git a/frontend/src/components/RecipeCard.vue b/frontend/src/components/RecipeCard.vue index c38e193..b2184b0 100644 --- a/frontend/src/components/RecipeCard.vue +++ b/frontend/src/components/RecipeCard.vue @@ -1,8 +1,8 @@