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:
120
frontend/src/components/CustomDialog.vue
Normal file
120
frontend/src/components/CustomDialog.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div v-if="dialogState.visible" class="dialog-overlay">
|
||||
<div class="dialog-box">
|
||||
<div class="dialog-msg">{{ dialogState.message }}</div>
|
||||
<input
|
||||
v-if="dialogState.type === 'prompt'"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
style="width:100%;padding:10px 14px;border:1.5px solid #d4cfc7;border-radius:10px;font-size:14px;margin-bottom:16px;outline:none;font-family:inherit;box-sizing:border-box"
|
||||
@keydown.enter="submitPrompt"
|
||||
ref="promptInput"
|
||||
/>
|
||||
<div class="dialog-btn-row">
|
||||
<button v-if="dialogState.type !== 'alert'" class="dialog-btn-outline" @click="cancel">取消</button>
|
||||
<button class="dialog-btn-primary" @click="ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { dialogState, closeDialog } from '../composables/useDialog'
|
||||
|
||||
const inputValue = ref('')
|
||||
const promptInput = ref(null)
|
||||
|
||||
watch(() => dialogState.visible, (v) => {
|
||||
if (v && dialogState.type === 'prompt') {
|
||||
inputValue.value = dialogState.defaultValue || ''
|
||||
nextTick(() => {
|
||||
promptInput.value?.focus()
|
||||
promptInput.value?.select()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function ok() {
|
||||
if (dialogState.type === 'alert') closeDialog()
|
||||
else if (dialogState.type === 'confirm') closeDialog(true)
|
||||
else closeDialog(inputValue.value)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (dialogState.type === 'confirm') closeDialog(false)
|
||||
else closeDialog(null)
|
||||
}
|
||||
|
||||
function submitPrompt() {
|
||||
closeDialog(inputValue.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 20px;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-msg {
|
||||
font-size: 15px;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 18px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dialog-btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-btn-primary {
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 28px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dialog-btn-outline {
|
||||
background: #fff;
|
||||
color: #6b6375;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
padding: 9px 28px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-btn-outline:hover {
|
||||
background: #f8f7f5;
|
||||
}
|
||||
</style>
|
||||
196
frontend/src/components/LoginModal.vue
Normal file
196
frontend/src/components/LoginModal.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="login-overlay" @click.self="$emit('close')">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span
|
||||
class="login-tab"
|
||||
:class="{ active: mode === 'login' }"
|
||||
@click="mode = 'login'"
|
||||
>登录</span>
|
||||
<span
|
||||
class="login-tab"
|
||||
:class="{ active: mode === 'register' }"
|
||||
@click="mode = 'register'"
|
||||
>注册</span>
|
||||
</div>
|
||||
|
||||
<div class="login-body">
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
class="login-input"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
class="login-input"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
<input
|
||||
v-if="mode === 'register'"
|
||||
v-model="displayName"
|
||||
type="text"
|
||||
placeholder="显示名称(可选)"
|
||||
class="login-input"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
|
||||
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
|
||||
|
||||
<button class="login-submit" :disabled="loading" @click="submit">
|
||||
{{ loading ? '请稍候...' : (mode === 'login' ? '登录' : '注册') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const mode = ref('login')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const displayName = ref('')
|
||||
const errorMsg = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = ''
|
||||
|
||||
if (!username.value.trim()) {
|
||||
errorMsg.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (!password.value) {
|
||||
errorMsg.value = '请输入密码'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === 'login') {
|
||||
await auth.login(username.value.trim(), password.value)
|
||||
ui.showToast('登录成功')
|
||||
} else {
|
||||
await auth.register(
|
||||
username.value.trim(),
|
||||
password.value,
|
||||
displayName.value.trim() || username.value.trim()
|
||||
)
|
||||
ui.showToast('注册成功')
|
||||
}
|
||||
emit('close')
|
||||
// Reload page data after auth change
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
errorMsg.value = e?.message || (mode.value === 'login' ? '登录失败,请检查用户名和密码' : '注册失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 5000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.login-tab.active {
|
||||
color: #4a9d7e;
|
||||
border-bottom-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
padding: 24px 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.login-submit:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
144
frontend/src/components/RecipeCard.vue
Normal file
144
frontend/src/components/RecipeCard.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="recipe-card" @click="$emit('click', index)">
|
||||
<div class="card-name">{{ recipe.name }}</div>
|
||||
|
||||
<div v-if="recipe.tags && recipe.tags.length" class="card-tags">
|
||||
<span v-for="tag in recipe.tags" :key="tag" class="card-tag">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-oils">
|
||||
<span v-for="(ing, i) in recipe.ingredients" :key="i" class="card-oil">
|
||||
{{ ing.oil }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<span class="card-price">
|
||||
{{ priceInfo.cost }}
|
||||
<span v-if="priceInfo.hasRetail" class="card-retail">零售 {{ priceInfo.retail }}</span>
|
||||
</span>
|
||||
<button
|
||||
class="card-star"
|
||||
:class="{ favorited: isFav }"
|
||||
@click.stop="$emit('toggle-fav', recipe._id)"
|
||||
:title="isFav ? '取消收藏' : '收藏'"
|
||||
>
|
||||
{{ isFav ? '★' : '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
|
||||
const props = defineProps({
|
||||
recipe: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['click', 'toggle-fav'])
|
||||
|
||||
const oilsStore = useOilsStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
|
||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
|
||||
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recipe-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 16px 14px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background: #f0ece4;
|
||||
color: #8a7e6b;
|
||||
}
|
||||
|
||||
.card-oils {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-oil {
|
||||
font-size: 12px;
|
||||
color: #6b6375;
|
||||
background: #f8f7f5;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #4a9d7e;
|
||||
}
|
||||
|
||||
.card-retail {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.card-star {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #ccc;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.card-star.favorited {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.card-star:hover {
|
||||
color: #f5a623;
|
||||
}
|
||||
</style>
|
||||
636
frontend/src/components/RecipeDetailOverlay.vue
Normal file
636
frontend/src/components/RecipeDetailOverlay.vue
Normal file
@@ -0,0 +1,636 @@
|
||||
<template>
|
||||
<div class="detail-overlay" @click.self="$emit('close')">
|
||||
<div class="detail-panel">
|
||||
<!-- Mode toggle -->
|
||||
<div class="detail-mode-tabs">
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ active: viewMode === 'card' }"
|
||||
@click="viewMode = 'card'"
|
||||
>卡片预览</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ active: viewMode === 'editor' }"
|
||||
@click="viewMode = 'editor'"
|
||||
>编辑</button>
|
||||
<button class="detail-close-btn" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Card View -->
|
||||
<div v-if="viewMode === 'card'" class="detail-card-view">
|
||||
<div ref="cardRef" class="export-card">
|
||||
<div class="export-card-name">{{ recipe.name }}</div>
|
||||
<div v-if="recipe.tags && recipe.tags.length" class="export-card-tags">
|
||||
<span v-for="tag in recipe.tags" :key="tag" class="export-card-tag">{{ tag }}</span>
|
||||
</div>
|
||||
<table class="export-card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>精油</th>
|
||||
<th>滴数</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ing, i) in recipe.ingredients" :key="i">
|
||||
<td>{{ ing.oil }}</td>
|
||||
<td>{{ ing.drops }}</td>
|
||||
<td>{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" style="text-align:right;font-weight:600">总计</td>
|
||||
<td style="font-weight:600">{{ priceInfo.cost }}</td>
|
||||
</tr>
|
||||
<tr v-if="priceInfo.hasRetail">
|
||||
<td colspan="2" style="text-align:right;color:#999">零售价</td>
|
||||
<td style="color:#999">{{ priceInfo.retail }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div v-if="recipe.note" class="export-card-note">{{ recipe.note }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card-actions">
|
||||
<button class="action-btn" @click="exportImage">📤 导出图片</button>
|
||||
<button class="action-btn" @click="$emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor View -->
|
||||
<div v-if="viewMode === 'editor'" class="detail-editor-view">
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">配方名称</label>
|
||||
<input v-model="editName" type="text" class="editor-input" />
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">备注</label>
|
||||
<textarea v-model="editNote" class="editor-textarea" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">标签</label>
|
||||
<div class="editor-tags">
|
||||
<span v-for="tag in editTags" :key="tag" class="editor-tag">
|
||||
{{ tag }}
|
||||
<span class="tag-remove" @click="removeTag(tag)">×</span>
|
||||
</span>
|
||||
<button class="tag-add-btn" @click="showTagPicker = true">+ 标签</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">成分</label>
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>精油</th>
|
||||
<th>滴数</th>
|
||||
<th>成本</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ing, i) in editIngredients" :key="i">
|
||||
<td>
|
||||
<select v-model="ing.oil" class="editor-select">
|
||||
<option value="">选择精油</option>
|
||||
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model.number="ing.drops" type="number" min="1" class="editor-drops" />
|
||||
</td>
|
||||
<td class="ing-cost">
|
||||
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="remove-row-btn" @click="removeIngredient(i)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="add-row-btn" @click="addIngredient">+ 添加精油</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">容量</label>
|
||||
<div class="volume-controls">
|
||||
<button
|
||||
v-for="(drops, ml) in volumeOptions"
|
||||
:key="ml"
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === ml }"
|
||||
@click="selectedVolume = ml"
|
||||
>{{ ml }}ml</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-total">
|
||||
总计: {{ editPriceInfo.cost }}
|
||||
<span v-if="editPriceInfo.hasRetail" style="color:#999;font-size:13px;margin-left:8px">
|
||||
零售 {{ editPriceInfo.retail }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<button class="action-btn" @click="$emit('close')">取消</button>
|
||||
<button class="action-btn action-btn-primary" @click="saveRecipe">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Picker -->
|
||||
<TagPicker
|
||||
v-if="showTagPicker"
|
||||
:name="editName"
|
||||
:current-tags="editTags"
|
||||
:all-tags="recipesStore.allTags"
|
||||
@save="onTagsSaved"
|
||||
@close="showTagPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useOilsStore, VOLUME_DROPS } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import TagPicker from './TagPicker.vue'
|
||||
|
||||
const props = defineProps({
|
||||
recipeIndex: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const oilsStore = useOilsStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const viewMode = ref('card')
|
||||
const cardRef = ref(null)
|
||||
const showTagPicker = ref(false)
|
||||
const selectedVolume = ref('5')
|
||||
|
||||
const volumeOptions = VOLUME_DROPS
|
||||
|
||||
// Source recipe
|
||||
const recipe = computed(() => recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' })
|
||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(recipe.value.ingredients))
|
||||
|
||||
// Editable copies
|
||||
const editName = ref('')
|
||||
const editNote = ref('')
|
||||
const editTags = ref([])
|
||||
const editIngredients = ref([])
|
||||
|
||||
const editPriceInfo = computed(() => oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil)))
|
||||
|
||||
onMounted(() => {
|
||||
const r = recipe.value
|
||||
editName.value = r.name
|
||||
editNote.value = r.note || ''
|
||||
editTags.value = [...(r.tags || [])]
|
||||
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
})
|
||||
|
||||
function addIngredient() {
|
||||
editIngredients.value.push({ oil: '', drops: 1 })
|
||||
}
|
||||
|
||||
function removeIngredient(index) {
|
||||
editIngredients.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
editTags.value = editTags.value.filter(t => t !== tag)
|
||||
}
|
||||
|
||||
function onTagsSaved(tags) {
|
||||
editTags.value = tags
|
||||
showTagPicker.value = false
|
||||
}
|
||||
|
||||
async function saveRecipe() {
|
||||
const ingredients = editIngredients.value.filter(i => i.oil && i.drops > 0)
|
||||
if (!editName.value.trim()) {
|
||||
ui.showToast('请输入配方名称')
|
||||
return
|
||||
}
|
||||
if (ingredients.length === 0) {
|
||||
ui.showToast('请至少添加一种精油')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...recipe.value,
|
||||
name: editName.value.trim(),
|
||||
note: editNote.value.trim(),
|
||||
tags: editTags.value,
|
||||
ingredients,
|
||||
}
|
||||
await recipesStore.saveRecipe(payload)
|
||||
ui.showToast('保存成功')
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportImage() {
|
||||
if (!cardRef.value) return
|
||||
try {
|
||||
const canvas = await html2canvas(cardRef.value, {
|
||||
backgroundColor: '#fff',
|
||||
scale: 2,
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.download = `${recipe.value.name || '配方'}.png`
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
ui.showToast('已导出图片')
|
||||
} catch (e) {
|
||||
ui.showToast('导出失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 5500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-mode-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
color: #4a9d7e;
|
||||
border-bottom-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.detail-close-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detail-close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Card View */
|
||||
.detail-card-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.export-card {
|
||||
background: #fefdfb;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 12px;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.export-card-name {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.export-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.export-card-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f0ece4;
|
||||
color: #8a7e6b;
|
||||
}
|
||||
|
||||
.export-card-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.export-card-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.export-card-table td {
|
||||
padding: 7px 6px;
|
||||
color: #3e3a44;
|
||||
border-bottom: 1px solid #f5f5f3;
|
||||
}
|
||||
|
||||
.export-card-table tfoot td {
|
||||
border-bottom: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.export-card-note {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Editor View */
|
||||
.detail-editor-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-input:focus {
|
||||
border-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor-textarea:focus {
|
||||
border-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.editor-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-tag {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background: #4a9d7e;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-add-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px dashed #ccc;
|
||||
background: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tag-add-btn:hover {
|
||||
border-color: #4a9d7e;
|
||||
color: #4a9d7e;
|
||||
}
|
||||
|
||||
.editor-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.editor-table td {
|
||||
padding: 5px 4px;
|
||||
}
|
||||
|
||||
.editor-select {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.editor-drops {
|
||||
width: 60px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ing-cost {
|
||||
font-size: 12px;
|
||||
color: #4a9d7e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-row-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.remove-row-btn:hover {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
.add-row-btn {
|
||||
margin-top: 8px;
|
||||
background: none;
|
||||
border: 1.5px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-row-btn:hover {
|
||||
border-color: #4a9d7e;
|
||||
color: #4a9d7e;
|
||||
}
|
||||
|
||||
.volume-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.volume-btn.active {
|
||||
background: #4a9d7e;
|
||||
color: #fff;
|
||||
border-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.editor-total {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4a9d7e;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #eee;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Shared action buttons */
|
||||
.action-btn {
|
||||
padding: 9px 22px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #6b6375;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.action-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
}
|
||||
</style>
|
||||
206
frontend/src/components/TagPicker.vue
Normal file
206
frontend/src/components/TagPicker.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="tagpicker-overlay" @click.self="$emit('close')">
|
||||
<div class="tagpicker-card">
|
||||
<div class="tagpicker-title">为「{{ name }}」选择标签</div>
|
||||
|
||||
<div class="tagpicker-pills">
|
||||
<span
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
class="tagpicker-pill"
|
||||
:class="{ selected: selectedTags.has(tag) }"
|
||||
@click="toggleTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tagpicker-new">
|
||||
<input
|
||||
v-model="newTag"
|
||||
type="text"
|
||||
placeholder="添加新标签..."
|
||||
class="tagpicker-input"
|
||||
@keydown.enter="addNewTag"
|
||||
/>
|
||||
<button class="tagpicker-add-btn" @click="addNewTag" :disabled="!newTag.trim()">+</button>
|
||||
</div>
|
||||
|
||||
<div class="tagpicker-actions">
|
||||
<button class="tagpicker-btn-cancel" @click="$emit('close')">取消</button>
|
||||
<button class="tagpicker-btn-confirm" @click="save">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
currentTags: { type: Array, default: () => [] },
|
||||
allTags: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const selectedTags = reactive(new Set(props.currentTags))
|
||||
const newTag = ref('')
|
||||
|
||||
function toggleTag(tag) {
|
||||
if (selectedTags.has(tag)) {
|
||||
selectedTags.delete(tag)
|
||||
} else {
|
||||
selectedTags.add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
function addNewTag() {
|
||||
const tag = newTag.value.trim()
|
||||
if (!tag) return
|
||||
if (!selectedTags.has(tag)) {
|
||||
selectedTags.add(tag)
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', [...selectedTags])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tagpicker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 6000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tagpicker-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.tagpicker-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tagpicker-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tagpicker-pill {
|
||||
font-size: 13px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 14px;
|
||||
background: #f0ece4;
|
||||
color: #8a7e6b;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tagpicker-pill.selected {
|
||||
background: #4a9d7e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tagpicker-pill:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tagpicker-new {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tagpicker-input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tagpicker-input:focus {
|
||||
border-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.tagpicker-add-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
color: #4a9d7e;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tagpicker-add-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tagpicker-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tagpicker-btn-cancel {
|
||||
background: #fff;
|
||||
color: #6b6375;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
padding: 9px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tagpicker-btn-cancel:hover {
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
.tagpicker-btn-confirm {
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tagpicker-btn-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/UserMenu.vue
Normal file
156
frontend/src/components/UserMenu.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="usermenu-overlay" @click.self="$emit('close')">
|
||||
<div class="usermenu-card">
|
||||
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
|
||||
<div class="usermenu-role">
|
||||
<span class="role-badge">{{ roleLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="usermenu-actions">
|
||||
<button class="usermenu-btn" @click="goMyDiary">
|
||||
📖 我的
|
||||
</button>
|
||||
<button class="usermenu-btn" @click="goNotifications">
|
||||
🔔 通知
|
||||
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
|
||||
</button>
|
||||
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
|
||||
🚪 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
|
||||
const unreadCount = ref(0)
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
const map = {
|
||||
admin: '管理员',
|
||||
senior_editor: '高级编辑',
|
||||
editor: '编辑',
|
||||
viewer: '查看者',
|
||||
}
|
||||
return map[auth.user.role] || auth.user.role
|
||||
})
|
||||
|
||||
function goMyDiary() {
|
||||
emit('close')
|
||||
router.push('/mydiary')
|
||||
}
|
||||
|
||||
function goNotifications() {
|
||||
emit('close')
|
||||
router.push('/notifications')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
ui.showToast('已退出登录')
|
||||
emit('close')
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.usermenu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
}
|
||||
|
||||
.usermenu-card {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
right: 16px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 18px 20px 14px;
|
||||
min-width: 180px;
|
||||
z-index: 4001;
|
||||
}
|
||||
|
||||
.usermenu-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.usermenu-role {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||
color: #4a9d7e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usermenu-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.usermenu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #3e3a44;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.usermenu-btn:hover {
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
.usermenu-btn-logout {
|
||||
color: #d9534f;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 12px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: #d9534f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 9px;
|
||||
padding: 0 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user