Files
oil-formula-calculator/frontend/src/views/MyDiary.vue
YoYo dcf516f2de
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Failing after 1m19s
fix: 移除所有权限身份显示,QR上传布局还原为initial commit样式
2026-04-08 19:47:24 +00:00

937 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="my-diary">
<!-- Sub Tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷 Brand</button>
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 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="粘贴配方文本,智能识别...&#10;例如: 舒缓配方薰衣草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">
<h4>🏷 品牌设置</h4>
<div class="form-group">
<label>品牌名称</label>
<input v-model="brandName" class="form-input" placeholder="您的品牌名称" @blur="saveBrandSettings" />
</div>
<div class="form-group">
<label>二维码链接</label>
<input v-model="brandQrUrl" class="form-input" placeholder="https://..." @blur="saveBrandSettings" />
<div v-if="brandQrUrl" class="qr-preview">
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(brandQrUrl)" alt="QR" class="qr-img" />
</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')">
<img v-if="brandLogo" :src="brandLogo" class="upload-preview" />
<span v-else class="upload-hint">点击上传Logo</span>
</div>
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
</div>
<div class="form-group">
<label>卡片背景</label>
<div class="upload-area" @click="triggerUpload('bg')">
<img v-if="brandBg" :src="brandBg" class="upload-preview wide" />
<span v-else class="upload-hint">点击上传背景图</span>
</div>
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
</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 v-if="!auth.isBusiness" class="section-card">
<h4>💼 商业认证</h4>
<p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
</div>
<div v-else class="section-card">
<h4>💼 商业认证</h4>
<div class="verified-badge"> 已认证商业用户</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } 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 activeTab = ref('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 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 businessReason = ref('')
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
await loadBrandSettings()
returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null
})
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 || ''
}
} catch {
// no brand settings yet
}
}
async function saveBrandSettings() {
try {
await api('/api/brand', {
method: 'PUT',
body: JSON.stringify({
brand_name: brandName.value,
qr_url: brandQrUrl.value,
}),
})
} catch {
// silent
}
}
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)
})
}
async function handleUpload(type, event) {
const file = event.target.files[0]
if (!file) return
try {
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) {
if (type === 'logo') brandLogo.value = base64
else if (type === 'bg') brandBg.value = base64
else if (type === 'qr') brandQrImage.value = base64
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() {
try {
await api('/api/business-apply', {
method: 'POST',
body: JSON.stringify({ reason: businessReason.value }),
})
businessReason.value = ''
ui.showToast('申请已提交,请等待审核')
} 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;
}
.hint-text {
font-size: 13px;
color: #6b6375;
margin-bottom: 12px;
}
.verified-badge {
padding: 12px;
background: #e8f5e9;
border-radius: 10px;
color: #2e7d5a;
font-weight: 500;
text-align: center;
}
/* 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>