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:
2026-04-06 18:35:00 +00:00
parent 0368e85abe
commit ee8ec23dc7
62 changed files with 15035 additions and 8448 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>