diff --git a/.gitignore b/.gitignore index 2502601..847cb22 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ backups/ # Frontend frontend/node_modules/ frontend/dist/ +frontend/.vite/ diff --git a/backend/main.py b/backend/main.py index 9786964..746bcc9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,6 +80,7 @@ class OilIn(BaseModel): drop_count: int retail_price: Optional[float] = None en_name: Optional[str] = None + is_active: Optional[int] = None class IngredientIn(BaseModel): @@ -659,10 +660,11 @@ def list_oils(): def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))): conn = get_db() conn.execute( - "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name) VALUES (?, ?, ?, ?, ?) " + "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name, is_active) VALUES (?, ?, ?, ?, ?, ?) " "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, " - "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name)", - (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name), + "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name), " + "is_active=COALESCE(excluded.is_active, oils.is_active)", + (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name, oil.is_active), ) log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4e69697..987e56d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,38 +2,23 @@
⚠️ 预览环境 · PR #{{ prId }} · 数据为生产副本,修改不影响正式环境
-
-
-
🌿
-
-

- doTERRA 配方计算器 - v2.2.0 - - - - - -

-

- 查询配方 - · - 计算成本 - · - 自制配方 - · - 导出卡片 - · - 精油知识 -

+
+
+
+
🌿
+
+

doTERRA 配方计算器

+

查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识

+
+
+
+ +
@@ -141,3 +126,66 @@ onMounted(async () => { }, 15000) }) + + diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index 02c8d27..d6ad844 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -36,8 +36,8 @@ >English
- -
+ +
- + +
@@ -113,47 +113,94 @@
-

🏷️ 品牌设置

+

分享配方卡片时,二维码、背景图、Logo 会自动展示在卡片上

-
- - -
+ +
+ +
+ +

卡片右上角展示

+
+ + 点击上传 +
+ + +
-
- - -
- QR + +
+ +

铺满整张卡片,半透明

+
+ + 点击上传 +
+ + +
+ + +
+ +

卡片左下角水印

