3 Commits

Author SHA1 Message Date
42aefaab17 fix: 保存配方卡片所有英文翻译(含精油名称)
Some checks failed
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 3s
Test / e2e-test (push) Failing after 1m19s
PR Preview / test (pull_request) Has been skipped
PR Preview / teardown-preview (pull_request) Successful in 14s
PR Preview / deploy-preview (pull_request) Has been skipped
之前保存翻译时只保存了配方名的 en_name,精油的自定义英文名
(customOilNameEn)没有持久化,刷新后丢失。

- backend: recipes 表新增 en_oils(JSON 字符串)列
- RecipeUpdate 模型增加 en_oils 字段,update_recipe 写入 DB
- 所有 SELECT 语句补上 en_oils,_recipe_to_dict 返回该字段
- frontend: applyTranslation() 同时提交 en_oils
- onMounted 优先从 r.en_oils 还原精油译名,再 fallback 静态表
- recipes store 映射 en_oils 字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:57:52 +00:00
dcf516f2de fix: 移除所有权限身份显示,QR上传布局还原为initial commit样式
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Failing after 1m19s
2026-04-08 19:47:24 +00:00
c8de1ad229 fix: 退出登录后跳转到配方查询页面
将 handleLogout 里的 window.location.reload() 改为 router.push('/'),
确保在任何需要登录的页面退出后都能回到配方查询页面。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:47:24 +00:00
7 changed files with 42 additions and 86 deletions

View File

@@ -240,6 +240,8 @@ def init_db():
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
if "en_name" not in cols:
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
if "en_oils" not in cols:
c.execute("ALTER TABLE recipes ADD COLUMN en_oils TEXT DEFAULT '{}'")
# Seed admin user if no users exist
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]

View File

@@ -96,6 +96,7 @@ class RecipeIn(BaseModel):
class RecipeUpdate(BaseModel):
name: Optional[str] = None
en_name: Optional[str] = None
en_oils: Optional[str] = None
note: Optional[str] = None
ingredients: Optional[list[IngredientIn]] = None
tags: Optional[list[str]] = None
@@ -309,7 +310,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
conn = get_db()
# Search in recipe names
rows = conn.execute(
"SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id"
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id"
).fetchall()
exact = []
related = []
@@ -698,6 +699,7 @@ def _recipe_to_dict(conn, row):
"id": rid,
"name": row["name"],
"en_name": row["en_name"] if "en_name" in row.keys() else "",
"en_oils": row["en_oils"] if "en_oils" in row.keys() else "{}",
"note": row["note"],
"owner_id": row["owner_id"],
"owner_name": (owner["display_name"] or owner["username"]) if owner else None,
@@ -712,19 +714,19 @@ def list_recipes(user=Depends(get_current_user)):
conn = get_db()
# Admin sees all; others see admin-owned (adopted) + their own
if user["role"] == "admin":
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id").fetchall()
else:
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
admin_id = admin["id"] if admin else 1
user_id = user.get("id")
if user_id:
rows = conn.execute(
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
(admin_id, user_id)
).fetchall()
else:
rows = conn.execute(
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? ORDER BY id",
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE owner_id = ? ORDER BY id",
(admin_id,)
).fetchall()
result = [_recipe_to_dict(conn, r) for r in rows]
@@ -735,7 +737,7 @@ def list_recipes(user=Depends(get_current_user)):
@app.get("/api/recipes/{recipe_id}")
def get_recipe(recipe_id: int):
conn = get_db()
row = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
row = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
if not row:
conn.close()
raise HTTPException(404, "Recipe not found")
@@ -808,6 +810,8 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
if update.en_name is not None:
c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (update.en_name, recipe_id))
if update.en_oils is not None:
c.execute("UPDATE recipes SET en_oils = ? WHERE id = ?", (update.en_oils, recipe_id))
if update.ingredients is not None:
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
for ing in update.ingredients:
@@ -837,7 +841,7 @@ def delete_recipe(recipe_id: int, user=Depends(get_current_user)):
conn = get_db()
row = _check_recipe_permission(conn, recipe_id, user)
# Save full snapshot for undo
full = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
full = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
snapshot = _recipe_to_dict(conn, full)
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
json.dumps(snapshot, ensure_ascii=False))
@@ -1340,7 +1344,7 @@ def recipes_by_inventory(user=Depends(get_current_user)):
if not inv:
conn.close()
return []
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id").fetchall()
result = []
for r in rows:
recipe = _recipe_to_dict(conn, r)

View File

