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>
This commit is contained in:
2026-04-08 22:23:55 +00:00
parent af365221f7
commit f3cd6727ca
3 changed files with 104 additions and 27 deletions

View File

@@ -36,8 +36,8 @@
>English</button>
</div>
<!-- Volume selector -->
<div class="card-volume-toggle">
<!-- Volume selector (hidden in preview, only in editor) -->
<div v-if="viewMode === 'editor'" class="card-volume-toggle">
<button
v-for="(drops, ml) in VOLUME_DROPS"
:key="ml"
@@ -583,8 +583,25 @@ async function saveImage() {
await generateCardImage()
}
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')
link.download = `${recipe.value.name || '配方'}_配方卡.png`
link.download = filename + '.png'
link.href = cardImageUrl.value
link.click()
ui.showToast('已保存图片')
@@ -615,18 +632,37 @@ function copyText() {
async function applyTranslation() {
showTranslationEditor.value = false
// Persist en_name to backend
let saved = 0
// 1. Save recipe English name to backend
if (recipe.value._id && customRecipeNameEn.value) {
try {
await api.put(`/api/recipes/${recipe.value._id}`, {
en_name: customRecipeNameEn.value,
version: recipe.value._version,
})
ui.showToast('翻译已保存')
} catch (e) {
ui.showToast('翻译保存失败')
}
recipe.value.en_name = customRecipeNameEn.value
saved++
} 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
nextTick(() => generateCardImage())
}
@@ -634,7 +670,7 @@ async function applyTranslation() {
// Override translation getters for card rendering
function getCardOilName(name) {
if (cardLang.value === 'en') {
return customOilNameEn.value[name] || oilEn(name) || name
return customOilNameEn.value[name] || oilsStore.oilsMeta[name]?.enName || oilEn(name) || 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)
}
// Save modal as image using html2canvas
// Save modal as image
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 {
const { default: html2canvas } = await import('html2canvas')
const overlay = document.querySelector('.modal-overlay')
if (!overlay) return
const cardEl = overlay.querySelector('[style*="border-radius: 20px"], [style*="border-radius:20px"]') || overlay.children[0]
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) {
const { captureAndSave } = await import('../composables/useSaveImage')
const ok = await captureAndSave(cardEl, name || '精油知识卡')
if (ok) ui.showToast('图片已保存')
} catch {
ui.showToast('保存失败')
}
}