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>
|
>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
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
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('保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user