3 Commits

Author SHA1 Message Date
6dbae8ea52 feat: header重排、商业认证标识、共享配方、待审核、权限优化
Some checks failed
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
Test / e2e-test (push) Failing after 1m24s
Header:
- 登录按钮固定右侧,适配各屏幕
- 登录后不显示版本号,用户名在原位
- 商业认证用户名前显示🏢 emoji
- 响应式布局(手机端字号缩小)

配方共享:
- 个人配方卡片加📤共享按钮
- 共享到公共配方库,非管理员需管理员审核

管理配方:
- 待审核栏显示配方名、来源、精油列表
- 采纳调用 /api/recipes/{id}/adopt
- 拒绝=删除(带确认)
- 从recipes列表动态计算pending(不依赖不存在的API)

权限:
- senior_editor可编辑精油价目和公共配方(auth.canEdit已包含)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:30:41 +00:00
f3cd6727ca feat: 手机保存图片到相册、翻译持久化、预览优化
Some checks failed
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
Test / e2e-test (push) Failing after 1m24s
手机保存图片:
- 使用 navigator.share API(原生分享到相册)
- 桌面端保持下载方式
- 精油知识卡、稀释比例、使用禁忌、配方卡片统一处理

配方卡片预览:
- 预览模式隐藏容量切换按钮,编辑模式保留

英文翻译持久化:
- 修改翻译点击"应用"后,配方英文名保存到 recipes.en_name
- 精油英文名保存到 oils.en_name(通过 saveOil API)
- 精油价目页自动同步(共用 oilsMeta.enName)
- 所有用户打开都能看到修改后的翻译

新增 composables/useSaveImage.js: 统一保存图片工具

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:23:55 +00:00
af365221f7 feat: 每日自动备份数据库到 MinIO
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
Test / e2e-test (push) Failing after 1m24s
- CronJob: daily-minio-backup, 每天 UTC 3:00 执行
- 备份 SQLite DB 到 minio-api.oci.euphon.net/oil-backups/
- 文件名: oil_calculator_YYYYMMDD.db
- 滚动保留最近 30 份,自动删除旧备份
- 使用 Python minio SDK 上传

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:14:35 +00:00
7 changed files with 349 additions and 82 deletions

View File

@@ -0,0 +1,96 @@
apiVersion: v1
kind: Secret
metadata:
name: minio-backup-creds
namespace: oil-calculator
type: Opaque
stringData:
MINIO_ALIAS: "oci"
MINIO_URL: "https://minio-api.oci.euphon.net"
MINIO_ACCESS_KEY: "admin"
MINIO_SECRET_KEY: "HpYMIVH0WN79VkzF4L4z8Zx1"
MINIO_BUCKET: "oil-backups"
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-minio-backup
namespace: oil-calculator
spec:
schedule: "0 3 * * *" # Daily at 3:00 UTC
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 2
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: registry.oci.euphon.net/oil-calculator:latest
command:
- sh
- -c
- |
set -e
DATE=$(date +%Y%m%d)
export BACKUP_FILE="oil_calculator_${DATE}.db"
echo "=== Oil Calculator Daily Backup ==="
echo "Date: ${DATE}"
# 1. Copy SQLite database (app does WAL checkpoint every 5min)
cp /data/oil_calculator.db /tmp/${BACKUP_FILE}
SIZE=$(du -h /tmp/${BACKUP_FILE} | cut -f1)
echo "Backup created: ${BACKUP_FILE} (${SIZE})"
# 2. Upload to minio and cleanup using Python minio SDK
pip install -q minio 2>/dev/null
cat > /tmp/upload_backup.py << 'PYEOF'
import os
from minio import Minio
url = os.environ['MINIO_URL'].replace('https://','').replace('http://','')
client = Minio(url, access_key=os.environ['MINIO_ACCESS_KEY'], secret_key=os.environ['MINIO_SECRET_KEY'], secure='https' in os.environ['MINIO_URL'])
bucket = os.environ['MINIO_BUCKET']
bf = os.environ['BACKUP_FILE']
client.fput_object(bucket, bf, '/tmp/' + bf)
print('Uploaded:', bf)
objs = sorted(client.list_objects(bucket, prefix='oil_calculator_'), key=lambda o: o.object_name, reverse=True)
for o in objs[30:]:
client.remove_object(bucket, o.object_name)
print('Deleted:', o.object_name)
print('Total backups:', min(len(objs), 30))
PYEOF
python3 /tmp/upload_backup.py
echo "=== Done ==="
env:
- name: MINIO_URL
valueFrom:
secretKeyRef:
name: minio-backup-creds
key: MINIO_URL
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: minio-backup-creds
key: MINIO_ACCESS_KEY
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: minio-backup-creds
key: MINIO_SECRET_KEY
- name: MINIO_BUCKET
valueFrom:
secretKeyRef:
name: minio-backup-creds
key: MINIO_BUCKET
volumeMounts:
- name: data
mountPath: /data
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: oil-calculator-data
restartPolicy: OnFailure
imagePullSecrets:
- name: regcred