@@ -16,7 +16,6 @@
>
<template v-if="auth.isLoggedIn">
👤 {{ auth.user.display_name || auth.user.username }}
<span style="font-size:10px;background:rgba(255,255,255,0.2);padding:2px 7px;border-radius:8px;margin:0 3px;vertical-align:middle;font-weight:400;letter-spacing:0">{{ roleLabel }}</span>
</template>
<template v-else>
@@ -70,7 +69,7 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useOilsStore } from './stores/oils'
@@ -88,11 +87,6 @@ const router = useRouter()
const route = useRoute()
const showUserMenu = ref(false)
const roleLabel = computed(() => {
const map = { admin: '管理员', senior_editor: '高级编辑', editor: '编辑', viewer: '查看者' }
return map[auth.user.role] || '查看者'
})
// Sync ui.currentSection from route on load and navigation
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
watch(() => route.path, (path) => {

View File

@@ -576,11 +576,12 @@ function copyText() {
async function applyTranslation() {
showTranslationEditor.value = false
// Persist en_name to backend
if (recipe.value._id && customRecipeNameEn.value) {
// Persist en_name and en_oils to backend
if (recipe.value._id) {
try {
await api.put(`/api/recipes/${recipe.value._id}`, {
en_name: customRecipeNameEn.value,
en_oils: JSON.stringify(customOilNameEn.value),
version: recipe.value._version,
})
ui.showToast('翻译已保存')
@@ -708,9 +709,10 @@ onMounted(() => {
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
// Init translation defaults
customRecipeNameEn.value = r.en_name || recipeNameEn(r.name)
const savedOilMap = r.en_oils ? (() => { try { return JSON.parse(r.en_oils) } catch { return {} } })() : {}
const enMap = {}
;(r.ingredients || []).forEach(ing => {
enMap[ing.oil] = oilEn(ing.oil) || ing.oil
enMap[ing.oil] = savedOilMap[ing.oil] || oilEn(ing.oil) || ing.oil
})
customOilNameEn.value = enMap

View File

@@ -2,9 +2,6 @@
<div class="usermenu-overlay" @click.self="$emit('close')">
<div class="usermenu-card">
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
<div class="usermenu-role">
<span class="role-badge">{{ roleLabel }}</span>
</div>
<div class="usermenu-actions">
<button class="usermenu-btn" @click="goMyDiary">
@@ -71,16 +68,6 @@ const bugContent = ref('')
const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
const roleLabel = computed(() => {
const map = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return map[auth.user.role] || auth.user.role
})
function formatTime(d) {
if (!d) return ''
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
@@ -136,7 +123,7 @@ function handleLogout() {
auth.logout()
ui.showToast('已退出登录')
emit('close')
window.location.reload()
router.push('/')
}
onMounted(loadNotifications)
@@ -162,13 +149,7 @@ onMounted(loadNotifications)
z-index: 4001;
}
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 4px; }
.usermenu-role { margin-bottom: 14px; }
.role-badge {
display: inline-block; font-size: 11px; padding: 2px 10px;
border-radius: 8px; background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #4a9d7e; font-weight: 500;
}
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; }
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
.usermenu-btn {

View File

@@ -17,6 +17,7 @@ export const useRecipesStore = defineStore('recipes', () => {
_version: r._version ?? r.version ?? 1,
name: r.name,
en_name: r.en_name ?? '',
en_oils: r.en_oils ?? '{}',
note: r.note ?? '',
tags: r.tags ?? [],
ingredients: (r.ingredients ?? []).map((ing) => ({

View File

@@ -130,14 +130,12 @@
<div class="form-group">
<label>我的二维码图片</label>
<div class="qr-preview">
<div class="upload-area qr-upload-area" @click="triggerUpload('qr')">
<img v-if="brandQrImage" :src="brandQrImage" class="qr-img" />
<span v-else class="upload-hint">📲 点击上传</span>
</div>
<div class="upload-area" @click="triggerUpload('qr')">
<img v-if="brandQrImage" :src="brandQrImage" class="upload-preview qr-upload-preview" />
<span v-else class="upload-hint">📲 点击上传二维码图片</span>
</div>
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
<div class="field-hint" style="text-align:center">上传后将显示在配方卡片右下角</div>
<div class="field-hint">上传后将显示在配方卡片右下角</div>
</div>
<div class="form-group">
@@ -176,10 +174,6 @@
<div class="form-static">{{ auth.user.username }}</div>
</div>
<div class="form-group">
<label>角色</label>
<div class="role-badge-display">{{ roleLabel }}</div>
</div>
</div>
<div class="section-card">
@@ -218,7 +212,7 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
@@ -258,16 +252,6 @@ const newPassword = ref('')
const confirmPassword = ref('')
const businessReason = ref('')
const roleLabel = computed(() => {
const roles = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return roles[auth.user.role] || auth.user.role
})
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
@@ -812,28 +796,6 @@ async function applyBusiness() {
color: #6b6375;
}
.role-badge-display {
display: inline-block;
font-size: 12px;
padding: 3px 12px;
border-radius: 8px;
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #4a9d7e;
font-weight: 500;
}
.qr-preview {
margin-top: 10px;
text-align: center;
}
.qr-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
}
.upload-area {
width: 100%;
min-height: 80px;
@@ -851,6 +813,18 @@ async function applyBusiness() {
border-color: #7ec6a4;
}
.qr-preview {
margin-top: 10px;
text-align: center;
}
.qr-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
}
.upload-preview {
max-width: 80px;
max-height: 80px;
@@ -862,11 +836,9 @@ async function applyBusiness() {
max-height: 100px;
}
.qr-upload-area {
width: 120px;
height: 120px;
min-height: unset;
border-radius: 8px;
.qr-upload-preview {
max-width: 120px;
max-height: 120px;
}
.field-hint {