Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 56s
- HEIC检测兼容MIME type和文件名 - heic2any返回数组时取第一个 - 转换失败时提示用户手动转JPG - 去掉保存品牌按钮,显示"所有修改自动保存" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1268 lines
36 KiB
Vue
1268 lines
36 KiB
Vue
<template>
|
||
<div class="my-diary">
|
||
<!-- Sub Tabs -->
|
||
<div class="sub-tabs">
|
||
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷 我的品牌</button>
|
||
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 我的账户</button>
|
||
</div>
|
||
|
||
<!-- Diary Tab -->
|
||
<div v-if="activeTab === 'diary'" class="tab-content">
|
||
<!-- Smart Paste -->
|
||
<div class="paste-section">
|
||
<textarea
|
||
v-model="pasteText"
|
||
class="paste-input"
|
||
placeholder="粘贴配方文本,智能识别... 例如: 舒缓配方,薰衣草3滴,茶树2滴"
|
||
rows="3"
|
||
></textarea>
|
||
<button class="btn-primary" @click="handleSmartPaste" :disabled="!pasteText.trim()">智能添加</button>
|
||
</div>
|
||
|
||
<!-- Diary Recipe Grid -->
|
||
<div class="diary-grid">
|
||
<div
|
||
v-for="d in diaryStore.userDiary"
|
||
:key="d._id || d.id"
|
||
class="diary-card"
|
||
:class="{ selected: selectedDiaryId === (d._id || d.id) }"
|
||
@click="selectDiary(d)"
|
||
>
|
||
<div class="diary-name">{{ d.name || '未命名' }}</div>
|
||
<div class="diary-ings">
|
||
<span v-for="ing in (d.ingredients || []).slice(0, 3)" :key="ing.oil" class="diary-ing">
|
||
{{ ing.oil }} {{ ing.drops }}滴
|
||
</span>
|
||
<span v-if="(d.ingredients || []).length > 3" class="diary-more">+{{ (d.ingredients || []).length - 3 }}</span>
|
||
</div>
|
||
<div class="diary-meta">
|
||
<span class="diary-cost" v-if="d.ingredients">{{ oils.fmtPrice(oils.calcCost(d.ingredients)) }}</span>
|
||
<span class="diary-entries">{{ (d.entries || []).length }} 条日志</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="diaryStore.userDiary.length === 0" class="empty-hint">暂无配方日记</div>
|
||
</div>
|
||
|
||
<!-- Diary Detail Panel -->
|
||
<div v-if="selectedDiary" class="diary-detail">
|
||
<div class="detail-header">
|
||
<input v-model="selectedDiary.name" class="detail-name-input" @blur="updateCurrentDiary" />
|
||
<button class="btn-icon" @click="deleteDiary(selectedDiary)" title="删除">🗑️</button>
|
||
</div>
|
||
|
||
<!-- Ingredients Editor -->
|
||
<div class="section-card">
|
||
<h4>成分</h4>
|
||
<div v-for="(ing, i) in selectedDiary.ingredients" :key="i" class="ing-row">
|
||
<select v-model="ing.oil" class="form-select" @change="updateCurrentDiary">
|
||
<option value="">选择精油</option>
|
||
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
|
||
</select>
|
||
<input
|
||
v-model.number="ing.drops"
|
||
type="number"
|
||
min="0"
|
||
class="form-input-sm"
|
||
@change="updateCurrentDiary"
|
||
/>
|
||
<button class="btn-icon-sm" @click="selectedDiary.ingredients.splice(i, 1); updateCurrentDiary()">✕</button>
|
||
</div>
|
||
<button class="btn-outline btn-sm" @click="selectedDiary.ingredients.push({ oil: '', drops: 1 })">+ 添加</button>
|
||
</div>
|
||
|
||
<!-- Notes -->
|
||
<div class="section-card">
|
||
<h4>备注</h4>
|
||
<textarea
|
||
v-model="selectedDiary.note"
|
||
class="form-textarea"
|
||
rows="2"
|
||
placeholder="配方备注..."
|
||
@blur="updateCurrentDiary"
|
||
></textarea>
|
||
</div>
|
||
|
||
<!-- Journal Entries -->
|
||
<div class="section-card">
|
||
<h4>使用日志</h4>
|
||
<div class="entry-list">
|
||
<div v-for="entry in (selectedDiary.entries || [])" :key="entry._id || entry.id" class="entry-item">
|
||
<div class="entry-date">{{ formatDate(entry.created_at) }}</div>
|
||
<div class="entry-content">{{ entry.content || entry.text }}</div>
|
||
<button class="btn-icon-sm" @click="removeEntry(entry)">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="entry-add">
|
||
<input
|
||
v-model="newEntryText"
|
||
class="form-input"
|
||
placeholder="记录使用感受..."
|
||
@keydown.enter="addNewEntry"
|
||
/>
|
||
<button class="btn-primary btn-sm" @click="addNewEntry" :disabled="!newEntryText.trim()">添加</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Brand Tab -->
|
||
<div v-if="activeTab === 'brand'" class="tab-content">
|
||
<!-- Back to recipe card (when navigated from a recipe) -->
|
||
<div v-if="returnRecipeId" class="return-banner">
|
||
<span>📋 上传完成后可返回配方卡片</span>
|
||
<button class="btn-return" @click="goBackToRecipe">← 返回配方卡片</button>
|
||
</div>
|
||
<div class="section-card">
|
||
<p style="font-size:13px;color:var(--text-light);margin-bottom:16px">分享配方卡片时,二维码、背景图、Logo 会自动展示在卡片上</p>
|
||
|
||
<!-- Three upload areas side by side -->
|
||
<div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:16px">
|
||
<!-- QR Code -->
|
||
<div>
|
||
<label class="form-label">📱 二维码</label>
|
||
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">卡片右上角展示</p>
|
||
<div class="upload-box" @click="triggerUpload('qr')">
|
||
<img v-if="brandQrImage" :src="brandQrImage" class="upload-box-img" />
|
||
<span v-else class="upload-box-hint">点击上传</span>
|
||
</div>
|
||
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
||
<button v-if="brandQrImage" class="btn-clear" @click="clearBrandImage('qr')">清除</button>
|
||
</div>
|
||
|
||
<!-- Background -->
|
||
<div>
|
||
<label class="form-label">🖼 背景图</label>
|
||
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">铺满整张卡片,半透明</p>
|
||
<div class="upload-box" @click="triggerUpload('bg')">
|
||
<img v-if="brandBg" :src="brandBg" class="upload-box-img" />
|
||
<span v-else class="upload-box-hint">点击上传</span>
|
||
</div>
|
||
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
|
||
<button v-if="brandBg" class="btn-clear" @click="clearBrandImage('bg')">清除</button>
|
||
</div>
|
||
|
||
<!-- Logo -->
|
||
<div>
|
||
<label class="form-label">🏷 Logo</label>
|
||
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">卡片左下角水印</p>
|
||
<div class="upload-box" @click="triggerUpload('logo')">
|
||
<img v-if="brandLogo" :src="brandLogo" class="upload-box-img" />
|
||
<span v-else class="upload-box-hint">点击上传</span>
|
||
</div>
|
||
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
|
||
<button v-if="brandLogo" class="btn-clear" @click="clearBrandImage('logo')">清除</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Brand name -->
|
||
<div class="form-group">
|
||
<label class="form-label">品牌名称或标语(显示在二维码下方)</label>
|
||
<textarea v-model="brandName" class="form-control" rows="2" placeholder="扫码申请成为优惠顾客 我的精油小屋" style="max-width:350px;font-size:13px" @blur="saveBrandSettings"></textarea>
|
||
<div style="display:flex;gap:6px;margin-top:6px">
|
||
<button class="btn-align" :class="{ active: brandAlign === 'left' }" @click="brandAlign='left'; saveBrandSettings()">靠左</button>
|
||
<button class="btn-align" :class="{ active: brandAlign === 'center' }" @click="brandAlign='center'; saveBrandSettings()">居中</button>
|
||
<button class="btn-align" :class="{ active: brandAlign === 'right' }" @click="brandAlign='right'; saveBrandSettings()">靠右</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Card Preview -->
|
||
<div style="margin-bottom:16px">
|
||
<label class="form-label">📋 配方卡片预览</label>
|
||
<div class="card-preview-mini">
|
||
<!-- Background overlay -->
|
||
<div v-if="brandBg" style="position:absolute;inset:0;background-size:cover;background-position:center;opacity:0.12;pointer-events:none" :style="{ backgroundImage: 'url(' + brandBg + ')' }"></div>
|
||
<!-- Logo: shown in bottom row, not as watermark -->
|
||
<!-- QR: top-right -->
|
||
<div v-if="brandQrImage" style="position:absolute;top:16px;right:12px;display:flex;flex-direction:column;align-items:center;gap:2px;z-index:2">
|
||
<img :src="brandQrImage" style="width:36px;height:36px;object-fit:cover;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.1)" />
|
||
<div v-if="brandName" :style="{ textAlign: brandAlign }" style="font-size:5px;color:var(--text-light);line-height:1.2;max-width:42px;white-space:pre-line">{{ brandName }}</div>
|
||
</div>
|
||
<!-- Content -->
|
||
<div style="position:relative;z-index:1">
|
||
<div style="font-size:7px;letter-spacing:1.5px;color:var(--sage);margin-bottom:3px">doTERRA · 来自大地的礼物</div>
|
||
<div style="font-size:13px;font-weight:700;color:var(--text-dark);margin-bottom:3px;line-height:1.3">配方名称</div>
|
||
<div style="width:30px;height:1px;background:linear-gradient(90deg,var(--sage),var(--gold));margin:6px 0"></div>
|
||
<div style="font-size:9px;color:var(--text-light);margin-bottom:6px">薰衣草 · 乳香 · 茶树</div>
|
||
<!-- Total cost bar -->
|
||
<div style="background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:6px;padding:6px 10px;display:flex;justify-content:space-between;align-items:center">
|
||
<span style="color:rgba(255,255,255,0.85);font-size:8px;letter-spacing:0.5px">配方总成本</span>
|
||
<span style="color:white;font-size:12px;font-weight:700">¥12.50</span>
|
||
</div>
|
||
<!-- Logo left + Date right -->
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-end;margin-top:8px">
|
||
<img v-if="brandLogo" :src="brandLogo" style="height:18px;object-fit:contain" />
|
||
<span v-else></span>
|
||
<span style="font-size:7px;color:var(--text-light);letter-spacing:0.5px">制作日期:{{ new Date().toLocaleDateString('zh-CN') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<span class="auto-save-hint">所有修改自动保存</span>
|
||
<button v-if="returnRecipeId" class="btn btn-outline" @click="goBackToRecipe">← 返回配方卡片</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Account Tab -->
|
||
<div v-if="activeTab === 'account'" class="tab-content">
|
||
<div class="section-card">
|
||
<h4>👤 账号设置</h4>
|
||
|
||
<div class="form-group">
|
||
<label>显示名称</label>
|
||
<input v-model="displayName" class="form-input" />
|
||
<button class="btn-primary btn-sm" style="margin-top:6px" @click="updateDisplayName">保存</button>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>用户名</label>
|
||
<div class="form-static">{{ auth.user.username }}</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="section-card">
|
||
<h4>🔑 修改密码</h4>
|
||
<div class="form-group">
|
||
<label>{{ auth.user.has_password ? '当前密码' : '(首次设置密码)' }}</label>
|
||
<input v-if="auth.user.has_password" v-model="oldPassword" type="password" class="form-input" placeholder="当前密码" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>新密码</label>
|
||
<input v-model="newPassword" type="password" class="form-input" placeholder="新密码" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>确认密码</label>
|
||
<input v-model="confirmPassword" type="password" class="form-input" placeholder="确认新密码" />
|
||
</div>
|
||
<button class="btn-primary" @click="changePassword">修改密码</button>
|
||
</div>
|
||
|
||
<!-- Business Verification -->
|
||
<div ref="bizCertRef" class="section-card biz-card">
|
||
<h4>🏢 商业用户认证</h4>
|
||
|
||
<!-- 已认证 -->
|
||
<div v-if="auth.isBusiness" class="biz-status-bar biz-approved">
|
||
<span>✅ 已认证商业用户</span>
|
||
</div>
|
||
|
||
<!-- 审核中 -->
|
||
<div v-else-if="bizApp.status === 'pending'" class="biz-status-bar biz-pending">
|
||
<span>⏳ 认证申请审核中</span>
|
||
<div class="biz-status-detail">商户名:{{ bizApp.business_name }} · 提交时间:{{ formatDate(bizApp.created_at) }}</div>
|
||
</div>
|
||
|
||
<!-- 被拒绝 -->
|
||
<template v-else-if="bizApp.status === 'rejected'">
|
||
<div class="biz-status-bar biz-rejected">
|
||
<span>❌ 认证申请未通过</span>
|
||
<div v-if="bizApp.reject_reason" class="biz-status-detail">原因:{{ bizApp.reject_reason }}</div>
|
||
</div>
|
||
<p class="hint-text">你可以修改信息后重新申请。</p>
|
||
</template>
|
||
|
||
<!-- 申请表单(首次或被拒后重新申请) -->
|
||
<template v-if="!auth.isBusiness && bizApp.status !== 'pending'">
|
||
<div class="biz-form">
|
||
<div class="form-group">
|
||
<label class="form-label">认证类型 *</label>
|
||
<select v-model="bizType" class="form-select">
|
||
<option value="">请选择</option>
|
||
<option value="individual">个体经营户</option>
|
||
<option value="company">公司</option>
|
||
<option value="studio">工作室/美容院</option>
|
||
<option value="distributor">代理商</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">企业/商户名称 *</label>
|
||
<input v-model="businessName" class="form-input" placeholder="你的企业或品牌名称" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">联系电话 *</label>
|
||
<input v-model="bizPhone" class="form-input" type="tel" placeholder="联系电话" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">业务描述</label>
|
||
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="描述你的业务范围和计划..."></textarea>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:12px">
|
||
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim() || !bizType">💾 提交申请</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, nextTick, onMounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useOilsStore } from '../stores/oils'
|
||
import { useDiaryStore } from '../stores/diary'
|
||
import { useUiStore } from '../stores/ui'
|
||
import { api } from '../composables/useApi'
|
||
import { showConfirm, showAlert } from '../composables/useDialog'
|
||
import { parseSingleBlock } from '../composables/useSmartPaste'
|
||
|
||
const auth = useAuthStore()
|
||
const oils = useOilsStore()
|
||
const diaryStore = useDiaryStore()
|
||
const ui = useUiStore()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const bizCertRef = ref(null)
|
||
|
||
const activeTab = ref(route.query.tab || 'brand')
|
||
const pasteText = ref('')
|
||
const selectedDiaryId = ref(null)
|
||
const returnRecipeId = ref(null)
|
||
const selectedDiary = ref(null)
|
||
const newEntryText = ref('')
|
||
|
||
// Brand settings
|
||
const brandName = ref('')
|
||
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)
|
||
|
||
// Account settings
|
||
const displayName = ref('')
|
||
const oldPassword = ref('')
|
||
const newPassword = ref('')
|
||
const confirmPassword = ref('')
|
||
const businessName = ref('')
|
||
const businessReason = ref('')
|
||
const bizType = ref('')
|
||
const bizPhone = ref('')
|
||
const bizApp = ref({ status: null })
|
||
|
||
onMounted(async () => {
|
||
await diaryStore.loadDiary()
|
||
displayName.value = auth.user.display_name || ''
|
||
await loadBrandSettings()
|
||
returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null
|
||
// Load business application status
|
||
try {
|
||
const bizRes = await api('/api/my-business-application')
|
||
if (bizRes.ok) bizApp.value = await bizRes.json()
|
||
} catch {}
|
||
// 从商业核算跳转过来,滚到商业认证区域
|
||
if (route.query.section === 'biz-cert') {
|
||
await nextTick()
|
||
bizCertRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
}
|
||
})
|
||
|
||
function goBackToRecipe() {
|
||
if (returnRecipeId.value) {
|
||
localStorage.removeItem('oil_return_recipe_id')
|
||
router.push('/?openRecipe=' + encodeURIComponent(returnRecipeId.value))
|
||
}
|
||
}
|
||
|
||
function selectDiary(d) {
|
||
const id = d._id || d.id
|
||
selectedDiaryId.value = id
|
||
selectedDiary.value = {
|
||
...d,
|
||
_id: id,
|
||
ingredients: (d.ingredients || []).map(i => ({ ...i })),
|
||
entries: d.entries || [],
|
||
note: d.note || '',
|
||
}
|
||
}
|
||
|
||
async function handleSmartPaste() {
|
||
const result = parseSingleBlock(pasteText.value, oils.oilNames)
|
||
try {
|
||
await diaryStore.createDiary({
|
||
name: result.name,
|
||
ingredients: result.ingredients,
|
||
note: '',
|
||
})
|
||
pasteText.value = ''
|
||
ui.showToast('已添加配方日记')
|
||
if (result.notFound.length > 0) {
|
||
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
|
||
}
|
||
} catch (e) {
|
||
ui.showToast('添加失败')
|
||
}
|
||
}
|
||
|
||
async function updateCurrentDiary() {
|
||
if (!selectedDiary.value) return
|
||
try {
|
||
await diaryStore.updateDiary(selectedDiary.value._id, {
|
||
name: selectedDiary.value.name,
|
||
ingredients: selectedDiary.value.ingredients.filter(i => i.oil),
|
||
note: selectedDiary.value.note,
|
||
})
|
||
} catch {
|
||
// silent
|
||
}
|
||
}
|
||
|
||
async function deleteDiary(d) {
|
||
const ok = await showConfirm(`确定删除 "${d.name}"?`)
|
||
if (!ok) return
|
||
await diaryStore.deleteDiary(d._id)
|
||
selectedDiary.value = null
|
||
selectedDiaryId.value = null
|
||
ui.showToast('已删除')
|
||
}
|
||
|
||
async function addNewEntry() {
|
||
if (!newEntryText.value.trim() || !selectedDiary.value) return
|
||
try {
|
||
await diaryStore.addEntry(selectedDiary.value._id, {
|
||
text: newEntryText.value.trim(),
|
||
})
|
||
newEntryText.value = ''
|
||
// Refresh diary to get new entries
|
||
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
|
||
if (updated) selectDiary(updated)
|
||
ui.showToast('已添加日志')
|
||
} catch {
|
||
ui.showToast('添加失败')
|
||
}
|
||
}
|
||
|
||
async function removeEntry(entry) {
|
||
const ok = await showConfirm('确定删除此日志?')
|
||
if (!ok) return
|
||
try {
|
||
await diaryStore.deleteEntry(entry._id || entry.id)
|
||
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
|
||
if (updated) selectDiary(updated)
|
||
} catch {
|
||
ui.showToast('删除失败')
|
||
}
|
||
}
|
||
|
||
function formatDate(d) {
|
||
if (!d) return ''
|
||
return new Date(d).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
// Brand settings
|
||
async function loadBrandSettings() {
|
||
try {
|
||
const res = await api('/api/brand')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
brandName.value = data.brand_name || ''
|
||
brandQrUrl.value = data.qr_url || ''
|
||
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
|
||
}
|
||
}
|
||
|
||
async function saveBrandSettings() {
|
||
try {
|
||
const res = await api('/api/brand', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
brand_name: brandName.value,
|
||
brand_align: brandAlign.value,
|
||
}),
|
||
})
|
||
if (res.ok) ui.showToast('已保存')
|
||
} catch {
|
||
ui.showToast('保存失败')
|
||
}
|
||
}
|
||
|
||
function triggerUpload(type) {
|
||
if (type === 'logo') logoInput.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)
|
||
})
|
||
}
|
||
|
||
// Compress image if too large
|
||
function compressImage(base64, maxSize = 500000, maxDim = 800) {
|
||
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
|
||
// Shrink progressively until it fits
|
||
let scale = 1
|
||
if (w > maxDim || h > maxDim) {
|
||
scale = Math.min(maxDim / w, maxDim / h)
|
||
}
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
const cw = Math.round(w * scale)
|
||
const ch = Math.round(h * scale)
|
||
canvas.width = cw
|
||
canvas.height = ch
|
||
canvas.getContext('2d').drawImage(img, 0, 0, cw, ch)
|
||
let quality = 0.8
|
||
let result = canvas.toDataURL('image/jpeg', quality)
|
||
while (result.length > maxSize && quality > 0.2) {
|
||
quality -= 0.15
|
||
result = canvas.toDataURL('image/jpeg', quality)
|
||
}
|
||
if (result.length <= maxSize) { resolve(result); return }
|
||
scale *= 0.7 // shrink more
|
||
}
|
||
// Last resort
|
||
resolve(canvas.toDataURL('image/jpeg', 0.3))
|
||
}
|
||
img.onerror = () => resolve(base64)
|
||
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) {
|
||
let file = event.target.files[0]
|
||
if (!file) return
|
||
try {
|
||
// Convert HEIC/HEIF to JPEG
|
||
const isHeic = file.name.toLowerCase().match(/\.hei[cf]$/) ||
|
||
file.type === 'image/heic' || file.type === 'image/heif'
|
||
if (isHeic) {
|
||
ui.showToast('正在转换HEIC格式...')
|
||
try {
|
||
const heic2any = (await import('heic2any')).default
|
||
let blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 })
|
||
if (Array.isArray(blob)) blob = blob[0]
|
||
file = new File([blob], 'photo.jpg', { type: 'image/jpeg' })
|
||
} catch (e) {
|
||
ui.showToast('HEIC转换失败,请手动转为JPG后上传')
|
||
return
|
||
}
|
||
}
|
||
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' ? 600000 : 300000
|
||
const maxDim = type === 'bg' ? 1000 : 600
|
||
base64 = await compressImage(base64, maxSize, maxDim)
|
||
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 }),
|
||
})
|
||
if (res.ok) {
|
||
if (type === 'logo') brandLogo.value = base64
|
||
else if (type === 'bg') brandBg.value = base64
|
||
else if (type === 'qr') brandQrImage.value = base64
|
||
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('清除失败')
|
||
}
|
||
}
|
||
|
||
// Account
|
||
async function updateDisplayName() {
|
||
try {
|
||
await api('/api/me', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ display_name: displayName.value }),
|
||
})
|
||
auth.user.display_name = displayName.value
|
||
ui.showToast('已更新')
|
||
} catch {
|
||
ui.showToast('更新失败')
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
if (newPassword.value !== confirmPassword.value) {
|
||
await showAlert('两次密码输入不一致')
|
||
return
|
||
}
|
||
if (newPassword.value.length < 4) {
|
||
await showAlert('密码至少4个字符')
|
||
return
|
||
}
|
||
try {
|
||
await api('/api/me/password', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
old_password: oldPassword.value,
|
||
new_password: newPassword.value,
|
||
}),
|
||
})
|
||
oldPassword.value = ''
|
||
newPassword.value = ''
|
||
confirmPassword.value = ''
|
||
ui.showToast('密码已修改')
|
||
await auth.loadMe()
|
||
} catch {
|
||
ui.showToast('修改失败')
|
||
}
|
||
}
|
||
|
||
async function applyBusiness() {
|
||
if (!businessName.value.trim() || !bizType.value) {
|
||
ui.showToast('请填写必填项')
|
||
return
|
||
}
|
||
const typeLabels = { individual: '个体经营户', company: '公司', studio: '工作室/美容院', distributor: '代理商' }
|
||
const info = [
|
||
`认证类型:${typeLabels[bizType.value] || bizType.value}`,
|
||
bizPhone.value ? `联系电话:${bizPhone.value}` : '',
|
||
businessReason.value ? `业务描述:${businessReason.value}` : '',
|
||
].filter(Boolean).join('\n')
|
||
try {
|
||
const res = await api('/api/business-apply', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
business_name: businessName.value.trim(),
|
||
document: info,
|
||
}),
|
||
})
|
||
if (res.ok) {
|
||
businessName.value = ''
|
||
businessReason.value = ''
|
||
bizType.value = ''
|
||
bizPhone.value = ''
|
||
ui.showToast('申请已提交,请等待管理员审核')
|
||
const bizRes = await api('/api/my-business-application')
|
||
if (bizRes.ok) bizApp.value = await bizRes.json()
|
||
} else {
|
||
const err = await res.json().catch(() => ({}))
|
||
ui.showToast(err.detail || '提交失败')
|
||
}
|
||
} catch {
|
||
ui.showToast('提交失败')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.my-diary {
|
||
padding: 0 12px 24px;
|
||
}
|
||
|
||
.sub-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 16px;
|
||
background: #f8f7f5;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.sub-tab {
|
||
flex: 1;
|
||
border: none;
|
||
background: transparent;
|
||
padding: 10px 8px;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
color: #6b6375;
|
||
transition: all 0.15s;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.sub-tab.active {
|
||
background: #fff;
|
||
color: #3e3a44;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.tab-content {
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(4px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.paste-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.paste-input {
|
||
width: 100%;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.paste-input:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
.diary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.diary-card {
|
||
padding: 12px 14px;
|
||
background: #fff;
|
||
border: 1.5px solid #e5e4e7;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.diary-card:hover {
|
||
border-color: #d4cfc7;
|
||
}
|
||
|
||
.diary-card.selected {
|
||
border-color: #7ec6a4;
|
||
background: #f0faf5;
|
||
}
|
||
|
||
.diary-name {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #3e3a44;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.diary-ings {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.diary-ing {
|
||
padding: 2px 8px;
|
||
background: #f0eeeb;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.diary-more {
|
||
font-size: 11px;
|
||
color: #b0aab5;
|
||
}
|
||
|
||
.diary-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.diary-cost {
|
||
color: #4a9d7e;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.diary-entries {
|
||
color: #b0aab5;
|
||
}
|
||
|
||
/* Detail panel */
|
||
.diary-detail {
|
||
margin-top: 16px;
|
||
padding: 16px;
|
||
background: #f8f7f5;
|
||
border-radius: 14px;
|
||
border: 1.5px solid #e5e4e7;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.detail-name-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
font-family: inherit;
|
||
outline: none;
|
||
}
|
||
|
||
.detail-name-input:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
.section-card {
|
||
margin-bottom: 14px;
|
||
padding: 12px;
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
border: 1.5px solid #e5e4e7;
|
||
}
|
||
|
||
.section-card h4 {
|
||
margin: 0 0 10px;
|
||
font-size: 13px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.ing-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.form-select {
|
||
flex: 1;
|
||
padding: 8px 10px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
background: #fff;
|
||
}
|
||
|
||
.form-input-sm {
|
||
width: 60px;
|
||
padding: 8px 8px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
text-align: center;
|
||
}
|
||
|
||
.form-textarea {
|
||
width: 100%;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.entry-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.entry-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
background: #f8f7f5;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.entry-date {
|
||
font-size: 11px;
|
||
color: #b0aab5;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.entry-content {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #3e3a44;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.entry-add {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.form-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
}
|
||
|
||
.form-input:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
/* Return banner */
|
||
.return-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: #f0faf5;
|
||
border: 1.5px solid #7ec6a4;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 14px;
|
||
font-size: 13px;
|
||
color: #3e7d5a;
|
||
gap: 10px;
|
||
}
|
||
|
||
.btn-return {
|
||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 6px 14px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-return:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* Brand */
|
||
.form-group {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #3e3a44;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.form-static {
|
||
padding: 8px 12px;
|
||
background: #f0eeeb;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.upload-area {
|
||
width: 100%;
|
||
min-height: 80px;
|
||
border: 2px dashed #d4cfc7;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.upload-area:hover {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
.qr-preview {
|
||
margin-top: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.qr-img {
|
||
width: 120px;
|
||
height: 120px;
|
||
border-radius: 8px;
|
||
border: 1.5px solid #e5e4e7;
|
||
}
|
||
|
||
.upload-preview {
|
||
max-width: 80px;
|
||
max-height: 80px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.upload-preview.wide {
|
||
max-width: 200px;
|
||
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;
|
||
}
|
||
|
||
/* 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;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.auto-save-hint { font-size: 12px; color: #999; font-style: italic; }
|
||
|
||
.verified-badge {
|
||
padding: 12px;
|
||
background: #e8f5e9;
|
||
border-radius: 10px;
|
||
color: #2e7d5a;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
}
|
||
|
||
.biz-card { border-radius: 16px; }
|
||
.biz-status-bar {
|
||
padding: 12px 16px;
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-bottom: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.biz-status-bar.biz-approved { background: #e8f5e9; color: #2e7d32; border-left: 3px solid #4caf50; }
|
||
.biz-status-bar.biz-pending { background: #fff3e0; color: #e65100; border-left: 3px solid #ff9800; }
|
||
.biz-status-bar.biz-rejected { background: #ffebee; color: #c62828; border-left: 3px solid #f44336; }
|
||
.biz-status-detail { font-size: 12px; margin-top: 4px; opacity: 0.8; }
|
||
.biz-form { margin-top: 8px; }
|
||
.biz-form .form-group { margin-bottom: 14px; }
|
||
.biz-form .form-label { display: block; font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; }
|
||
.biz-form .form-select {
|
||
width: 100%; padding: 10px 14px; border: 1.5px solid #d4cfc7; border-radius: 10px;
|
||
font-size: 14px; font-family: inherit; background: #fff; outline: none; box-sizing: border-box;
|
||
}
|
||
.biz-form .form-select:focus { border-color: #7ec6a4; }
|
||
|
||
/* Buttons */
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 10px;
|
||
padding: 9px 20px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
|
||
.btn-outline {
|
||
background: #fff;
|
||
color: #6b6375;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 10px;
|
||
padding: 9px 20px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 6px 14px;
|
||
font-size: 12px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.btn-icon {
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.btn-icon-sm {
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
padding: 2px;
|
||
color: #999;
|
||
}
|
||
|
||
.empty-hint {
|
||
grid-column: 1 / -1;
|
||
text-align: center;
|
||
color: #b0aab5;
|
||
font-size: 13px;
|
||
padding: 24px 0;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.diary-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|