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:
zhangtuo
2026-01-20 18:20:03 +08:00
commit e9d7f8832a
241 changed files with 33070 additions and 0 deletions

View File

@@ -0,0 +1,376 @@
<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 {
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/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="close"
></div>
<!-- 模态框内容 -->
<div 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>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale } from '../i18n'
import {
ArrowRightOnRectangleIcon,
MoonIcon,
SparklesIcon,
StarIcon,
SunIcon,
} from '@heroicons/vue/24/outline'
const { locale } = useI18n()
const isDark = ref(false)
// 管理员状态直接读取 Storage
const isLoggedIn = computed(() => !!sessionStorage.getItem('admin_auth'))
function logout() {
sessionStorage.removeItem('admin_auth')
window.location.reload()
}
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">
DreamWeaver
</span>
<span class="inline-block px-2 py-0.5 ml-2 text-xs font-bold text-white bg-purple-600 rounded-full">
Admin
</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>
<!-- 管理员已登录状态 -->
<div v-if="isLoggedIn" class="relative ml-4 pl-4 border-l border-gray-200">
<div class="flex items-center space-x-3">
<div class="text-right hidden sm:block">
<div class="text-sm font-bold text-gray-800">Administrator</div>
<div class="text-xs text-gray-500">Super User</div>
</div>
<button
@click="logout"
class="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="退出登录"
>
<ArrowRightOnRectangleIcon class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>

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

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

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

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

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

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

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

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

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

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