Compare commits
3 Commits
ec017318be
...
feature/qr
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dbae8ea52 | |||
| f3cd6727ca | |||
| af365221f7 |
96
deploy/minio-backup-cronjob.yaml
Normal file
96
deploy/minio-backup-cronjob.yaml
Normal 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
|
||||||
@@ -6,14 +6,14 @@ describe('Recipe Detail', () => {
|
|||||||
|
|
||||||
it('opens detail panel when clicking a recipe card', () => {
|
it('opens detail panel when clicking a recipe card', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('.detail-overlay').should('be.visible')
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows recipe name in detail view', () => {
|
it('shows recipe name in detail view', () => {
|
||||||
cy.get('.recipe-card').first().invoke('text').then(cardText => {
|
cy.get('.recipe-card').first().invoke('text').then(cardText => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
cy.get('.detail-overlay').should('be.visible')
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ describe('Recipe Detail', () => {
|
|||||||
|
|
||||||
it('closes detail panel when clicking close button', () => {
|
it('closes detail panel when clicking close button', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('.detail-overlay').should('be.visible')
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
cy.get('button').contains(/✕|关闭/).first().click()
|
cy.get('button').contains(/✕|关闭/).first().click()
|
||||||
cy.get('.recipe-card').should('be.visible')
|
cy.get('.recipe-card').should('be.visible')
|
||||||
})
|
})
|
||||||
@@ -61,33 +61,21 @@ describe('Recipe Detail - Editor (Admin)', () => {
|
|||||||
|
|
||||||
it('shows editable ingredients table in editor tab', () => {
|
it('shows editable ingredients table in editor tab', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('.detail-overlay', { timeout: 5000 }).should('be.visible')
|
cy.wait(500)
|
||||||
cy.get('.detail-overlay').then($el => {
|
|
||||||
if ($el.find(':contains("编辑")').filter('button').length) {
|
|
||||||
cy.contains('编辑').click()
|
cy.contains('编辑').click()
|
||||||
cy.get('.editor-select, .editor-drops').should('exist')
|
cy.get('.editor-select, .editor-drops').should('exist')
|
||||||
} else {
|
|
||||||
cy.log('Edit button not available (not admin) — skipping')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows add ingredient button in editor tab', () => {
|
it('shows add ingredient button in editor tab', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('.detail-overlay', { timeout: 5000 }).should('be.visible')
|
cy.wait(500)
|
||||||
cy.get('.detail-overlay').then($el => {
|
|
||||||
if ($el.find(':contains("编辑")').filter('button').length) {
|
|
||||||
cy.contains('编辑').click()
|
cy.contains('编辑').click()
|
||||||
cy.contains('添加精油').should('exist')
|
cy.contains('添加精油').should('exist')
|
||||||
} else {
|
|
||||||
cy.log('Edit button not available (not admin) — skipping')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows save image button', () => {
|
it('shows export image button', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('.detail-overlay', { timeout: 5000 }).should('be.visible')
|
cy.wait(500)
|
||||||
cy.contains('保存图片').should('exist')
|
cy.contains('导出图片').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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-left">
|
||||||
<div class="header-icon">🌿</div>
|
<div class="header-icon">🌿</div>
|
||||||
<div class="header-title" style="text-align:left;flex:1">
|
<div class="header-title">
|
||||||
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
|
<h1>doTERRA 配方计算器</h1>
|
||||||
<span style="flex-shrink:0">doTERRA 配方计算器
|
<p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
|
||||||
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<span
|
<div class="header-right" @click="toggleUserMenu">
|
||||||
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"
|
|
||||||
@click="toggleUserMenu"
|
|
||||||
>
|
|
||||||
<template v-if="auth.isLoggedIn">
|
<template v-if="auth.isLoggedIn">
|
||||||
👤 {{ auth.user.display_name || auth.user.username }}
|
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||||||
▾
|
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
|
<span class="login-btn">登录</span>
|
||||||
</template>
|
</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>
|
||||||
@@ -54,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="main" @touchstart="onSwipeStart" @touchend="onSwipeEnd">
|
<div class="main">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,7 +54,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useOilsStore } from './stores/oils'
|
import { useOilsStore } from './stores/oils'
|
||||||
@@ -121,48 +106,6 @@ function toggleUserMenu() {
|
|||||||
showUserMenu.value = !showUserMenu.value
|
showUserMenu.value = !showUserMenu.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe to switch tabs
|
|
||||||
const swipeStartX = ref(0)
|
|
||||||
const swipeStartY = ref(0)
|
|
||||||
|
|
||||||
// Tab order for swipe navigation (only user-accessible tabs)
|
|
||||||
const tabOrder = computed(() => {
|
|
||||||
const tabs = ['search', 'oils']
|
|
||||||
if (auth.isLoggedIn) {
|
|
||||||
tabs.splice(1, 0, 'manage', 'inventory')
|
|
||||||
}
|
|
||||||
if (auth.isBusiness) tabs.push('projects')
|
|
||||||
return tabs
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSwipeStart(e) {
|
|
||||||
const touch = e.touches[0]
|
|
||||||
swipeStartX.value = touch.clientX
|
|
||||||
swipeStartY.value = touch.clientY
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSwipeEnd(e) {
|
|
||||||
const touch = e.changedTouches[0]
|
|
||||||
const dx = touch.clientX - swipeStartX.value
|
|
||||||
const dy = touch.clientY - swipeStartY.value
|
|
||||||
// Only trigger if horizontal swipe is dominant and > 50px
|
|
||||||
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return
|
|
||||||
// Check if the swipe originated inside a carousel (data-no-tab-swipe)
|
|
||||||
if (e.target.closest && e.target.closest('[data-no-tab-swipe]')) return
|
|
||||||
|
|
||||||
const tabs = tabOrder.value
|
|
||||||
const currentIdx = tabs.indexOf(ui.currentSection)
|
|
||||||
if (currentIdx < 0) return
|
|
||||||
|
|
||||||
if (dx < -50 && currentIdx < tabs.length - 1) {
|
|
||||||
// Swipe left -> next tab
|
|
||||||
goSection(tabs[currentIdx + 1])
|
|
||||||
} else if (dx > 50 && currentIdx > 0) {
|
|
||||||
// Swipe right -> previous tab
|
|
||||||
goSection(tabs[currentIdx - 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.initToken()
|
await auth.initToken()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -183,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>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -388,7 +388,6 @@ import { useDiaryStore } from '../stores/diary'
|
|||||||
import { api } from '../composables/useApi'
|
import { api } from '../composables/useApi'
|
||||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||||
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
|
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
|
||||||
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
|
|
||||||
// TagPicker replaced with inline tag editing
|
// TagPicker replaced with inline tag editing
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -584,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('已保存图片')
|
||||||
@@ -616,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())
|
||||||
}
|
}
|
||||||
@@ -635,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
|
||||||
}
|
}
|
||||||
@@ -715,7 +750,7 @@ const filteredOilsForAdd = computed(() => {
|
|||||||
if (!q) return oilsStore.oilNames
|
if (!q) return oilsStore.oilNames
|
||||||
return oilsStore.oilNames.filter(n => {
|
return oilsStore.oilNames.filter(n => {
|
||||||
const en = oilEn(n).toLowerCase()
|
const en = oilEn(n).toLowerCase()
|
||||||
return n.includes(q) || en.startsWith(q) || en.includes(q) || matchesPinyinInitials(n, q)
|
return n.includes(q) || en.startsWith(q) || en.includes(q)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,7 @@
|
|||||||
<div class="notif-list">
|
<div class="notif-list">
|
||||||
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
|
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
|
||||||
class="notif-item" :class="{ unread: !n.is_read }">
|
class="notif-item" :class="{ unread: !n.is_read }">
|
||||||
<div class="notif-item-header">
|
|
||||||
<div class="notif-title">{{ n.title }}</div>
|
<div class="notif-title">{{ n.title }}</div>
|
||||||
<button v-if="!n.is_read" class="notif-mark-one" @click="markOneRead(n)">已读</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
|
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
|
||||||
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
|
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,13 +105,6 @@ async function submitBug() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markOneRead(n) {
|
|
||||||
try {
|
|
||||||
await api(`/api/notifications/${n.id}/read`, { method: 'POST', body: '{}' })
|
|
||||||
n.is_read = 1
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
try {
|
try {
|
||||||
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
|
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
|
||||||
@@ -197,14 +187,7 @@ onMounted(loadNotifications)
|
|||||||
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
|
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
|
||||||
}
|
}
|
||||||
.notif-item.unread { background: #fafafa; }
|
.notif-item.unread { background: #fafafa; }
|
||||||
.notif-item-header { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
|
.notif-title { font-weight: 500; color: #333; }
|
||||||
.notif-title { font-weight: 500; color: #333; flex: 1; }
|
|
||||||
.notif-mark-one {
|
|
||||||
background: none; border: 1px solid #ccc; border-radius: 6px;
|
|
||||||
font-size: 11px; color: #7a9e7e; cursor: pointer; padding: 2px 8px;
|
|
||||||
font-family: inherit; white-space: nowrap; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.notif-mark-one:hover { background: #f0faf5; border-color: #7a9e7e; }
|
|
||||||
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
|
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
|
||||||
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
|
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
|
||||||
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
|
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Simple pinyin initial matching for Chinese oil names.
|
|
||||||
* Maps common Chinese characters used in essential oil names to their pinyin initials.
|
|
||||||
* This is a lightweight approach - no full pinyin library needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Common characters in essential oil / herb names mapped to pinyin initials
|
|
||||||
const PINYIN_MAP = {
|
|
||||||
'薰': 'x', '衣': 'y', '草': 'c', '茶': 'c', '树': 's',
|
|
||||||
'柠': 'n', '檬': 'm', '薄': 'b', '荷': 'h', '迷': 'm',
|
|
||||||
'迭': 'd', '香': 'x', '乳': 'r', '沉': 'c', '丝': 's',
|
|
||||||
'柏': 'b', '尤': 'y', '加': 'j', '利': 'l', '丁': 'd',
|
|
||||||
'肉': 'r', '桂': 'g', '罗': 'l', '勒': 'l', '百': 'b',
|
|
||||||
'里': 'l', '牛': 'n', '至': 'z', '马': 'm', '鞭': 'b',
|
|
||||||
'天': 't', '竺': 'z', '葵': 'k', '生': 's', '姜': 'j',
|
|
||||||
'黑': 'h', '胡': 'h', '椒': 'j', '玫': 'm', '瑰': 'g',
|
|
||||||
'茉': 'm', '莉': 'l', '依': 'y', '兰': 'l', '花': 'h',
|
|
||||||
'橙': 'c', '佛': 'f', '手': 's', '柑': 'g', '葡': 'p',
|
|
||||||
'萄': 't', '柚': 'y', '甜': 't', '苦': 'k', '野': 'y',
|
|
||||||
'山': 's', '松': 's', '杉': 's', '杜': 'd', '雪': 'x',
|
|
||||||
'莲': 'l', '芦': 'l', '荟': 'h', '白': 'b', '芷': 'z',
|
|
||||||
'当': 'd', '归': 'g', '川': 'c', '芎': 'x', '红': 'h',
|
|
||||||
'枣': 'z', '枸': 'g', '杞': 'q', '菊': 'j', '洋': 'y',
|
|
||||||
'甘': 'g', '菘': 's', '蓝': 'l', '永': 'y', '久': 'j',
|
|
||||||
'快': 'k', '乐': 'l', '鼠': 's', '尾': 'w', '岩': 'y',
|
|
||||||
'冷': 'l', '杰': 'j', '绿': 'lv', '芫': 'y', '荽': 's',
|
|
||||||
'椰': 'y', '子': 'z', '油': 'y', '基': 'j', '底': 'd',
|
|
||||||
'精': 'j', '纯': 'c', '露': 'l', '木': 'm', '果': 'g',
|
|
||||||
'叶': 'y', '根': 'g', '皮': 'p', '籽': 'z', '仁': 'r',
|
|
||||||
'大': 'd', '小': 'x', '西': 'x', '东': 'd', '南': 'n',
|
|
||||||
'北': 'b', '中': 'z', '新': 'x', '古': 'g', '老': 'l',
|
|
||||||
'春': 'c', '夏': 'x', '秋': 'q', '冬': 'd', '温': 'w',
|
|
||||||
'热': 'r', '凉': 'l', '冰': 'b', '火': 'h', '水': 's',
|
|
||||||
'金': 'j', '银': 'y', '铜': 't', '铁': 't', '玉': 'y',
|
|
||||||
'珍': 'z', '珠': 'z', '翠': 'c', '碧': 'b', '紫': 'z',
|
|
||||||
'青': 'q', '蓝': 'l', '绿': 'lv', '黄': 'h', '棕': 'z',
|
|
||||||
'褐': 'h', '灰': 'h', '粉': 'f', '豆': 'd', '蔻': 'k',
|
|
||||||
'藿': 'h', '苏': 's', '萃': 'c', '缬': 'x', '安': 'a',
|
|
||||||
'息': 'x', '宁': 'n', '静': 'j', '和': 'h', '平': 'p',
|
|
||||||
'舒': 's', '缓': 'h', '放': 'f', '松': 's', '活': 'h',
|
|
||||||
'力': 'l', '能': 'n', '量': 'l', '保': 'b', '护': 'h',
|
|
||||||
'防': 'f', '御': 'y', '健': 'j', '康': 'k', '美': 'm',
|
|
||||||
'丽': 'l', '清': 'q', '新': 'x', '自': 'z', '然': 'r',
|
|
||||||
'植': 'z', '物': 'w', '芳': 'f', '疗': 'l', '复': 'f',
|
|
||||||
'方': 'f', '单': 'd', '配': 'p', '调': 'd',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get pinyin initials string for a Chinese name.
|
|
||||||
* e.g. "薰衣草" -> "xyc"
|
|
||||||
*/
|
|
||||||
export function getPinyinInitials(name) {
|
|
||||||
let result = ''
|
|
||||||
for (const char of name) {
|
|
||||||
const initial = PINYIN_MAP[char]
|
|
||||||
if (initial) {
|
|
||||||
result += initial
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a query matches a name by pinyin initials.
|
|
||||||
* The query is matched as a prefix or substring of the pinyin initials.
|
|
||||||
*/
|
|
||||||
export function matchesPinyinInitials(name, query) {
|
|
||||||
if (!query || !name) return false
|
|
||||||
const initials = getPinyinInitials(name)
|
|
||||||
if (!initials) return false
|
|
||||||
const q = query.toLowerCase()
|
|
||||||
return initials.includes(q)
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,6 @@
|
|||||||
<span v-else class="upload-hint">📲 点击上传二维码图片</span>
|
<span v-else class="upload-hint">📲 点击上传二维码图片</span>
|
||||||
</div>
|
</div>
|
||||||
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
||||||
<button v-if="brandQrImage" class="btn-outline btn-sm btn-clear-img" @click="clearBrandImage('qr')">清除二维码</button>
|
|
||||||
<div class="field-hint">上传后将显示在配方卡片右下角</div>
|
<div class="field-hint">上传后将显示在配方卡片右下角</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +145,6 @@
|
|||||||
<span v-else class="upload-hint">点击上传Logo</span>
|
<span v-else class="upload-hint">点击上传Logo</span>
|
||||||
</div>
|
</div>
|
||||||
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
|
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
|
||||||
<button v-if="brandLogo" class="btn-outline btn-sm btn-clear-img" @click="clearBrandImage('logo')">清除Logo</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -156,7 +154,6 @@
|
|||||||
<span v-else class="upload-hint">点击上传背景图</span>
|
<span v-else class="upload-hint">点击上传背景图</span>
|
||||||
</div>
|
</div>
|
||||||
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
|
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
|
||||||
<button v-if="brandBg" class="btn-outline btn-sm btn-clear-img" @click="clearBrandImage('bg')">清除背景</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,26 +420,6 @@ async function handleUpload(type, event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearBrandImage(type) {
|
|
||||||
const fieldMap = { logo: 'brand_logo', bg: 'brand_bg', qr: 'qr_code' }
|
|
||||||
const field = fieldMap[type]
|
|
||||||
if (!field) return
|
|
||||||
try {
|
|
||||||
const res = await api('/api/brand', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ [field]: '' }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
if (type === 'logo') brandLogo.value = ''
|
|
||||||
else if (type === 'bg') brandBg.value = ''
|
|
||||||
else if (type === 'qr') brandQrImage.value = ''
|
|
||||||
ui.showToast('已清除')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
ui.showToast('清除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
async function updateDisplayName() {
|
async function updateDisplayName() {
|
||||||
try {
|
try {
|
||||||
@@ -876,12 +853,6 @@ async function applyBusiness() {
|
|||||||
color: #b0aab5;
|
color: #b0aab5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-clear-img {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: #d9534f;
|
|
||||||
border-color: #d9534f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
.hint-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #6b6375;
|
color: #6b6375;
|
||||||
|
|||||||
@@ -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) {
|
||||||
try {
|
|
||||||
const { default: html2canvas } = await import('html2canvas')
|
|
||||||
const overlay = document.querySelector('.modal-overlay')
|
const overlay = document.querySelector('.modal-overlay')
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
const cardEl = overlay.querySelector('[style*="border-radius: 20px"], [style*="border-radius:20px"]') || overlay.children[0]
|
const cardEl = overlay.querySelector('[style*="border-radius: 20px"], [style*="border-radius:20px"]') ||
|
||||||
|
overlay.querySelector('.oil-card-modal') || overlay.children[0]
|
||||||
if (!cardEl) return
|
if (!cardEl) return
|
||||||
// Hide close buttons during capture
|
try {
|
||||||
const btns = cardEl.querySelectorAll('button')
|
const { captureAndSave } = await import('../composables/useSaveImage')
|
||||||
btns.forEach(b => b.style.display = 'none')
|
const ok = await captureAndSave(cardEl, name || '精油知识卡')
|
||||||
const canvas = await html2canvas(cardEl, { scale: 2, backgroundColor: '#ffffff', useCORS: true })
|
if (ok) ui.showToast('图片已保存')
|
||||||
btns.forEach(b => b.style.display = '')
|
} catch {
|
||||||
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('保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="pending-info">
|
||||||
<span class="pending-name">{{ r.name }}</span>
|
<span class="pending-name">{{ r.name }}</span>
|
||||||
<span class="pending-owner">{{ r._owner_name }}</span>
|
<span class="pending-owner">来自 {{ r._owner_name }}</span>
|
||||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
<span class="pending-oils">{{ r.ingredients.map(i => i.oil).join('、') }}</span>
|
||||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
</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
|
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}, { immediate: true })
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recipe-search">
|
<div class="recipe-search">
|
||||||
<!-- Category Carousel (full-width image slides) -->
|
<!-- Category Carousel (full-width image slides) -->
|
||||||
<div class="cat-wrap" v-if="categories.length && !selectedCategory" data-no-tab-swipe @touchstart="onCarouselTouchStart" @touchend="onCarouselTouchEnd">
|
<div class="cat-wrap" v-if="categories.length && !selectedCategory">
|
||||||
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
|
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
|
||||||
<div
|
<div
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
@@ -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>
|
||||||
@@ -236,17 +237,9 @@ const myDiaryRecipes = computed(() => {
|
|||||||
|
|
||||||
const favoritesPreview = computed(() => {
|
const favoritesPreview = computed(() => {
|
||||||
if (!auth.isLoggedIn) return []
|
if (!auth.isLoggedIn) return []
|
||||||
let list = recipeStore.recipes.filter(r => recipeStore.isFavorite(r))
|
return recipeStore.recipes
|
||||||
if (searchQuery.value.trim()) {
|
.filter(r => recipeStore.isFavorite(r))
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
.slice(0, 6)
|
||||||
list = list.filter(r => {
|
|
||||||
const nameMatch = r.name.toLowerCase().includes(q)
|
|
||||||
const oilMatch = r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
|
|
||||||
const tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
|
|
||||||
return nameMatch || oilMatch || tagMatch
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return list.slice(0, 6)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function findGlobalIndex(recipe) {
|
function findGlobalIndex(recipe) {
|
||||||
@@ -290,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
|
||||||
}
|
}
|
||||||
@@ -298,18 +316,6 @@ function clearSearch() {
|
|||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedCategory.value = null
|
selectedCategory.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carousel swipe
|
|
||||||
const carouselTouchStartX = ref(0)
|
|
||||||
function onCarouselTouchStart(e) {
|
|
||||||
carouselTouchStartX.value = e.touches[0].clientX
|
|
||||||
}
|
|
||||||
function onCarouselTouchEnd(e) {
|
|
||||||
const dx = e.changedTouches[0].clientX - carouselTouchStartX.value
|
|
||||||
if (Math.abs(dx) > 50) {
|
|
||||||
slideCat(dx < 0 ? 1 : -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -572,6 +578,17 @@ function onCarouselTouchEnd(e) {
|
|||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user