Compare commits
4 Commits
feat/permi
...
7599599910
| Author | SHA1 | Date | |
|---|---|---|---|
| 7599599910 | |||
| 846058fa0f | |||
| 08c2d5bd75 | |||
| 7030dd107c |
@@ -119,7 +119,7 @@ async function submit() {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 5000;
|
||||
z-index: 6000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -36,6 +36,23 @@
|
||||
>English</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume selector -->
|
||||
<div class="volume-controls card-volume-controls">
|
||||
<button
|
||||
v-for="(drops, ml) in VOLUME_DROPS"
|
||||
:key="ml"
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedCardVolume === ml }"
|
||||
@click="onCardVolumeChange(ml)"
|
||||
>{{ ml === '单次' ? '单次' : ml + 'ml' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- QR / brand upload hint -->
|
||||
<div v-if="showBrandHint" class="brand-upload-hint">
|
||||
<span class="hint-icon">✨</span>
|
||||
<span>上传你的专属二维码,生成属于自己的配方卡片</span>
|
||||
</div>
|
||||
|
||||
<!-- Card image (rendered by html2canvas) -->
|
||||
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
|
||||
<!-- Brand overlay layers -->
|
||||
@@ -126,6 +143,11 @@
|
||||
class="action-btn"
|
||||
@click="showTranslationEditor = true"
|
||||
>✏️ 修改翻译</button>
|
||||
<button
|
||||
v-if="showBrandHint"
|
||||
class="action-btn action-btn-qr"
|
||||
@click="goUploadQr"
|
||||
>📲 上传我的二维码</button>
|
||||
</div>
|
||||
|
||||
<!-- Translation editor (inline) -->
|
||||
@@ -342,6 +364,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useOilsStore, DROPS_PER_ML, VOLUME_DROPS } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
@@ -363,12 +386,14 @@ const recipesStore = useRecipesStore()
|
||||
const authStore = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const diaryStore = useDiaryStore()
|
||||
const router = useRouter()
|
||||
|
||||
// ---- View state ----
|
||||
const viewMode = ref('card')
|
||||
const cardRef = ref(null)
|
||||
const cardImageUrl = ref(null)
|
||||
const cardLang = ref('zh')
|
||||
const selectedCardVolume = ref('单次')
|
||||
const showTranslationEditor = ref(false)
|
||||
const customRecipeNameEn = ref('')
|
||||
const customOilNameEn = ref({})
|
||||
@@ -453,6 +478,22 @@ async function loadBrand() {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether to show the brand/QR upload hint (show to all users who haven't set up brand assets)
|
||||
const showBrandHint = computed(() =>
|
||||
!!brand.value && !brand.value.qr_code && !brand.value.brand_bg
|
||||
)
|
||||
|
||||
function goUploadQr() {
|
||||
if (!authStore.isLoggedIn) {
|
||||
ui.openLogin()
|
||||
return
|
||||
}
|
||||
if (recipe.value._id) {
|
||||
localStorage.setItem('oil_return_recipe_id', recipe.value._id)
|
||||
}
|
||||
router.push('/mydiary')
|
||||
}
|
||||
|
||||
// ---- Card image generation ----
|
||||
async function generateCardImage() {
|
||||
if (!cardRef.value || generatingImage.value) return
|
||||
@@ -801,6 +842,7 @@ async function saveRecipe() {
|
||||
ingredients: ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
|
||||
}
|
||||
await recipesStore.saveRecipe(payload)
|
||||
// Reload recipes so the data is fresh when re-opened
|
||||
await recipesStore.loadRecipes()
|
||||
ui.showToast('保存成功')
|
||||
emit('close')
|
||||
@@ -1146,6 +1188,42 @@ async function saveRecipe() {
|
||||
background: var(--sage-mist, #eef4ee);
|
||||
}
|
||||
|
||||
/* Brand upload hint banner */
|
||||
.brand-upload-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #eef4ee, #f5f0e8);
|
||||
border: 1.5px dashed var(--sage-light, #c8ddc9);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--sage-dark, #5a7d5e);
|
||||
font-weight: 500;
|
||||
animation: hint-pop 0.3s ease;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes hint-pop {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.action-btn-qr {
|
||||
background: linear-gradient(135deg, #c8ddc9, var(--sage, #7a9e7e));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.action-btn-qr:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Card bottom actions */
|
||||
.card-bottom-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -123,6 +123,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>我的二维码图片</label>
|
||||
<div class="upload-area" @click="triggerUpload('qr')">
|
||||
<img v-if="brandQrImage" :src="brandQrImage" class="upload-preview qr-upload-preview" />
|
||||
<span v-else class="upload-hint">📲 点击上传二维码图片</span>
|
||||
</div>
|
||||
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
||||
<div class="field-hint">上传后将显示在配方卡片右下角</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>品牌Logo</label>
|
||||
<div class="upload-area" @click="triggerUpload('logo')">
|
||||
@@ -202,6 +212,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useDiaryStore } from '../stores/diary'
|
||||
@@ -214,6 +225,7 @@ const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const diaryStore = useDiaryStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('brand')
|
||||
const pasteText = ref('')
|
||||
@@ -224,10 +236,12 @@ const newEntryText = ref('')
|
||||
// Brand settings
|
||||
const brandName = ref('')
|
||||
const brandQrUrl = ref('')
|
||||
const brandQrImage = ref('')
|
||||
const brandLogo = ref('')
|
||||
const brandBg = ref('')
|
||||
const logoInput = ref(null)
|
||||
const bgInput = ref(null)
|
||||
const qrInput = ref(null)
|
||||
|
||||
// Account settings
|
||||
const displayName = ref('')
|
||||
@@ -345,8 +359,9 @@ async function loadBrandSettings() {
|
||||
const data = await res.json()
|
||||
brandName.value = data.brand_name || ''
|
||||
brandQrUrl.value = data.qr_url || ''
|
||||
brandLogo.value = data.logo_url || ''
|
||||
brandBg.value = data.bg_url || ''
|
||||
brandQrImage.value = data.qr_code || ''
|
||||
brandLogo.value = data.brand_logo || ''
|
||||
brandBg.value = data.brand_bg || ''
|
||||
}
|
||||
} catch {
|
||||
// no brand settings yet
|
||||
@@ -369,26 +384,46 @@ async function saveBrandSettings() {
|
||||
|
||||
function triggerUpload(type) {
|
||||
if (type === 'logo') logoInput.value?.click()
|
||||
else bgInput.value?.click()
|
||||
else if (type === 'bg') bgInput.value?.click()
|
||||
else if (type === 'qr') qrInput.value?.click()
|
||||
}
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = e => resolve(e.target.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUpload(type, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', type)
|
||||
try {
|
||||
const token = localStorage.getItem('oil_auth_token') || ''
|
||||
const res = await fetch('/api/brand-upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
body: formData,
|
||||
const base64 = await readFileAsBase64(file)
|
||||
const fieldMap = { logo: 'brand_logo', bg: 'brand_bg', qr: 'qr_code' }
|
||||
const field = fieldMap[type]
|
||||
if (!field) return
|
||||
const res = await api('/api/brand', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ [field]: base64 }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (type === 'logo') brandLogo.value = data.url
|
||||
else brandBg.value = data.url
|
||||
if (type === 'logo') brandLogo.value = base64
|
||||
else if (type === 'bg') brandBg.value = base64
|
||||
else if (type === 'qr') {
|
||||
brandQrImage.value = base64
|
||||
// If user came here from a recipe card, navigate back
|
||||
const returnRecipeId = localStorage.getItem('oil_return_recipe_id')
|
||||
if (returnRecipeId) {
|
||||
localStorage.removeItem('oil_return_recipe_id')
|
||||
ui.showToast('二维码已上传,返回配方卡片 ✨')
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
router.push('/?openRecipe=' + encodeURIComponent(returnRecipeId))
|
||||
return
|
||||
}
|
||||
}
|
||||
ui.showToast('上传成功')
|
||||
}
|
||||
} catch {
|
||||
@@ -783,6 +818,18 @@ async function applyBusiness() {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.qr-upload-preview {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #9b94a3;
|
||||
margin-top: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 13px;
|
||||
color: #b0aab5;
|
||||
|
||||
@@ -123,7 +123,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
@@ -136,6 +137,8 @@ const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref(null)
|
||||
@@ -154,6 +157,27 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// category modules are optional
|
||||
}
|
||||
|
||||
// Return to a recipe card after QR upload redirect
|
||||
const openRecipeId = route.query.openRecipe
|
||||
if (openRecipeId) {
|
||||
router.replace({ path: '/', query: {} })
|
||||
const tryOpen = () => {
|
||||
const idx = recipeStore.recipes.findIndex(r => r._id === openRecipeId)
|
||||
if (idx >= 0) {
|
||||
openDetail(idx)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!tryOpen()) {
|
||||
// Recipes might not be loaded yet, watch until available
|
||||
const stop = watch(
|
||||
() => recipeStore.recipes.length,
|
||||
() => { if (tryOpen()) stop() },
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function selectCategory(cat) {
|
||||
|
||||
Reference in New Issue
Block a user