- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
275 lines
8.4 KiB
Vue
275 lines
8.4 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { api } from '../api/client'
|
||
import BaseButton from '../components/ui/BaseButton.vue'
|
||
import BaseCard from '../components/ui/BaseCard.vue'
|
||
import BaseInput from '../components/ui/BaseInput.vue'
|
||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||
import EmptyState from '../components/ui/EmptyState.vue'
|
||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||
import MemoryList, { type MemoryItem } from '../components/MemoryList.vue'
|
||
import AddMemoryModal from '../components/AddMemoryModal.vue'
|
||
import { ExclamationCircleIcon, UserCircleIcon, SparklesIcon, PlusIcon } from '@heroicons/vue/24/outline'
|
||
|
||
interface ChildProfile {
|
||
id: string
|
||
name: string
|
||
avatar_url: string | null
|
||
birth_date: string | null
|
||
gender: string | null
|
||
age: number | null
|
||
interests: string[]
|
||
growth_themes: string[]
|
||
stories_count: number
|
||
total_reading_time: number
|
||
}
|
||
|
||
interface MemoryListResponse {
|
||
memories: MemoryItem[]
|
||
total: number
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const profile = ref<ChildProfile | null>(null)
|
||
const loading = ref(true)
|
||
const error = ref('')
|
||
|
||
// 记忆相关状态
|
||
const memories = ref<MemoryItem[]>([])
|
||
const loadingMemories = ref(true)
|
||
const showAddMemoryModal = ref(false)
|
||
|
||
const form = ref({
|
||
name: '',
|
||
birth_date: '',
|
||
gender: '',
|
||
interests: '',
|
||
growth_themes: '',
|
||
})
|
||
|
||
function parseTags(input: string) {
|
||
return input
|
||
.split(/[,,]/)
|
||
.map(tag => tag.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function fillForm(data: ChildProfile) {
|
||
form.value = {
|
||
name: data.name,
|
||
birth_date: data.birth_date || '',
|
||
gender: data.gender || '',
|
||
interests: data.interests.join('、'),
|
||
growth_themes: data.growth_themes.join('、'),
|
||
}
|
||
}
|
||
|
||
async function fetchProfile() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const data = await api.get<ChildProfile>(`/api/profiles/${route.params.id}`)
|
||
profile.value = data
|
||
fillForm(data)
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchMemories() {
|
||
loadingMemories.value = true
|
||
try {
|
||
const data = await api.get<MemoryListResponse>(`/api/profiles/${route.params.id}/memories`)
|
||
memories.value = data.memories
|
||
} catch (e) {
|
||
console.error('获取记忆失败:', e)
|
||
} finally {
|
||
loadingMemories.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDeleteMemory(id: string) {
|
||
try {
|
||
await api.delete(`/api/profiles/${route.params.id}/memories/${id}`)
|
||
memories.value = memories.value.filter(m => m.id !== id)
|
||
} catch (e) {
|
||
console.error('删除记忆失败:', e)
|
||
}
|
||
}
|
||
|
||
async function handleAddMemory(data: { type: string; value: Record<string, unknown> }) {
|
||
try {
|
||
// 使用合适的端点 based on type
|
||
let newMemory: MemoryItem
|
||
|
||
if (data.type === 'favorite_character') {
|
||
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories/character`, {
|
||
name: data.value.name,
|
||
description: data.value.description || null,
|
||
})
|
||
} else if (data.type === 'scary_element') {
|
||
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories/scary`, {
|
||
keyword: data.value.keyword,
|
||
category: data.value.category || 'other',
|
||
})
|
||
} else {
|
||
// 通用类型
|
||
newMemory = await api.post<MemoryItem>(`/api/profiles/${route.params.id}/memories`, {
|
||
type: data.type,
|
||
value: data.value,
|
||
})
|
||
}
|
||
|
||
memories.value.unshift(newMemory)
|
||
showAddMemoryModal.value = false
|
||
} catch (e) {
|
||
console.error('添加记忆失败:', e)
|
||
}
|
||
}
|
||
|
||
async function updateProfile() {
|
||
if (!form.value.name.trim()) {
|
||
error.value = '姓名不能为空'
|
||
return
|
||
}
|
||
|
||
error.value = ''
|
||
try {
|
||
const data = await api.put<ChildProfile>(`/api/profiles/${route.params.id}`, {
|
||
name: form.value.name.trim(),
|
||
birth_date: form.value.birth_date || undefined,
|
||
gender: form.value.gender || undefined,
|
||
interests: parseTags(form.value.interests),
|
||
growth_themes: parseTags(form.value.growth_themes),
|
||
})
|
||
profile.value = data
|
||
fillForm(data)
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '更新失败'
|
||
}
|
||
}
|
||
|
||
async function deleteProfile() {
|
||
if (!window.confirm('确定删除这个档案吗?')) return
|
||
|
||
try {
|
||
await api.delete(`/api/profiles/${route.params.id}`)
|
||
router.push('/profiles')
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '删除失败'
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchProfile()
|
||
fetchMemories()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="max-w-3xl mx-auto px-4">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h1 class="text-3xl font-bold gradient-text mb-2">档案详情</h1>
|
||
<p class="text-gray-500">查看并编辑孩子档案</p>
|
||
</div>
|
||
<div class="flex gap-4">
|
||
<BaseButton as="router-link" :to="`/profiles/${route.params.id}/timeline`" class="bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all">
|
||
✨ 查看成长足迹
|
||
</BaseButton>
|
||
<BaseButton as="router-link" to="/profiles" variant="ghost" class="text-purple-600">
|
||
返回列表
|
||
</BaseButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="py-10">
|
||
<LoadingSpinner text="加载中..." />
|
||
</div>
|
||
|
||
<div v-else-if="error" class="py-10">
|
||
<EmptyState
|
||
:icon="ExclamationCircleIcon"
|
||
title="加载失败"
|
||
:description="error"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else-if="profile" class="space-y-6">
|
||
<BaseCard>
|
||
<div class="flex items-center space-x-4">
|
||
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 text-white font-bold flex items-center justify-center">
|
||
{{ profile.name.charAt(0) }}
|
||
</div>
|
||
<div>
|
||
<div class="text-xl font-semibold text-gray-800">{{ profile.name }}</div>
|
||
<div class="text-sm text-gray-500">{{ profile.age ?? '未知' }} 岁 · {{ profile.gender ?? '未设置' }}</div>
|
||
</div>
|
||
</div>
|
||
</BaseCard>
|
||
|
||
<BaseCard>
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<UserCircleIcon class="h-5 w-5 text-purple-500" />
|
||
<h2 class="text-lg font-semibold text-gray-700">编辑信息</h2>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<BaseInput v-model="form.name" placeholder="孩子姓名" />
|
||
<BaseInput v-model="form.birth_date" type="date" />
|
||
<BaseSelect
|
||
v-model="form.gender"
|
||
:options="[
|
||
{ value: '', label: '性别(可选)' },
|
||
{ value: 'male', label: '男' },
|
||
{ value: 'female', label: '女' },
|
||
{ value: 'other', label: '其他' },
|
||
]"
|
||
/>
|
||
<BaseInput v-model="form.interests" placeholder="兴趣标签(逗号分隔)" />
|
||
<BaseInput v-model="form.growth_themes" placeholder="成长主题(逗号分隔)" class="md:col-span-2" />
|
||
</div>
|
||
<div class="mt-4 flex items-center justify-between">
|
||
<span v-if="error" class="text-sm text-red-500">{{ error }}</span>
|
||
<div class="flex gap-3">
|
||
<BaseButton @click="updateProfile">保存</BaseButton>
|
||
<BaseButton variant="secondary" class="text-red-500 border-red-200" @click="deleteProfile">
|
||
删除档案
|
||
</BaseButton>
|
||
</div>
|
||
</div>
|
||
</BaseCard>
|
||
|
||
<!-- 记忆管理区块 -->
|
||
<BaseCard>
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-2">
|
||
<SparklesIcon class="h-5 w-5 text-purple-500" />
|
||
<h2 class="text-lg font-semibold text-gray-700">记忆管理</h2>
|
||
</div>
|
||
<BaseButton size="sm" @click="showAddMemoryModal = true">
|
||
<PlusIcon class="h-4 w-4 mr-1" />
|
||
添加记忆
|
||
</BaseButton>
|
||
</div>
|
||
|
||
<MemoryList
|
||
:memories="memories"
|
||
:loading="loadingMemories"
|
||
@delete="handleDeleteMemory"
|
||
/>
|
||
</BaseCard>
|
||
</div>
|
||
|
||
<!-- 添加记忆模态框 -->
|
||
<AddMemoryModal
|
||
:show="showAddMemoryModal"
|
||
@close="showAddMemoryModal = false"
|
||
@submit="handleAddMemory"
|
||
/>
|
||
</div>
|
||
</template>
|