Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
872
frontend/src/views/MyDiary.vue
Normal file
872
frontend/src/views/MyDiary.vue
Normal file
@@ -0,0 +1,872 @@
|
||||
<template>
|
||||
<div class="my-diary">
|
||||
<!-- Sub Tabs -->
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab" :class="{ active: activeTab === 'diary' }" @click="activeTab = 'diary'">📖 配方日记</button>
|
||||
<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="粘贴配方文本,智能识别... 例如: 舒缓配方,薰衣草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('diary')
|
||||
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-settings')
|
||||
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-settings', {
|
||||
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/display-name', {
|
||||
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>
|
||||
Reference in New Issue
Block a user