+
+ + 点击上传 +
+ +
+
- -
- - 📲 点击上传二维码图片 + + +
+ + +
- -
上传后将显示在配方卡片右下角
-
- -
- - 点击上传Logo + +
+ +
+ +
+ + +
+ +
{{ brandName }}
+
+ +
+
doTERRA · 来自大地的礼物
+
配方名称
+
+
薰衣草 · 乳香 · 茶树
+ +
+ 配方总成本 + ¥12.50 +
+ +
+ + + 制作日期:{{ new Date().toLocaleDateString('zh-CN') }} +
+
-
-
- -
- - 点击上传背景图 -
- +
+ +
@@ -241,6 +288,7 @@ const brandQrUrl = ref('') const brandQrImage = ref('') const brandLogo = ref('') const brandBg = ref('') +const brandAlign = ref('center') const logoInput = ref(null) const bgInput = ref(null) const qrInput = ref(null) @@ -362,6 +410,7 @@ async function loadBrandSettings() { brandQrImage.value = data.qr_code || '' brandLogo.value = data.brand_logo || '' brandBg.value = data.brand_bg || '' + brandAlign.value = data.brand_align || 'center' } } catch { // no brand settings yet @@ -370,15 +419,16 @@ async function loadBrandSettings() { async function saveBrandSettings() { try { - await api('/api/brand', { + const res = await api('/api/brand', { method: 'PUT', body: JSON.stringify({ brand_name: brandName.value, - qr_url: brandQrUrl.value, + brand_align: brandAlign.value, }), }) + if (res.ok) ui.showToast('已保存') } catch { - // silent + ui.showToast('保存失败') } } @@ -397,14 +447,96 @@ function readFileAsBase64(file) { }) } +// Compress image if too large (keeps PNG for small images, JPEG for large) +function compressImage(base64, maxSize = 500000) { + return new Promise((resolve) => { + if (base64.length <= maxSize) { resolve(base64); return } + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + let w = img.width, h = img.height + const maxDim = 600 + if (w > maxDim || h > maxDim) { + const ratio = Math.min(maxDim / w, maxDim / h) + w = Math.round(w * ratio) + h = Math.round(h * ratio) + } + canvas.width = w + canvas.height = h + canvas.getContext('2d').drawImage(img, 0, 0, w, h) + // Try PNG first, then JPEG with decreasing quality + let result = canvas.toDataURL('image/png') + if (result.length > maxSize) { + let quality = 0.85 + while (quality > 0.2) { + result = canvas.toDataURL('image/jpeg', quality) + if (result.length <= maxSize) break + quality -= 0.1 + } + } + resolve(result) + } + img.onerror = () => resolve(base64) // fallback: return original + img.src = base64 + }) +} + +// Crop image to square from center +function cropToSquare(base64) { + return new Promise((resolve) => { + const img = new Image() + img.onload = () => { + const size = Math.min(img.width, img.height) + const x = (img.width - size) / 2 + const y = (img.height - size) / 2 + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + canvas.getContext('2d').drawImage(img, x, y, size, size, 0, 0, size, size) + resolve(canvas.toDataURL('image/png')) + } + img.onerror = () => resolve(base64) + img.src = base64 + }) +} + +// Check if image is roughly square +function checkSquare(base64) { + return new Promise((resolve) => { + const img = new Image() + img.onload = () => { + const ratio = img.width / img.height + resolve(ratio > 0.85 && ratio < 1.15) // within 15% of square + } + img.onerror = () => resolve(true) + img.src = base64 + }) +} + async function handleUpload(type, event) { const file = event.target.files[0] if (!file) return try { - const base64 = await readFileAsBase64(file) + let base64 = await readFileAsBase64(file) + + // QR: check if square, offer to crop + if (type === 'qr') { + const isSquare = await checkSquare(base64) + if (!isSquare) { + const { showConfirm: confirm } = await import('../composables/useDialog') + const ok = await confirm('二维码图片不是正方形,是否自动裁剪为正方形?\n(取中心区域)') + if (ok) { + base64 = await cropToSquare(base64) + } + } + } + + const maxSize = type === 'bg' ? 1000000 : 500000 + base64 = await compressImage(base64, maxSize) const fieldMap = { logo: 'brand_logo', bg: 'brand_bg', qr: 'qr_code' } const field = fieldMap[type] if (!field) return + ui.showToast('正在上传...') const res = await api('/api/brand', { method: 'PUT', body: JSON.stringify({ [field]: base64 }), @@ -413,10 +545,32 @@ async function handleUpload(type, event) { if (type === 'logo') brandLogo.value = base64 else if (type === 'bg') brandBg.value = base64 else if (type === 'qr') brandQrImage.value = base64 - ui.showToast('上传成功') + ui.showToast('上传成功 ✓') + } else { + const err = await res.json().catch(() => ({})) + ui.showToast('上传失败: ' + (err.detail || res.status)) } + } catch (e) { + ui.showToast('上传出错: ' + (e.message || '网络错误')) + } + // Reset input so same file can be re-selected + event.target.value = '' +} + +async function clearBrandImage(type) { + const fieldMap = { logo: 'brand_logo', bg: 'brand_bg', qr: 'qr_code' } + const field = fieldMap[type] + try { + await api('/api/brand', { + method: 'PUT', + body: JSON.stringify({ [field]: null }), + }) + if (type === 'logo') brandLogo.value = '' + else if (type === 'bg') brandBg.value = '' + else if (type === 'qr') brandQrImage.value = '' + ui.showToast('已清除') } catch { - ui.showToast('上传失败') + ui.showToast('清除失败') } } @@ -853,6 +1007,61 @@ async function applyBusiness() { color: #b0aab5; } +/* Upload box (matching initial commit style) */ +.upload-box { + width: 100px; + height: 100px; + border: 2px dashed var(--border, #e0d4c0); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + overflow: hidden; + background: white; + transition: border-color 0.15s; +} +.upload-box:hover { border-color: var(--sage, #7a9e7e); } +.upload-box-img { width: 100%; height: 100%; object-fit: contain; } +.upload-box-hint { font-size: 12px; color: var(--text-light, #9a8570); } +.btn-clear { + margin-top: 6px; + font-size: 11px; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; + color: var(--text-light); +} +.btn-clear:hover { border-color: #c0392b; color: #c0392b; } +.btn-align { + font-size: 11px; + padding: 3px 10px; + border: 1.5px solid var(--border); + border-radius: 6px; + background: white; + cursor: pointer; + color: var(--text-mid); +} +.btn-align.active { + background: var(--sage-mist); + border-color: var(--sage); + color: var(--sage-dark); +} + +/* Card preview mini */ +.card-preview-mini { + position: relative; + width: 280px; + background: linear-gradient(145deg, #faf7f0, #f5ede0); + border-radius: 14px; + border: 1px solid #e0ccaa; + overflow: hidden; + font-family: 'Noto Serif SC', serif; + padding: 18px; +} + .hint-text { font-size: 13px; color: #6b6375; diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index da6fa30..e29a476 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -2,21 +2,25 @@
-
-
💧
-
稀释比例
-
不同年龄段的稀释指南
+
+ 💧 +
+
稀释比例
+
不同年龄段的稀释指南
+
-
-
⚠️
-
使用禁忌
-
安全使用精油的注意事项
+
+ ⚠️ +
+
使用禁忌
+
安全使用精油的注意事项
+