feat: 手机保存图片到相册、翻译持久化、预览优化
手机保存图片: - 使用 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:
@@ -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
|
||||
}
|
||||
|
||||
49
frontend/src/composables/useSaveImage.js
Normal file
49
frontend/src/composables/useSaveImage.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user