View File

@@ -2,38 +2,23 @@
<div v-if="isPreview" style="background:#e65100;color:white;text-align:center;padding:6px 16px;font-size:13px;font-weight:600;letter-spacing:0.5px;position:sticky;top:0;z-index:100"> <div v-if="isPreview" style="background:#e65100;color:white;text-align:center;padding:6px 16px;font-size:13px;font-weight:600;letter-spacing:0.5px;position:sticky;top:0;z-index:100">
预览环境 · PR #{{ prId }} · 数据为生产副本修改不影响正式环境 预览环境 · PR #{{ prId }} · 数据为生产副本修改不影响正式环境
</div> </div>
<div class="app-header" style="position:relative"> <div class="app-header">
<div class="header-inner" style="padding-right:80px"> <div class="header-inner">
<div class="header-icon">🌿</div> <div class="header-left">
<div class="header-title" style="text-align:left;flex:1"> <div class="header-icon">🌿</div>
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap"> <div class="header-title">
<span style="flex-shrink:0">doTERRA 配方计算器 <h1>doTERRA 配方计算器</h1>
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span> <p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
</span> </div>
<span </div>
style="cursor:pointer;color:white;font-size:13px;font-weight:500;flex-shrink:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif;letter-spacing:0.3px;opacity:0.95" <div class="header-right" @click="toggleUserMenu">
@click="toggleUserMenu" <template v-if="auth.isLoggedIn">
> <span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
<template v-if="auth.isLoggedIn"> <span class="user-name">{{ auth.user.display_name || auth.user.username }} </span>
👤 {{ auth.user.display_name || auth.user.username }} </template>
<template v-else>
</template> <span class="login-btn">登录</span>
<template v-else> </template>
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
</template>
</span>
</h1>
<p style="display:flex;flex-wrap:wrap;gap:4px 8px;margin:0">
<span style="white-space:nowrap">查询配方</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">计算成本</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">自制配方</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">导出卡片</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">精油知识</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -141,3 +126,73 @@ onMounted(async () => {
}, 15000) }, 15000)
}) })
</script> </script>
<style scoped>
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: relative;
z-index: 1;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.header-icon { font-size: 36px; flex-shrink: 0; }
.header-title { color: white; min-width: 0; }
.header-title h1 {
font-family: 'Noto Serif SC', serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-title p {
font-size: 12px;
opacity: 0.8;
margin-top: 3px;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
flex-shrink: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.user-name {
color: white;
font-size: 13px;
font-weight: 500;
opacity: 0.95;
white-space: nowrap;
}
.login-btn {
color: white;
background: rgba(255,255,255,0.2);
padding: 5px 14px;
border-radius: 12px;
font-size: 13px;
white-space: nowrap;
}
.biz-badge {
font-size: 14px;
}
@media (max-width: 480px) {
.header-icon { font-size: 28px; }
.header-title h1 { font-size: 18px; }
.header-title p { font-size: 10px; }
.user-name { font-size: 12px; }
}
</style>

View File

@@ -36,8 +36,8 @@
>English</button> >English</button>
</div> </div>
<!-- Volume selector --> <!-- Volume selector (hidden in preview, only in editor) -->
<div class="card-volume-toggle"> <div v-if="viewMode === 'editor'" class="card-volume-toggle">
<button <button
v-for="(drops, ml) in VOLUME_DROPS" v-for="(drops, ml) in VOLUME_DROPS"
:key="ml" :key="ml"
@@ -583,8 +583,25 @@ async function saveImage() {
await generateCardImage() await generateCardImage()
} }
if (!cardImageUrl.value) return if (!cardImageUrl.value) return
const filename = `${recipe.value.name || '配方'}_配方卡`
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
if (isMobile && navigator.canShare) {
try {
const res = await fetch(cardImageUrl.value)
const blob = await res.blob()
const file = new File([blob], filename + '.png', { type: 'image/png' })
if (navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] })
ui.showToast('已保存图片')
return
}
} catch {}
}
// Desktop fallback
const link = document.createElement('a') const link = document.createElement('a')
link.download = `${recipe.value.name || '配方'}_配方卡.png` link.download = filename + '.png'
link.href = cardImageUrl.value link.href = cardImageUrl.value
link.click() link.click()
ui.showToast('已保存图片') ui.showToast('已保存图片')
@@ -615,18 +632,37 @@ function copyText() {
async function applyTranslation() { async function applyTranslation() {
showTranslationEditor.value = false showTranslationEditor.value = false
// Persist en_name to backend let saved = 0
// 1. Save recipe English name to backend
if (recipe.value._id && customRecipeNameEn.value) { if (recipe.value._id && customRecipeNameEn.value) {
try { try {
await api.put(`/api/recipes/${recipe.value._id}`, { await api.put(`/api/recipes/${recipe.value._id}`, {
en_name: customRecipeNameEn.value, en_name: customRecipeNameEn.value,
version: recipe.value._version, version: recipe.value._version,
}) })
ui.showToast('翻译已保存') recipe.value.en_name = customRecipeNameEn.value
} catch (e) { saved++
ui.showToast('翻译保存失败') } catch {}
}
} }
// 2. Save each oil's English name to backend (updates oils table en_name)
for (const [oilName, enName] of Object.entries(customOilNameEn.value)) {
if (!enName || !enName.trim()) continue
const meta = oilsStore.oilsMeta[oilName]
if (!meta) continue
// Only save if changed from what's stored
if (meta.enName === enName.trim()) continue
try {
await oilsStore.saveOil(oilName, meta.bottlePrice, meta.dropCount, meta.retailPrice, enName.trim())
saved++
} catch {}
}
if (saved > 0) {
ui.showToast(`翻译已保存(${saved}项)`)
}
cardImageUrl.value = null cardImageUrl.value = null
nextTick(() => generateCardImage()) nextTick(() => generateCardImage())
} }
@@ -634,7 +670,7 @@ async function applyTranslation() {
// Override translation getters for card rendering // Override translation getters for card rendering
function getCardOilName(name) { function getCardOilName(name) {
if (cardLang.value === 'en') { if (cardLang.value === 'en') {
return customOilNameEn.value[name] || oilEn(name) || name return customOilNameEn.value[name] || oilsStore.oilsMeta[name]?.enName || oilEn(name) || name
} }
return name return name
} }

View File

@@ -0,0 +1,49 @@
/**
* Save a canvas/image — on mobile use native share (save to photos),
* on desktop trigger download.
*/
export async function saveCanvasImage(canvas, filename) {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
if (isMobile && navigator.canShare) {
// Mobile: use native share sheet → save to photos
try {
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'))
const file = new File([blob], filename + '.png', { type: 'image/png' })
if (navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] })
return true
}
} catch {}
}
// Desktop fallback: direct download
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = filename + '.png'
a.click()
return true
}
/**
* Capture an element as image and save it.
*/
export async function captureAndSave(element, filename) {
const { default: html2canvas } = await import('html2canvas')
// Hide buttons during capture
const buttons = element.querySelectorAll('button')
buttons.forEach(b => b.style.display = 'none')
try {
const canvas = await html2canvas(element, {
scale: 2,
backgroundColor: '#ffffff',
useCORS: true,
})
buttons.forEach(b => b.style.display = '')
return saveCanvasImage(canvas, filename)
} catch {
buttons.forEach(b => b.style.display = '')
return false
}
}

