Files
oil-formula-calculator/frontend/src/views/MyDiary.vue
Hera Zhao f88521c9be
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 12s
Test / e2e-test (push) Failing after 6m15s
Fix CI: cd to frontend before cypress, use subshell for vite
2026-04-06 22:20:47 +00:00

872 lines
21 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">
<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>品牌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 class="form-group">
<label>角色</label>
<div class="form-static role-badge">{{ roleLabel }}</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, computed, onMounted, watch } from 'vue'
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 activeTab = ref('brand')
const pasteText = ref('')
const selectedDiaryId = ref(null)
const selectedDiary = ref(null)
const newEntryText = ref('')
// Brand settings
const brandName = ref('')
const brandQrUrl = ref('')
const brandLogo = ref('')
const brandBg = ref('')
const logoInput = ref(null)
const bgInput = ref(null)
// Account settings
const displayName = ref('')
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const businessReason = ref('')
const roleLabel = computed(() => {
const roles = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return roles[auth.user.role] || auth.user.role
})
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
await loadBrandSettings()
})
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 || ''
brandLogo.value = data.logo_url || ''
brandBg.value = data.bg_url || ''
}
} 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 bgInput.value?.click()
}
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,
})
if (res.ok) {
const data = await res.json()
if (type === 'logo') brandLogo.value = data.url
else brandBg.value = data.url
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;
}
/* 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;
}
.role-badge {
display: inline-block;
}
.qr-preview {
margin-top: 10px;
text-align: center;
}
.qr-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
}
.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;
}
.upload-preview {
max-width: 80px;
max-height: 80px;
object-fit: contain;
}
.upload-preview.wide {
max-width: 200px;
max-height: 100px;
}
.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>