Initial commit: clean project structure
- 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
This commit is contained in:
221
frontend/src/components/AddMemoryModal.vue
Normal file
221
frontend/src/components/AddMemoryModal.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'submit', data: { type: string; value: Record<string, unknown> }): void
|
||||
}>()
|
||||
|
||||
const selectedType = ref('favorite_character')
|
||||
const loading = ref(false)
|
||||
|
||||
// 喜欢的角色表单
|
||||
const characterForm = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 回避元素表单
|
||||
const scaryForm = ref({
|
||||
keyword: '',
|
||||
category: 'other',
|
||||
})
|
||||
|
||||
// 阅读偏好表单
|
||||
const preferenceForm = ref({
|
||||
preference: '',
|
||||
category: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'favorite_character', label: '💕 喜欢的角色' },
|
||||
{ value: 'scary_element', label: '⚠️ 回避元素' },
|
||||
{ value: 'reading_preference', label: '📚 阅读偏好' },
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'creature', label: '生物' },
|
||||
{ value: 'scene', label: '场景' },
|
||||
{ value: 'action', label: '动作' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const isValid = computed(() => {
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
return characterForm.value.name.trim().length > 0
|
||||
case 'scary_element':
|
||||
return scaryForm.value.keyword.trim().length > 0
|
||||
case 'reading_preference':
|
||||
return preferenceForm.value.preference.trim().length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
function resetForms() {
|
||||
characterForm.value = { name: '', description: '' }
|
||||
scaryForm.value = { keyword: '', category: 'other' }
|
||||
preferenceForm.value = { preference: '', category: '' }
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForms()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid.value) return
|
||||
|
||||
let value: Record<string, unknown> = {}
|
||||
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
value = {
|
||||
name: characterForm.value.name.trim(),
|
||||
description: characterForm.value.description.trim(),
|
||||
}
|
||||
break
|
||||
case 'scary_element':
|
||||
value = {
|
||||
keyword: scaryForm.value.keyword.trim(),
|
||||
category: scaryForm.value.category,
|
||||
}
|
||||
break
|
||||
case 'reading_preference':
|
||||
value = {
|
||||
preference: preferenceForm.value.preference.trim(),
|
||||
category: preferenceForm.value.category.trim(),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
emit('submit', { type: selectedType.value, value })
|
||||
resetForms()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
@click="handleClose"
|
||||
></div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">添加记忆</h2>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="mb-4">
|
||||
<BaseSelect
|
||||
v-model="selectedType"
|
||||
label="记忆类型"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 喜欢的角色表单 -->
|
||||
<div v-if="selectedType === 'favorite_character'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="characterForm.name"
|
||||
label="角色名称"
|
||||
placeholder="例如:小兔子、勇敢的骑士"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="characterForm.description"
|
||||
label="角色描述(可选)"
|
||||
placeholder="简短描述这个角色的特点"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 回避元素表单 -->
|
||||
<div v-if="selectedType === 'scary_element'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="scaryForm.keyword"
|
||||
label="回避的元素"
|
||||
placeholder="例如:大灰狼、黑暗的森林"
|
||||
required
|
||||
/>
|
||||
<BaseSelect
|
||||
v-model="scaryForm.category"
|
||||
label="分类"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">
|
||||
添加后,生成故事时会自动避免出现这些元素
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 阅读偏好表单 -->
|
||||
<div v-if="selectedType === 'reading_preference'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="preferenceForm.preference"
|
||||
label="偏好内容"
|
||||
placeholder="例如:喜欢冒险故事、喜欢动物主题"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="preferenceForm.category"
|
||||
label="偏好类别(可选)"
|
||||
placeholder="例如:题材、风格、长度"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
|
||||
<BaseButton variant="secondary" @click="handleClose">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton :disabled="!isValid || loading" @click="handleSubmit">
|
||||
{{ loading ? '添加中...' : '添加' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from > div:last-child,
|
||||
.modal-leave-to > div:last-child {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
380
frontend/src/components/CreateStoryModal.vue
Normal file
380
frontend/src/components/CreateStoryModal.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useStorybookStore } from '../stores/storybook'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
import BaseTextarea from './ui/BaseTextarea.vue'
|
||||
import AnalysisAnimation from './ui/AnalysisAnimation.vue'
|
||||
import {
|
||||
SparklesIcon,
|
||||
PencilSquareIcon,
|
||||
BookOpenIcon,
|
||||
PhotoIcon,
|
||||
XMarkIcon,
|
||||
ExclamationCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
UserGroupIcon,
|
||||
ShareIcon,
|
||||
CheckBadgeIcon,
|
||||
ArrowPathIcon,
|
||||
HeartIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const storybookStore = useStorybookStore()
|
||||
|
||||
// State
|
||||
const inputType = ref<'keywords' | 'full_story'>('keywords')
|
||||
const outputMode = ref<'full_story' | 'storybook'>('full_story')
|
||||
const inputData = ref('')
|
||||
const educationTheme = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Data
|
||||
interface ChildProfile {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
interface StoryUniverse {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
const profiles = ref<ChildProfile[]>([])
|
||||
const universes = ref<StoryUniverse[]>([])
|
||||
const selectedProfileId = ref('')
|
||||
const selectedUniverseId = ref('')
|
||||
const profileError = ref('')
|
||||
|
||||
// Themes
|
||||
type ThemeOption = { icon: Component; label: string; value: string }
|
||||
const themes: ThemeOption[] = [
|
||||
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
|
||||
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
|
||||
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
|
||||
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
|
||||
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
|
||||
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
|
||||
]
|
||||
|
||||
const profileOptions = computed(() =>
|
||||
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
|
||||
)
|
||||
const universeOptions = computed(() =>
|
||||
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
|
||||
)
|
||||
|
||||
// Methods
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
profileError.value = ''
|
||||
try {
|
||||
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
|
||||
profiles.value = data.profiles
|
||||
if (!selectedProfileId.value && profiles.value.length > 0) {
|
||||
selectedProfileId.value = profiles.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
profileError.value = e instanceof Error ? e.message : '档案加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUniverses(profileId: string) {
|
||||
selectedUniverseId.value = ''
|
||||
if (!profileId) {
|
||||
universes.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
|
||||
universes.value = data.universes
|
||||
if (universes.value.length > 0) {
|
||||
selectedUniverseId.value = universes.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedProfileId, (newId) => {
|
||||
if (newId) fetchUniverses(newId)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchProfiles()
|
||||
}
|
||||
})
|
||||
|
||||
async function generateStory() {
|
||||
if (!inputData.value.trim()) {
|
||||
error.value = t('home.errorEmpty')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
type: inputType.value,
|
||||
data: inputData.value,
|
||||
education_theme: educationTheme.value || undefined,
|
||||
}
|
||||
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
|
||||
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
|
||||
|
||||
if (outputMode.value === 'storybook') {
|
||||
const response = await api.post<any>('/api/storybook/generate', {
|
||||
keywords: inputData.value,
|
||||
education_theme: educationTheme.value || undefined,
|
||||
generate_images: true,
|
||||
page_count: 6,
|
||||
child_profile_id: selectedProfileId.value || undefined,
|
||||
universe_id: selectedUniverseId.value || undefined
|
||||
})
|
||||
|
||||
storybookStore.setStorybook(response)
|
||||
close()
|
||||
router.push('/storybook/view')
|
||||
} else {
|
||||
const result = await api.post<any>('/api/stories/generate/full', payload)
|
||||
const query: Record<string, string> = {}
|
||||
if (result.errors && Object.keys(result.errors).length > 0) {
|
||||
if (result.errors.image) query.imageError = '1'
|
||||
}
|
||||
close()
|
||||
router.push({ path: `/story/${result.id}`, query })
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="!loading && close()"
|
||||
></div>
|
||||
|
||||
<!-- 全屏加载动画 -->
|
||||
<AnalysisAnimation v-if="loading" />
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="close"
|
||||
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
|
||||
>
|
||||
<XMarkIcon class="h-6 w-6 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">
|
||||
{{ t('home.createModalTitle') }}
|
||||
</h2>
|
||||
|
||||
<!-- 输入类型切换 -->
|
||||
<div class="flex space-x-3 mb-6">
|
||||
<button
|
||||
@click="inputType = 'keywords'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
inputType === 'keywords'
|
||||
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<SparklesIcon class="h-5 w-5" />
|
||||
<span>{{ t('home.inputTypeKeywords') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="inputType = 'full_story'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
inputType === 'full_story'
|
||||
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<PencilSquareIcon class="h-5 w-5" />
|
||||
<span>{{ t('home.inputTypeStory') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
|
||||
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
|
||||
<button
|
||||
@click="outputMode = 'full_story'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
outputMode === 'full_story'
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<BookOpenIcon class="h-5 w-5" />
|
||||
<span>普通故事</span>
|
||||
</button>
|
||||
<button
|
||||
@click="outputMode = 'storybook'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
outputMode === 'storybook'
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<PhotoIcon class="h-5 w-5" />
|
||||
<span>绘本模式</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 孩子档案选择 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ t('home.selectProfile') }}
|
||||
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BaseSelect
|
||||
v-model="selectedProfileId"
|
||||
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
|
||||
/>
|
||||
<BaseSelect
|
||||
v-model="selectedUniverseId"
|
||||
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
|
||||
:disabled="!selectedProfileId || universes.length === 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
|
||||
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
|
||||
{{ t('home.noUniverseHint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
|
||||
</label>
|
||||
<BaseTextarea
|
||||
v-model="inputData"
|
||||
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
|
||||
:rows="5"
|
||||
:maxLength="5000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 教育主题选择 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ t('home.themeLabel') }}
|
||||
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
|
||||
educationTheme === theme.value
|
||||
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<component :is="theme.icon" class="h-4 w-4" />
|
||||
<span>{{ theme.label }}</span>
|
||||
</button>
|
||||
<BaseInput
|
||||
v-model="educationTheme"
|
||||
:placeholder="t('home.themeCustom')"
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-300"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
|
||||
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="generateStory"
|
||||
>
|
||||
<template v-if="loading">
|
||||
{{ t('home.generating') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
{{ t('home.startCreate') }}
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 临时添加一些 btn-magic 样式确保兼容 */
|
||||
.btn-magic {
|
||||
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
|
||||
color: #0D0F1A;
|
||||
}
|
||||
</style>
|
||||
226
frontend/src/components/MemoryList.vue
Normal file
226
frontend/src/components/MemoryList.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
HeartIcon,
|
||||
BookOpenIcon,
|
||||
ExclamationTriangleIcon,
|
||||
AcademicCapIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
TrophyIcon,
|
||||
LightBulbIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string
|
||||
type: string
|
||||
value: Record<string, unknown>
|
||||
base_weight: number
|
||||
ttl_days: number | null
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memories: MemoryItem[]
|
||||
loading?: boolean
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showActions: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
// 记忆类型配置
|
||||
const typeConfig: Record<string, {
|
||||
label: string
|
||||
icon: typeof HeartIcon
|
||||
color: string
|
||||
bgColor: string
|
||||
}> = {
|
||||
recent_story: {
|
||||
label: '近期故事',
|
||||
icon: BookOpenIcon,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
favorite_character: {
|
||||
label: '喜欢的角色',
|
||||
icon: HeartIcon,
|
||||
color: 'text-pink-600',
|
||||
bgColor: 'bg-pink-50',
|
||||
},
|
||||
scary_element: {
|
||||
label: '回避元素',
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
},
|
||||
vocabulary_growth: {
|
||||
label: '词汇积累',
|
||||
icon: AcademicCapIcon,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
emotional_highlight: {
|
||||
label: '情感高光',
|
||||
icon: SparklesIcon,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
reading_preference: {
|
||||
label: '阅读偏好',
|
||||
icon: StarIcon,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
},
|
||||
milestone: {
|
||||
label: '里程碑',
|
||||
icon: TrophyIcon,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
skill_mastered: {
|
||||
label: '掌握的技能',
|
||||
icon: LightBulbIcon,
|
||||
color: 'text-teal-600',
|
||||
bgColor: 'bg-teal-50',
|
||||
},
|
||||
}
|
||||
|
||||
// 按类型分组记忆
|
||||
const groupedMemories = computed(() => {
|
||||
const groups: Record<string, MemoryItem[]> = {}
|
||||
|
||||
for (const memory of props.memories) {
|
||||
if (!groups[memory.type]) {
|
||||
groups[memory.type] = []
|
||||
}
|
||||
groups[memory.type].push(memory)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 获取记忆的显示文本
|
||||
function getMemoryDisplayText(memory: MemoryItem): string {
|
||||
const value = memory.value as Record<string, unknown>
|
||||
|
||||
switch (memory.type) {
|
||||
case 'recent_story':
|
||||
return value.title as string || '未知故事'
|
||||
case 'favorite_character':
|
||||
return `${value.name || '未知角色'}${value.description ? ` - ${value.description}` : ''}`
|
||||
case 'scary_element':
|
||||
return value.keyword as string || '未知元素'
|
||||
case 'vocabulary_growth':
|
||||
return value.word as string || '未知词汇'
|
||||
case 'emotional_highlight':
|
||||
return value.description as string || '情感记忆'
|
||||
case 'reading_preference':
|
||||
return value.preference as string || '阅读偏好'
|
||||
case 'milestone':
|
||||
return value.title as string || '里程碑'
|
||||
case 'skill_mastered':
|
||||
return value.skill as string || '技能'
|
||||
default:
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
if (window.confirm('确定要删除这条记忆吗?')) {
|
||||
emit('delete', id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="memories.length === 0" class="text-center py-8 text-gray-500">
|
||||
<SparklesIcon class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>还没有记忆记录</p>
|
||||
<p class="text-sm mt-1">阅读故事后会自动积累记忆</p>
|
||||
</div>
|
||||
|
||||
<!-- 记忆列表 -->
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(items, type) in groupedMemories" :key="type">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<component
|
||||
:is="typeConfig[type]?.icon || SparklesIcon"
|
||||
:class="['w-5 h-5', typeConfig[type]?.color || 'text-gray-500']"
|
||||
/>
|
||||
<h3 class="font-semibold text-gray-700">
|
||||
{{ typeConfig[type]?.label || type }}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{{ items.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="memory in items"
|
||||
:key="memory.id"
|
||||
:class="[
|
||||
'group relative p-4 rounded-xl border transition-all hover:shadow-md',
|
||||
typeConfig[memory.type]?.bgColor || 'bg-gray-50',
|
||||
'border-gray-100'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate">
|
||||
{{ getMemoryDisplayText(memory) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ formatDate(memory.created_at) }}
|
||||
<span v-if="memory.ttl_days" class="ml-2">
|
||||
· 有效期 {{ memory.ttl_days }}天
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
v-if="showActions"
|
||||
@click="handleDelete(memory.id)"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500"
|
||||
title="删除"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 权重指示器 -->
|
||||
<div
|
||||
v-if="memory.base_weight > 1"
|
||||
class="absolute top-2 right-2 w-2 h-2 rounded-full bg-yellow-400"
|
||||
title="高权重记忆"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
212
frontend/src/components/NavBar.vue
Normal file
212
frontend/src/components/NavBar.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLocale } from '../i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import {
|
||||
ArrowRightOnRectangleIcon,
|
||||
BookOpenIcon,
|
||||
GlobeAltIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
SunIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const showUserMenu = ref(false)
|
||||
const isDark = ref(false)
|
||||
|
||||
function switchLocale(lang: 'en' | 'zh') {
|
||||
setLocale(lang)
|
||||
}
|
||||
|
||||
function applyTheme(value: boolean) {
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
applyTheme(isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
isDark.value = savedTheme === 'dark'
|
||||
} else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
||||
isDark.value = true
|
||||
}
|
||||
applyTheme(isDark.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-18 py-3">
|
||||
<router-link to="/" class="flex items-center space-x-3 group">
|
||||
<div class="relative">
|
||||
<SparklesIcon class="h-8 w-8 text-purple-500" />
|
||||
<StarIcon class="absolute -top-1 -right-1 h-3.5 w-3.5 text-pink-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-2xl font-bold gradient-text">
|
||||
{{ t('app.title') }}
|
||||
</span>
|
||||
<span class="hidden sm:inline-block text-xs text-gray-400 ml-2">DreamWeaver</span>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
|
||||
:class="{ 'bg-white/70': locale === 'en' }"
|
||||
@click="switchLocale('en')"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
|
||||
:class="{ 'bg-white/70': locale === 'zh' }"
|
||||
@click="switchLocale('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-white/50 transition"
|
||||
@click="toggleTheme"
|
||||
:aria-pressed="isDark"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="h-5 w-5 text-amber-500" />
|
||||
<MoonIcon v-else class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<template v-if="userStore.user">
|
||||
<router-link
|
||||
to="/my-stories"
|
||||
class="hidden sm:flex items-center space-x-2 px-4 py-2 rounded-xl text-gray-600 hover:text-purple-600 hover:bg-purple-50 transition-all duration-300"
|
||||
>
|
||||
<BookOpenIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navMyStories') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/profiles"
|
||||
class="hidden sm:flex items-center space-x-2 px-4 py-2 rounded-xl text-gray-600 hover:text-purple-600 hover:bg-purple-50 transition-all duration-300"
|
||||
>
|
||||
<UserGroupIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navProfiles') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/universes"
|
||||
class="hidden sm:flex items-center space-x-2 px-4 py-2 rounded-xl text-gray-600 hover:text-purple-600 hover:bg-purple-50 transition-all duration-300"
|
||||
>
|
||||
<GlobeAltIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navUniverses') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- 管理入口已移除,管理员直接访问 /admin/providers -->
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center space-x-3 px-3 py-2 rounded-xl hover:bg-white/50 transition-all duration-300"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
v-if="userStore.user.avatar_url"
|
||||
:src="userStore.user.avatar_url"
|
||||
:alt="userStore.user.name"
|
||||
class="w-10 h-10 rounded-full ring-2 ring-purple-200 ring-offset-2"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center text-white font-bold">
|
||||
{{ userStore.user.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-400 rounded-full border-2 border-white"></div>
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<div class="font-medium text-gray-800">{{ userStore.user.name }}</div>
|
||||
<div class="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span v-if="userStore.user.provider === 'github'">GitHub</span>
|
||||
<span v-else>Google</span>
|
||||
<span>{{ t('common.enabled') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform duration-300" :class="{ 'rotate-180': showUserMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200"
|
||||
enter-from-class="opacity-0 scale-95 -translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-56 glass rounded-2xl shadow-xl py-2 border border-white/20"
|
||||
>
|
||||
<router-link
|
||||
to="/my-stories"
|
||||
class="sm:hidden flex items-center space-x-3 px-4 py-3 text-gray-700 hover:bg-purple-50 transition-colors"
|
||||
@click="showUserMenu = false"
|
||||
>
|
||||
<BookOpenIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navMyStories') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/profiles"
|
||||
class="sm:hidden flex items-center space-x-3 px-4 py-3 text-gray-700 hover:bg-purple-50 transition-colors"
|
||||
@click="showUserMenu = false"
|
||||
>
|
||||
<UserGroupIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navProfiles') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/universes"
|
||||
class="sm:hidden flex items-center space-x-3 px-4 py-3 text-gray-700 hover:bg-purple-50 transition-colors"
|
||||
@click="showUserMenu = false"
|
||||
>
|
||||
<GlobeAltIcon class="h-5 w-5" />
|
||||
<span>{{ t('app.navUniverses') }}</span>
|
||||
</router-link>
|
||||
<div class="sm:hidden border-t border-gray-100 my-1"></div>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
class="w-full justify-start text-red-500 hover:bg-red-50"
|
||||
@click="userStore.logout(); showUserMenu = false"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon class="h-5 w-5" />
|
||||
<span>{{ t('common.cancel') }}</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<BaseButton size="sm" @click="userStore.loginWithGithub">
|
||||
GitHub
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" variant="secondary" @click="userStore.loginWithGoogle">
|
||||
Google
|
||||
</BaseButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="showUserMenu = false"
|
||||
></div>
|
||||
</template>
|
||||
139
frontend/src/components/ui/AnalysisAnimation.vue
Normal file
139
frontend/src/components/ui/AnalysisAnimation.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const steps = [
|
||||
'正在接收梦境信号...',
|
||||
'编织故事脉络...',
|
||||
'绘制精美插画 (需要一点点魔法时间)...',
|
||||
'撒上一些星光粉...',
|
||||
'即将完成独一无二的绘本!'
|
||||
]
|
||||
|
||||
const currentStepIndex = ref(0)
|
||||
let stepInterval: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
stepInterval = window.setInterval(() => {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
}
|
||||
}, 2500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stepInterval) clearInterval(stepInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
|
||||
<!-- 背景星空 -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div v-for="i in 20" :key="i"
|
||||
class="absolute rounded-full bg-white animate-twinkle"
|
||||
:style="{
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 3 + 1}px`,
|
||||
height: `${Math.random() * 3 + 1}px`,
|
||||
animationDelay: `${Math.random() * 3}s`,
|
||||
opacity: Math.random() * 0.7 + 0.3
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 核心动画:梦境织机 -->
|
||||
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
|
||||
<!-- 外圈光晕 -->
|
||||
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
|
||||
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
|
||||
|
||||
<!-- 核心光球 -->
|
||||
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 飞舞的粒子 -->
|
||||
<div class="absolute inset-0 animate-spin-slow">
|
||||
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
|
||||
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字提示 -->
|
||||
<div class="z-10 text-center space-y-4">
|
||||
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
|
||||
梦境编织中...
|
||||
</h3>
|
||||
<Transition mode="out-in" name="fade-slide">
|
||||
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
|
||||
{{ steps[currentStepIndex] }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-reverse-slower {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 0.8; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 12s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse-slower {
|
||||
animation: spin-reverse-slower 20s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-gentle {
|
||||
animation: bounce-gentle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-twinkle {
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/components/ui/BaseButton.vue
Normal file
87
frontend/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
as?: string | Record<string, unknown>
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
as: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isButton = computed(() => props.as === 'button' || !props.as)
|
||||
const isDisabled = computed(() => props.disabled || props.loading)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (props.size === 'sm') return 'px-3 py-2 text-sm rounded-lg'
|
||||
if (props.size === 'lg') return 'px-6 py-3 text-base rounded-xl'
|
||||
return 'px-4 py-2.5 text-sm rounded-xl'
|
||||
})
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
case 'danger':
|
||||
return 'bg-red-500 text-white hover:bg-red-600'
|
||||
case 'ghost':
|
||||
return 'bg-transparent text-gray-600 hover:bg-gray-100'
|
||||
default:
|
||||
return 'btn-magic text-white'
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = computed(() => [
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-300',
|
||||
sizeClasses.value,
|
||||
variantClasses.value,
|
||||
isDisabled.value ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, type: _type, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!isButton.value && isDisabled.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.as || 'button'"
|
||||
:type="isButton ? (attrs.type as string || 'button') : undefined"
|
||||
:disabled="isButton ? isDisabled : undefined"
|
||||
:aria-disabled="!isButton && isDisabled ? 'true' : undefined"
|
||||
:class="[baseClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span
|
||||
v-if="props.loading"
|
||||
class="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
43
frontend/src/components/ui/BaseCard.vue
Normal file
43
frontend/src/components/ui/BaseCard.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hover?: boolean
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}>(),
|
||||
{
|
||||
hover: false,
|
||||
padding: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const paddingClasses = computed(() => {
|
||||
switch (props.padding) {
|
||||
case 'none':
|
||||
return 'p-0'
|
||||
case 'sm':
|
||||
return 'p-4'
|
||||
case 'lg':
|
||||
return 'p-8'
|
||||
default:
|
||||
return 'p-6'
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = computed(() => [
|
||||
'glass rounded-2xl',
|
||||
paddingClasses.value,
|
||||
props.hover ? 'card-hover' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
71
frontend/src/components/ui/BaseInput.vue
Normal file
71
frontend/src/components/ui/BaseInput.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number | null
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'date'
|
||||
placeholder?: string
|
||||
label?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
modelModifiers?: { number?: boolean; trim?: boolean }
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
label: '',
|
||||
error: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `input-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const inputId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none',
|
||||
props.error ? 'ring-2 ring-red-200' : '',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="inputId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:value="props.modelValue ?? ''"
|
||||
:disabled="props.disabled"
|
||||
:class="[inputClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@input="(event) => {
|
||||
let value = (event.target as HTMLInputElement).value
|
||||
if (props.modelModifiers?.trim) value = value.trim()
|
||||
if (props.modelModifiers?.number) {
|
||||
const nextValue = Number(value)
|
||||
emit('update:modelValue', Number.isNaN(nextValue) ? value : nextValue)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}"
|
||||
/>
|
||||
<p v-if="props.error" class="text-sm text-red-500">
|
||||
{{ props.error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/src/components/ui/BaseSelect.vue
Normal file
67
frontend/src/components/ui/BaseSelect.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
type Option = { value: string | number; label: string }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number | null
|
||||
options: Option[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `select-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const selectId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const selectClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 focus:outline-none',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
function handleChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const matched = props.options.find(option => String(option.value) === value)
|
||||
emit('update:modelValue', matched ? matched.value : value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="selectId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<select
|
||||
:id="selectId"
|
||||
:value="props.modelValue ?? ''"
|
||||
:disabled="props.disabled"
|
||||
:class="[selectClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@change="handleChange"
|
||||
>
|
||||
<option v-if="props.placeholder" value="">
|
||||
{{ props.placeholder }}
|
||||
</option>
|
||||
<option v-for="option in props.options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
62
frontend/src/components/ui/BaseTextarea.vue
Normal file
62
frontend/src/components/ui/BaseTextarea.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
maxLength?: number
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
rows: 4,
|
||||
label: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `textarea-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const textareaId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none resize-none',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="textareaId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
:id="textareaId"
|
||||
:rows="props.rows"
|
||||
:placeholder="props.placeholder"
|
||||
:value="props.modelValue"
|
||||
:maxlength="props.maxLength"
|
||||
:disabled="props.disabled"
|
||||
:class="[textareaClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
<div v-if="props.maxLength" class="absolute bottom-3 right-4 text-xs text-gray-400">
|
||||
{{ props.modelValue.length }} / {{ props.maxLength }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
60
frontend/src/components/ui/ConfirmModal.vue
Normal file
60
frontend/src/components/ui/ConfirmModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}>(),
|
||||
{
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
variant: 'info',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
|
||||
const confirmVariant = computed(() => (props.variant === 'danger' ? 'danger' : 'primary'))
|
||||
const headerClasses = computed(() => {
|
||||
if (props.variant === 'danger') return 'text-red-600'
|
||||
if (props.variant === 'warning') return 'text-amber-600'
|
||||
return 'text-gray-800'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-all duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="props.show" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="emit('cancel')"></div>
|
||||
<div class="relative glass rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h3 class="text-lg font-semibold" :class="headerClasses">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ props.message }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<BaseButton variant="secondary" @click="emit('cancel')">
|
||||
{{ props.cancelText }}
|
||||
</BaseButton>
|
||||
<BaseButton :variant="confirmVariant" @click="emit('confirm')">
|
||||
{{ props.confirmText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
45
frontend/src/components/ui/EmptyState.vue
Normal file
45
frontend/src/components/ui/EmptyState.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon: Component
|
||||
title: string
|
||||
description: string
|
||||
actionText?: string
|
||||
actionTo?: string
|
||||
}>(),
|
||||
{
|
||||
actionText: '',
|
||||
actionTo: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
|
||||
function handleAction() {
|
||||
if (!props.actionTo) emit('action')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-12">
|
||||
<component :is="props.icon" class="h-14 w-14 text-purple-400" aria-hidden="true" />
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-800">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
<BaseButton
|
||||
v-if="props.actionText"
|
||||
:as="props.actionTo ? 'router-link' : 'button'"
|
||||
:to="props.actionTo || undefined"
|
||||
class="mt-6"
|
||||
@click="handleAction"
|
||||
>
|
||||
{{ props.actionText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
36
frontend/src/components/ui/LoadingSpinner.vue
Normal file
36
frontend/src/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
text: '',
|
||||
},
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'h-6 w-6 border-2'
|
||||
case 'lg':
|
||||
return 'h-14 w-14 border-4'
|
||||
default:
|
||||
return 'h-10 w-10 border-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div
|
||||
:class="['animate-spin rounded-full border-purple-200 border-t-purple-500 border-r-pink-400', sizeClasses]"
|
||||
></div>
|
||||
<p v-if="props.text" class="mt-4 text-sm text-gray-500">
|
||||
{{ props.text }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
136
frontend/src/components/ui/LoginDialog.vue
Normal file
136
frontend/src/components/ui/LoginDialog.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function loginWithGithub() {
|
||||
window.location.href = '/auth/github/signin'
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = '/auth/google/signin'
|
||||
}
|
||||
|
||||
function loginWithDev() {
|
||||
window.location.href = '/auth/dev/signin'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="close"
|
||||
></div>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="close"
|
||||
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-3xl mb-3">✨</div>
|
||||
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
|
||||
登录开始创作
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
选择你的登录方式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
@click="loginWithDev"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
|
||||
>
|
||||
<CommandLineIcon class="w-5 h-5" />
|
||||
<span>开发模式一键登录</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="loginWithGithub"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
<span>使用 GitHub 登录</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="loginWithGoogle"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
<span>使用 Google 登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-dialog {
|
||||
--bg-deep: #0D0F1A;
|
||||
--bg-card: #151829;
|
||||
--bg-elevated: #1C2035;
|
||||
--accent: #FFD369;
|
||||
--text: #EAEAEA;
|
||||
--text-muted: #6B7280;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 211, 105, 0.1);
|
||||
}
|
||||
</style>
|
||||
8
frontend/src/components/ui/index.ts
Normal file
8
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as BaseButton } from './BaseButton.vue'
|
||||
export { default as BaseCard } from './BaseCard.vue'
|
||||
export { default as BaseInput } from './BaseInput.vue'
|
||||
export { default as BaseSelect } from './BaseSelect.vue'
|
||||
export { default as BaseTextarea } from './BaseTextarea.vue'
|
||||
export { default as LoadingSpinner } from './LoadingSpinner.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
Reference in New Issue
Block a user