View File

@@ -725,26 +725,18 @@ function exportPDF() {
setTimeout(() => w.print(), 500) setTimeout(() => w.print(), 500)
} }
// Save modal as image using html2canvas // Save modal as image
async function saveModalImage(name) { async function saveModalImage(name) {
const overlay = document.querySelector('.modal-overlay')
if (!overlay) return
const cardEl = overlay.querySelector('[style*="border-radius: 20px"], [style*="border-radius:20px"]') ||
overlay.querySelector('.oil-card-modal') || overlay.children[0]
if (!cardEl) return
try { try {
const { default: html2canvas } = await import('html2canvas') const { captureAndSave } = await import('../composables/useSaveImage')
const overlay = document.querySelector('.modal-overlay') const ok = await captureAndSave(cardEl, name || '精油知识卡')
if (!overlay) return if (ok) ui.showToast('图片已保存')
const cardEl = overlay.querySelector('[style*="border-radius: 20px"], [style*="border-radius:20px"]') || overlay.children[0] } catch {
if (!cardEl) return
// Hide close buttons during capture
const btns = cardEl.querySelectorAll('button')
btns.forEach(b => b.style.display = 'none')
const canvas = await html2canvas(cardEl, { scale: 2, backgroundColor: '#ffffff', useCORS: true })
btns.forEach(b => b.style.display = '')
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = (name || '精油知识卡') + '.png'
a.click()
ui.showToast('图片已保存')
} catch (e) {
ui.showToast('保存失败') ui.showToast('保存失败')
} }
} }

