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:
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>
|
||||
Reference in New Issue
Block a user