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