View File

@@ -7,10 +7,15 @@
</div> </div>
<div v-if="showPending && pendingRecipes.length" class="pending-list"> <div v-if="showPending && pendingRecipes.length" class="pending-list">
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item"> <div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
<span class="pending-name">{{ r.name }}</span> <div class="pending-info">
<span class="pending-owner">{{ r._owner_name }}</span> <span class="pending-name">{{ r.name }}</span>
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button> <span class="pending-owner">来自 {{ r._owner_name }}</span>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button> <span class="pending-oils">{{ r.ingredients.map(i => i.oil).join('、') }}</span>
</div>
<div class="pending-actions">
<button class="btn-sm btn-approve" @click="approveRecipe(r)"> 采纳</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">🗑 删除</button>
</div>
</div> </div>
</div> </div>
@@ -196,7 +201,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, reactive, onMounted } from 'vue' import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils' import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
@@ -431,10 +436,8 @@ async function removeRecipe(recipe) {
async function approveRecipe(recipe) { async function approveRecipe(recipe) {
try { try {
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' }) await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id) ui.showToast('已采纳')
pendingCount.value--
ui.showToast('已通过')
await recipeStore.loadRecipes() await recipeStore.loadRecipes()
} catch { } catch {
ui.showToast('操作失败') ui.showToast('操作失败')
@@ -442,11 +445,12 @@ async function approveRecipe(recipe) {
} }
async function rejectRecipe(recipe) { async function rejectRecipe(recipe) {
const { showConfirm } = await import('../composables/useDialog')
const ok = await showConfirm(`确定删除「${recipe.name}」?`)
if (!ok) return
try { try {
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' }) await recipeStore.deleteRecipe(recipe._id)
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id) ui.showToast('已删除')
pendingCount.value--
ui.showToast('已拒绝')
} catch { } catch {
ui.showToast('操作失败') ui.showToast('操作失败')
} }
@@ -474,16 +478,14 @@ function onTagPickerSave(tags) {
showTagPicker.value = false showTagPicker.value = false
} }
// Load pending if admin // Compute pending: recipes created by non-admin users (need admin review)
if (auth.isAdmin) { watch(() => recipeStore.recipes, () => {
api('/api/recipes/pending').then(async res => { if (auth.isAdmin) {
if (res.ok) { const pending = recipeStore.recipes.filter(r => r._owner_id && r._owner_id !== auth.user.id)
const data = await res.json() pendingRecipes.value = pending
pendingRecipes.value = data pendingCount.value = pending.length
pendingCount.value = data.length }
} }, { immediate: true })
}).catch(() => {})
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -64,6 +64,7 @@
<div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div> <div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div>
<div class="card-bottom"> <div class="card-bottom">
<span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span> <span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
<button class="share-btn" @click.stop="shareDiaryToPublic(d)" title="共享到公共配方库">📤</button>
</div> </div>
</div> </div>
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div> <div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
@@ -282,6 +283,31 @@ async function handleToggleFav(recipe) {
await recipeStore.toggleFavorite(recipe._id) await recipeStore.toggleFavorite(recipe._id)
} }
async function shareDiaryToPublic(diary) {
const { showConfirm } = await import('../composables/useDialog')
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?\n共享后所有用户都能看到。`)
if (!ok) return
try {
await api('/api/recipes', {
method: 'POST',
body: JSON.stringify({
name: diary.name,
note: diary.note || '',
ingredients: (diary.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: diary.tags || [],
}),
})
if (auth.isAdmin) {
ui.showToast('已共享到公共配方库')
} else {
ui.showToast('已提交,等待管理员审核')
}
await recipeStore.loadRecipes()
} catch {
ui.showToast('共享失败')
}
}
function onSearch() { function onSearch() {
// fuzzyResults computed handles the filtering reactively // fuzzyResults computed handles the filtering reactively
} }
@@ -552,6 +578,17 @@ function clearSearch() {
font-weight: 600; font-weight: 600;
color: var(--sage-dark, #5a7d5e); color: var(--sage-dark, #5a7d5e);
} }
.share-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
border-radius: 6px;
opacity: 0.5;
transition: opacity 0.2s;
}
.share-btn:hover { opacity: 1; }
@media (max-width: 600px) { @media (max-width: 600px) {
.recipe-grid { .recipe-grid {