feat: add data backup/import, goal step ordering, and PostgreSQL migration
- Add GET /api/backup/export and POST /api/backup/import endpoints for full data backup
- Add drag-and-drop reorder for goal steps with PUT /api/goals/{id}/steps/reorder
- Auto-assign sort_order on step creation (preserves creation order)
- Fix duplicate milestone rendering in goal detail page
- Add category management button in goal dialog
- Migrate database default from SQLite to PostgreSQL
- Fix router guard redirect loop for logged-in users on setup/login pages
- Fix ALTER TABLE ADD COLUMN crash on callable defaults (uuid.uuid4)
- Add auth status rate limiter and token version caching
- Update CLAUDE.md to reflect current architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,8 @@ COPY api/ ./api/
|
||||
# 拷贝编译后的前端产物
|
||||
COPY api/webui/ ./api/webui/
|
||||
|
||||
# 创建数据和日志目录
|
||||
RUN mkdir -p api/data api/logs
|
||||
# 创建日志目录
|
||||
RUN mkdir -p api/logs
|
||||
|
||||
EXPOSE 23994
|
||||
|
||||
|
||||
13
WebUI/src/api/backup.ts
Normal file
13
WebUI/src/api/backup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import request from './request'
|
||||
|
||||
export function exportBackup(): Promise<Blob> {
|
||||
return request.get('/backup/export', { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function importBackup(file: File): Promise<{ message: string; count: number }> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post('/backup/import', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export function toggleStep(goalId: number, stepId: number): Promise<GoalStep> {
|
||||
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
|
||||
}
|
||||
|
||||
export function reorderSteps(goalId: number, items: { id: number; sort_order: number }[]): Promise<{ message: string }> {
|
||||
return put<{ message: string }>(`/goals/${goalId}/steps/reorder`, { items })
|
||||
}
|
||||
|
||||
// ============ Reviews ============
|
||||
|
||||
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||
import { useUIStore } from '@/stores/useUIStore'
|
||||
import type { Goal, GoalFormData } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -16,6 +17,7 @@ const emit = defineEmits<{
|
||||
|
||||
const goalStore = useGoalStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const form = ref<GoalFormData>({
|
||||
title: '',
|
||||
@@ -119,14 +121,19 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="无分类">
|
||||
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
<div class="category-row">
|
||||
<el-select v-model="form.category_id" style="flex:1" clearable placeholder="无分类">
|
||||
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
<el-button text size="small" class="manage-cat-btn" @click="uiStore.openCategoryDialog()">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="颜色">
|
||||
<div class="color-picker">
|
||||
<button
|
||||
@@ -165,15 +172,28 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.manage-cat-btn {
|
||||
flex-shrink: 0;
|
||||
color: #999;
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
|
||||
.color-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -93,6 +93,18 @@ router.beforeEach(async (to, from) => {
|
||||
const siteName = userStore.siteName || '爱莉希雅待办'
|
||||
document.title = page ? `${page} - ${siteName}` : siteName
|
||||
|
||||
// 已登录用户访问 setup/login 页面时重定向到主页
|
||||
if ((to.path === '/setup' || to.path === '/login')) {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.checked && authStore.isLoggedIn && !authStore.needSetup) {
|
||||
return { path: '/' }
|
||||
}
|
||||
if (authStore.checked && authStore.needSetup && to.path === '/login') {
|
||||
return { path: '/setup' }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.noAuth) return
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useTaskStore } from '@/stores/useTaskStore'
|
||||
import { reorderSteps } from '@/api/goals'
|
||||
import GoalDialog from '@/components/GoalDialog.vue'
|
||||
import type { Goal, GoalStep } from '@/api/types'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
@@ -43,6 +44,13 @@ const stepLabel: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
}
|
||||
|
||||
function hasPhaseParent(step: GoalStep): boolean {
|
||||
if (!step.parent_id) return false
|
||||
return goalStore.currentGoal?.steps.some(
|
||||
(s: GoalStep) => s.id === step.parent_id && s.step_type === 'phase'
|
||||
) ?? false
|
||||
}
|
||||
|
||||
const stepColor: Record<string, string> = {
|
||||
pending: '#909399',
|
||||
in_progress: '#E6A23C',
|
||||
@@ -67,7 +75,6 @@ async function handleAddStep() {
|
||||
step_type: stepType.value,
|
||||
status: 'pending',
|
||||
parent_id: stepParentId.value,
|
||||
sort_order: 0,
|
||||
})
|
||||
stepTitle.value = ''
|
||||
stepParentId.value = null
|
||||
@@ -121,6 +128,82 @@ const unlinkedTasks = computed(() => {
|
||||
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
|
||||
})
|
||||
|
||||
// ============ Drag & Drop ============
|
||||
interface DragItem {
|
||||
id: number
|
||||
step_type: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
const dragItem = ref<DragItem | null>(null)
|
||||
const dragOverId = ref<number | null>(null)
|
||||
|
||||
function onDragStart(step: GoalStep) {
|
||||
dragItem.value = { id: step.id, step_type: step.step_type, parent_id: step.parent_id }
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, step: GoalStep) {
|
||||
e.preventDefault()
|
||||
if (dragItem.value && dragItem.value.id !== step.id) {
|
||||
dragOverId.value = step.id
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverId.value = null
|
||||
}
|
||||
|
||||
async function onDrop(targetStep: GoalStep) {
|
||||
dragOverId.value = null
|
||||
if (!dragItem.value || dragItem.value.id === targetStep.id) return
|
||||
if (!goalStore.currentGoal) return
|
||||
|
||||
const src = dragItem.value
|
||||
const currentGoal = goalStore.currentGoal
|
||||
|
||||
// Only allow reorder within the same parent scope
|
||||
if (src.parent_id !== targetStep.parent_id) {
|
||||
dragItem.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const siblingSteps = currentGoal.steps.filter(
|
||||
(s: GoalStep) => s.parent_id === targetStep.parent_id && s.step_type === targetStep.step_type
|
||||
)
|
||||
const children = targetStep.parent_id
|
||||
? (currentGoal.steps.find((s: GoalStep) => s.id === targetStep.parent_id)?.children || [])
|
||||
: []
|
||||
|
||||
// Build ordered list: remove dragged item, insert at target position
|
||||
const ordered = siblingSteps.filter((s: GoalStep) => s.id !== src.id)
|
||||
const targetIdx = ordered.findIndex((s: GoalStep) => s.id === targetStep.id)
|
||||
ordered.splice(targetIdx, 0, currentGoal.steps.find((s: GoalStep) => s.id === src.id)!)
|
||||
|
||||
// Assign new sort_order values
|
||||
const items = ordered.map((s: GoalStep, i: number) => ({ id: s.id, sort_order: i }))
|
||||
|
||||
// Optimistic local update
|
||||
for (const item of items) {
|
||||
const step = currentGoal.steps.find((s: GoalStep) => s.id === item.id)
|
||||
if (step) (step as any).sort_order = item.sort_order
|
||||
}
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await reorderSteps(currentGoal.id, items)
|
||||
} catch {
|
||||
ElMessage.error('排序更新失败')
|
||||
await goalStore.fetchGoal(currentGoal.id)
|
||||
}
|
||||
|
||||
dragItem.value = null
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragItem.value = null
|
||||
dragOverId.value = null
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
|
||||
}
|
||||
@@ -197,7 +280,16 @@ const statusLabel: Record<string, string> = {
|
||||
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
|
||||
<!-- Phase -->
|
||||
<div v-if="step.step_type === 'phase'" class="phase-node">
|
||||
<div class="step-row" :class="step.status">
|
||||
<div
|
||||
class="step-row"
|
||||
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(step)"
|
||||
@dragover="onDragOver($event, step)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop(step)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<el-button
|
||||
text
|
||||
class="toggle-btn"
|
||||
@@ -219,7 +311,16 @@ const statusLabel: Record<string, string> = {
|
||||
</div>
|
||||
<!-- child milestones -->
|
||||
<div v-for="child in step.children" :key="child.id" class="child-milestone">
|
||||
<div class="step-row" :class="child.status">
|
||||
<div
|
||||
class="step-row"
|
||||
:class="[child.status, { 'drag-over': dragOverId === child.id, 'dragging': dragItem?.id === child.id }]"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(child)"
|
||||
@dragover="onDragOver($event, child)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop(child)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<el-button
|
||||
text
|
||||
class="toggle-btn"
|
||||
@@ -243,7 +344,17 @@ const statusLabel: Record<string, string> = {
|
||||
</div>
|
||||
|
||||
<!-- Standalone milestone (no parent phase) -->
|
||||
<div v-else class="step-row standalone" :class="step.status">
|
||||
<div
|
||||
v-else-if="!hasPhaseParent(step)"
|
||||
class="step-row standalone"
|
||||
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(step)"
|
||||
@dragover="onDragOver($event, step)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop(step)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<el-button
|
||||
text
|
||||
class="toggle-btn"
|
||||
@@ -420,10 +531,15 @@ section {
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
cursor: grab;
|
||||
&:hover { background: #fafafa; }
|
||||
&:active { cursor: grabbing; }
|
||||
|
||||
.toggle-btn { padding: 4px; }
|
||||
&.dragging { opacity: 0.4; }
|
||||
&.drag-over { background: #e6f7ff; border: 1px dashed #69b1ff; }
|
||||
|
||||
.toggle-btn { padding: 4px; flex-shrink: 0; cursor: pointer; }
|
||||
.step-title { flex: 1; font-size: 14px; }
|
||||
.step-date { font-size: 12px; color: #bbb; }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const error = ref('')
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password.value) return
|
||||
if (!password.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useTaskStore } from '@/stores/useTaskStore'
|
||||
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||
import { useTagStore } from '@/stores/useTagStore'
|
||||
import { useHabitStore } from '@/stores/useHabitStore'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
|
||||
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
|
||||
import { get, post, del } from '@/api/request'
|
||||
import { exportBackup, importBackup } from '@/api/backup'
|
||||
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
||||
|
||||
const userStore = useUserSettingsStore()
|
||||
@@ -15,10 +17,13 @@ const taskStore = useTaskStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
const tagStore = useTagStore()
|
||||
const habitStore = useHabitStore()
|
||||
const goalStore = useGoalStore()
|
||||
const anniversaryStore = useAnniversaryStore()
|
||||
const syncStore = useSyncStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const exporting = ref(false)
|
||||
const importing = ref(false)
|
||||
|
||||
const viewOptions = [
|
||||
{ label: '列表', value: 'list' },
|
||||
@@ -135,32 +140,14 @@ async function startSync() {
|
||||
async function exportData() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([
|
||||
get<Task[]>('/tasks'),
|
||||
get<Category[]>('/categories'),
|
||||
get<Tag[]>('/tags'),
|
||||
get<HabitGroup[]>('/habit-groups'),
|
||||
get<Habit[]>('/habits', { params: { include_archived: true } })
|
||||
])
|
||||
|
||||
const exportObj = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tasks,
|
||||
categories,
|
||||
tags,
|
||||
habitGroups,
|
||||
habits
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' })
|
||||
const blob = await exportBackup()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
|
||||
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
|
||||
a.download = `elysia-backup-${now}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('数据导出成功~')
|
||||
} catch {
|
||||
ElMessage.error('导出失败了呢~')
|
||||
@@ -169,168 +156,46 @@ async function exportData() {
|
||||
}
|
||||
}
|
||||
|
||||
const importFileRef = ref<HTMLInputElement>()
|
||||
|
||||
function importData() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
importFileRef.value?.click()
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
|
||||
'确认导入',
|
||||
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
async function handleImportFile(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'导入数据会覆盖当前的所有数据(包括任务、分类、标签、习惯、纪念日、目标等),确定要继续吗?',
|
||||
'确认导入',
|
||||
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
|
||||
if (!data.tasks || !Array.isArray(data.tasks)) {
|
||||
ElMessage.error('数据格式不正确呢~')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
const result = await importBackup(file)
|
||||
ElMessage.success(`数据导入成功~ 共导入 ${result.count} 条记录`)
|
||||
|
||||
// 先删除所有现有数据
|
||||
const allTasks = await get<Task[]>('/tasks')
|
||||
for (const t of allTasks) {
|
||||
await del(`/tasks/${t.id}`)
|
||||
}
|
||||
|
||||
const allCategories = await get<Category[]>('/categories')
|
||||
for (const c of allCategories) {
|
||||
await del(`/categories/${c.id}`)
|
||||
}
|
||||
|
||||
const allTags = await get<Tag[]>('/tags')
|
||||
for (const t of allTags) {
|
||||
await del(`/tags/${t.id}`)
|
||||
}
|
||||
|
||||
// 删除习惯数据(如果有的话)
|
||||
if (data.habits && Array.isArray(data.habits)) {
|
||||
const allHabits = await get<Habit[]>('/habits', { params: { include_archived: true } })
|
||||
for (const h of allHabits) {
|
||||
await del(`/habits/${h.id}`)
|
||||
}
|
||||
const allGroups = await get<HabitGroup[]>('/habit-groups')
|
||||
for (const g of allGroups) {
|
||||
await del(`/habit-groups/${g.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新导入
|
||||
if (data.categories && Array.isArray(data.categories)) {
|
||||
for (const cat of data.categories) {
|
||||
await post('/categories', { name: cat.name, color: cat.color, icon: cat.icon })
|
||||
}
|
||||
}
|
||||
if (data.tags && Array.isArray(data.tags)) {
|
||||
for (const tag of data.tags) {
|
||||
await post('/tags', { name: tag.name })
|
||||
}
|
||||
}
|
||||
if (data.tasks && Array.isArray(data.tasks)) {
|
||||
// 建立新旧ID到名称的映射
|
||||
const oldCatMap = new Map<number, string>()
|
||||
const oldTagMap = new Map<number, string>()
|
||||
if (data.categories) {
|
||||
data.categories.forEach((c: Category) => oldCatMap.set(c.id, c.name))
|
||||
}
|
||||
if (data.tags) {
|
||||
data.tags.forEach((t: Tag) => oldTagMap.set(t.id, t.name))
|
||||
}
|
||||
|
||||
// 获取新建后的分类和标签
|
||||
const newCategories = await get<Category[]>('/categories')
|
||||
const newTags = await get<Tag[]>('/tags')
|
||||
const catNameToId = new Map(newCategories.map(c => [c.name, c.id]))
|
||||
const tagNameToId = new Map(newTags.map(t => [t.name, t.id]))
|
||||
|
||||
for (const task of data.tasks) {
|
||||
const taskData: Record<string, unknown> = {
|
||||
title: task.title,
|
||||
description: task.description || null,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date || null
|
||||
}
|
||||
|
||||
if (task.category_id && oldCatMap.has(task.category_id)) {
|
||||
const catName = oldCatMap.get(task.category_id)
|
||||
if (catName && catNameToId.has(catName)) {
|
||||
taskData.category_id = catNameToId.get(catName)!
|
||||
}
|
||||
}
|
||||
|
||||
const tagIds: number[] = []
|
||||
if (task.tags && Array.isArray(task.tags)) {
|
||||
for (const tag of task.tags) {
|
||||
if (tagNameToId.has(tag.name)) {
|
||||
tagIds.push(tagNameToId.get(tag.name)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
taskData.tag_ids = tagIds
|
||||
|
||||
await post('/tasks', taskData)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入习惯数据
|
||||
if (data.habitGroups && Array.isArray(data.habitGroups)) {
|
||||
for (const grp of data.habitGroups) {
|
||||
await post('/habit-groups', { name: grp.name, color: grp.color, icon: grp.icon, sort_order: grp.sort_order })
|
||||
}
|
||||
}
|
||||
if (data.habits && Array.isArray(data.habits)) {
|
||||
const oldGroupMap = new Map<number, string>()
|
||||
if (data.habitGroups) {
|
||||
data.habitGroups.forEach((g: HabitGroup) => oldGroupMap.set(g.id, g.name))
|
||||
}
|
||||
|
||||
const newGroups = await get<HabitGroup[]>('/habit-groups')
|
||||
const groupNameToId = new Map(newGroups.map(g => [g.name, g.id]))
|
||||
|
||||
for (const habit of data.habits) {
|
||||
const habitData: Record<string, unknown> = {
|
||||
name: habit.name,
|
||||
description: habit.description || null,
|
||||
target_count: habit.target_count || 1,
|
||||
frequency: habit.frequency || 'daily',
|
||||
active_days: habit.active_days || null,
|
||||
is_archived: habit.is_archived || false
|
||||
}
|
||||
|
||||
if (habit.group_id && oldGroupMap.has(habit.group_id)) {
|
||||
const grpName = oldGroupMap.get(habit.group_id)
|
||||
if (grpName && groupNameToId.has(grpName)) {
|
||||
habitData.group_id = groupNameToId.get(grpName)!
|
||||
}
|
||||
}
|
||||
|
||||
await post('/habits', habitData)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
await Promise.all([
|
||||
taskStore.fetchTasks(),
|
||||
categoryStore.fetchCategories(),
|
||||
tagStore.fetchTags()
|
||||
])
|
||||
if (data.habits || data.habitGroups) {
|
||||
await habitStore.init()
|
||||
}
|
||||
|
||||
ElMessage.success('数据导入成功~')
|
||||
} catch (err) {
|
||||
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') {
|
||||
ElMessage.error('导入失败了呢~')
|
||||
}
|
||||
// 刷新所有 store
|
||||
await Promise.all([
|
||||
taskStore.fetchTasks(),
|
||||
categoryStore.fetchCategories(),
|
||||
tagStore.fetchTags(),
|
||||
habitStore.init(),
|
||||
goalStore.fetchGoals(),
|
||||
anniversaryStore.fetchCategories(),
|
||||
anniversaryStore.fetchAnniversaries(),
|
||||
])
|
||||
} catch (err: any) {
|
||||
if (err?.toString?.() !== 'cancel') {
|
||||
ElMessage.error(err?.response?.data?.detail || '导入失败了呢~')
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
// 重置 file input,允许重新选择同一文件
|
||||
if (importFileRef.value) importFileRef.value.value = ''
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
async function clearCompleted() {
|
||||
@@ -458,7 +323,7 @@ async function clearCompleted() {
|
||||
<div class="data-action-item">
|
||||
<div class="action-info">
|
||||
<span class="action-title">导出数据</span>
|
||||
<span class="action-desc">将所有任务、分类、标签和习惯导出为 JSON 文件</span>
|
||||
<span class="action-desc">将所有数据(任务、目标、习惯、纪念日、分类、标签等)导出为 JSON 文件</span>
|
||||
</div>
|
||||
<el-button
|
||||
:loading="exporting"
|
||||
@@ -473,9 +338,17 @@ async function clearCompleted() {
|
||||
<div class="data-action-item">
|
||||
<div class="action-info">
|
||||
<span class="action-title">导入数据</span>
|
||||
<span class="action-desc warning">从 JSON 文件恢复数据(会覆盖现有数据)</span>
|
||||
<span class="action-desc warning">从 JSON 备份文件恢复全部数据(会覆盖当前所有数据)</span>
|
||||
</div>
|
||||
<input
|
||||
ref="importFileRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display:none"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
<el-button
|
||||
:loading="importing"
|
||||
@click="importData"
|
||||
class="action-btn"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ function validate(): string | null {
|
||||
}
|
||||
|
||||
async function handleSetup() {
|
||||
if (loading.value) return
|
||||
const msg = validate()
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
|
||||
@@ -9,8 +9,10 @@ _logger = logging.getLogger("app.config")
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db")
|
||||
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://ToDoList:53N2PTSjMBPDy6zY@192.168.1.86:5432/ToDoList",
|
||||
)
|
||||
|
||||
# WebUI 配置
|
||||
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
|
||||
@@ -49,3 +51,4 @@ def _load_jwt_secret() -> str:
|
||||
|
||||
JWT_SECRET = _load_jwt_secret()
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
@@ -84,7 +84,11 @@ def init_db():
|
||||
elif col.default is not None:
|
||||
default_val = col.default.arg
|
||||
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
|
||||
if isinstance(default_val, bool):
|
||||
if callable(default_val):
|
||||
# callable 类型的默认值(如 uuid.uuid4)无法写入 SQL DEFAULT,
|
||||
# 后续的 UUID 回填逻辑会处理已有记录
|
||||
pass
|
||||
elif isinstance(default_val, bool):
|
||||
ddl += f" DEFAULT {'TRUE' if default_val else 'FALSE'}"
|
||||
elif isinstance(default_val, str):
|
||||
ddl += f" DEFAULT '{default_val}'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -12,3 +12,4 @@ api_router.include_router(habits.router)
|
||||
api_router.include_router(anniversaries.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(sync.router)
|
||||
api_router.include_router(backup.router)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
import time
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse
|
||||
from app.utils.auth import (
|
||||
hash_password, verify_password, create_access_token,
|
||||
get_current_user,
|
||||
get_current_user, set_cached_token_version,
|
||||
)
|
||||
from app.utils.datetime import utcnow
|
||||
from app.utils.rate_limiter import login_limiter
|
||||
from app.config import ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
@@ -32,9 +34,31 @@ def _get_or_create_settings(db: Session) -> UserSettings:
|
||||
return settings
|
||||
|
||||
|
||||
class _StatusLimiter:
|
||||
MAX_REQUESTS = 30
|
||||
WINDOW_SECONDS = 60
|
||||
|
||||
def __init__(self):
|
||||
self._requests: dict[str, list[float]] = {}
|
||||
|
||||
def check(self, ip: str) -> bool:
|
||||
now = time.time()
|
||||
times = [t for t in self._requests.get(ip, []) if now - t < self.WINDOW_SECONDS]
|
||||
self._requests[ip] = times
|
||||
if len(times) >= self.MAX_REQUESTS:
|
||||
return False
|
||||
times.append(now)
|
||||
return True
|
||||
|
||||
_status_limiter = _StatusLimiter()
|
||||
|
||||
|
||||
@router.get("/status", response_model=AuthSetupStatusResponse)
|
||||
def auth_status(db: Session = Depends(get_db)):
|
||||
def auth_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""检查系统密码是否已设置"""
|
||||
ip = _get_client_ip(request)
|
||||
if not _status_limiter.check(ip):
|
||||
raise HTTPException(status_code=429, detail="请求过于频繁")
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
has_password = bool(settings and settings.password_hash)
|
||||
return AuthSetupStatusResponse(has_password=has_password)
|
||||
@@ -68,7 +92,7 @@ def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)):
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
max_age=86400,
|
||||
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
@@ -111,7 +135,7 @@ def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
max_age=86400,
|
||||
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
@@ -147,6 +171,7 @@ def change_password(
|
||||
|
||||
settings.password_hash = hash_password(data.new_password)
|
||||
settings.token_version = (settings.token_version or 0) + 1
|
||||
set_cached_token_version(str(settings.id), settings.token_version)
|
||||
settings.updated_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
161
api/app/routers/backup.py
Normal file
161
api/app/routers/backup.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""数据备份导入导出路由"""
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from io import BytesIO
|
||||
|
||||
from app.database import get_db, Base, engine
|
||||
from app.utils.logger import logger
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/backup", tags=["备份"])
|
||||
|
||||
# 导出顺序:按依赖关系(无 FK 的先导出)
|
||||
EXPORT_TABLES = [
|
||||
"categories", "tags", "user_settings", "sync_settings",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"goals", "tasks", "habits", "anniversaries",
|
||||
"goal_steps", "goal_reviews", "habit_checkins",
|
||||
"task_tags", "goal_tasks",
|
||||
]
|
||||
|
||||
# 导入时的清表顺序:子表先删(避免 FK 约束报错)
|
||||
TRUNCATE_ORDER = [
|
||||
"task_tags", "goal_tasks",
|
||||
"habit_checkins",
|
||||
"goal_reviews", "goal_steps",
|
||||
"tasks", "habits", "anniversaries",
|
||||
"goals", "categories", "tags",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"user_settings", "sync_settings",
|
||||
]
|
||||
|
||||
# 导入时的插入顺序:父表先插
|
||||
INSERT_ORDER = [
|
||||
"categories", "tags", "user_settings", "sync_settings",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"goals", "tasks", "habits", "anniversaries",
|
||||
"goal_steps", "goal_reviews", "habit_checkins",
|
||||
"task_tags", "goal_tasks",
|
||||
]
|
||||
|
||||
|
||||
def _serialize_value(val):
|
||||
"""将 Python 对象转为 JSON 可序列化的值"""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (datetime, date)):
|
||||
return val.isoformat()
|
||||
if isinstance(val, bytes):
|
||||
return val.decode("utf-8", errors="replace")
|
||||
return val
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_data(db: Session = Depends(get_db)):
|
||||
"""导出所有数据为 JSON 备份文件"""
|
||||
try:
|
||||
data: dict[str, list[dict]] = {}
|
||||
|
||||
for table_name in EXPORT_TABLES:
|
||||
rows = []
|
||||
try:
|
||||
result = db.execute(text(f"SELECT * FROM {table_name}"))
|
||||
columns = list(result.keys())
|
||||
for row in result:
|
||||
rows.append({
|
||||
col: _serialize_value(getattr(row, col))
|
||||
for col in columns
|
||||
})
|
||||
except Exception:
|
||||
# 表可能不存在
|
||||
rows = []
|
||||
data[table_name] = rows
|
||||
|
||||
backup = {
|
||||
"metadata": {
|
||||
"version": 1,
|
||||
"exported_at": utcnow().isoformat(),
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
json_bytes = json.dumps(backup, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
filename = f"elysia-backup-{utcnow().strftime('%Y%m%d-%H%M%S')}.json"
|
||||
|
||||
logger.info(f"数据导出成功,共 {sum(len(v) for v in data.values())} 条记录")
|
||||
return StreamingResponse(
|
||||
BytesIO(json_bytes),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导出数据失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="导出数据失败")
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_data(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""导入备份数据(覆盖当前所有数据)"""
|
||||
if not file.filename or not file.filename.endswith(".json"):
|
||||
raise HTTPException(status_code=400, detail="请上传 JSON 格式的备份文件")
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
backup = json.loads(content.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="备份文件格式不正确")
|
||||
except Exception as e:
|
||||
logger.error(f"读取备份文件失败: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="读取备份文件失败")
|
||||
|
||||
payload = backup.get("data")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="备份文件内容为空")
|
||||
|
||||
# 验证必要表存在
|
||||
for table_name in INSERT_ORDER:
|
||||
if table_name not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"备份文件缺少表: {table_name}")
|
||||
|
||||
imported_count = 0
|
||||
try:
|
||||
# 1. 按序清空所有表
|
||||
for table_name in TRUNCATE_ORDER:
|
||||
try:
|
||||
db.execute(text(f"DELETE FROM {table_name}"))
|
||||
except Exception:
|
||||
pass # 表可能不存在
|
||||
db.flush()
|
||||
|
||||
# 2. 按序插入数据
|
||||
for table_name in INSERT_ORDER:
|
||||
rows = payload.get(table_name, [])
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
col_str = ", ".join(columns)
|
||||
placeholders = ", ".join([f":{c}" for c in columns])
|
||||
|
||||
for row_data in rows:
|
||||
db.execute(
|
||||
text(f"INSERT INTO {table_name} ({col_str}) VALUES ({placeholders})"),
|
||||
{c: row_data[c] for c in columns},
|
||||
)
|
||||
imported_count += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"数据导入成功,共 {imported_count} 条记录")
|
||||
return {"message": "数据导入成功", "count": imported_count}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"导入数据失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"导入数据失败: {str(e)}")
|
||||
@@ -9,6 +9,7 @@ from app.schemas.goal import (
|
||||
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
|
||||
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
|
||||
GoalReviewCreate, GoalReviewResponse,
|
||||
ReorderRequest,
|
||||
)
|
||||
from app.schemas.common import DeleteResponse
|
||||
from app.utils.crud import get_or_404
|
||||
@@ -35,10 +36,10 @@ def recalc_progress(db: Session, goal_id: int):
|
||||
|
||||
|
||||
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
|
||||
"""将扁平的 step 列表转为树形结构(phase 包含子 milestone)"""
|
||||
"""将扁平的 step 列表转为树形结构(phase 包含子 milestone),按 sort_order 排序"""
|
||||
step_map = {}
|
||||
roots = []
|
||||
for s in steps:
|
||||
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||||
step_map[s.id] = {
|
||||
"id": s.id,
|
||||
"goal_id": s.goal_id,
|
||||
@@ -52,7 +53,7 @@ def build_step_tree(steps: list[GoalStep]) -> list[dict]:
|
||||
"created_at": s.created_at,
|
||||
"children": [],
|
||||
}
|
||||
for s in steps:
|
||||
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||||
node = step_map[s.id]
|
||||
if s.parent_id and s.parent_id in step_map:
|
||||
step_map[s.parent_id]["children"].append(node)
|
||||
@@ -206,7 +207,13 @@ def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db
|
||||
"""添加阶段/里程碑"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
step = GoalStep(goal_id=goal_id, **data.model_dump())
|
||||
# 自动分配 sort_order:同类步骤中取最大值 + 1
|
||||
max_sort = db.query(GoalStep).filter(
|
||||
GoalStep.goal_id == goal_id,
|
||||
GoalStep.step_type == data.step_type,
|
||||
).order_by(GoalStep.sort_order.desc()).first()
|
||||
next_sort = (max_sort.sort_order + 1) if max_sort and max_sort.sort_order is not None else 0
|
||||
step = GoalStep(goal_id=goal_id, sort_order=next_sort, **data.model_dump())
|
||||
db.add(step)
|
||||
db.commit()
|
||||
db.refresh(step)
|
||||
@@ -228,6 +235,31 @@ def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db
|
||||
raise HTTPException(status_code=500, detail="添加步骤失败")
|
||||
|
||||
|
||||
# ============ Reorder ============
|
||||
|
||||
@router.put("/{goal_id}/steps/reorder")
|
||||
def reorder_steps(goal_id: int, data: ReorderRequest, db: Session = Depends(get_db)):
|
||||
"""批量更新步骤排序"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
for item in data.items:
|
||||
step = db.query(GoalStep).filter(
|
||||
GoalStep.id == item.id,
|
||||
GoalStep.goal_id == goal_id,
|
||||
).first()
|
||||
if step:
|
||||
step.sort_order = item.sort_order
|
||||
db.commit()
|
||||
logger.info(f"步骤排序更新成功: goal_id={goal_id}, count={len(data.items)}")
|
||||
return {"message": "排序更新成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"更新步骤排序失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="更新排序失败")
|
||||
|
||||
|
||||
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
|
||||
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
|
||||
"""更新阶段/里程碑"""
|
||||
|
||||
14
api/app/schemas/backup.py
Normal file
14
api/app/schemas/backup.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""数据备份导入导出 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
class BackupMetadata(BaseModel):
|
||||
version: int = 1
|
||||
exported_at: datetime
|
||||
|
||||
|
||||
class BackupPayload(BaseModel):
|
||||
metadata: BackupMetadata
|
||||
data: dict[str, list[dict[str, Any]]]
|
||||
@@ -120,3 +120,14 @@ class GoalDetailResponse(GoalListResponse):
|
||||
|
||||
class GoalStatusUpdate(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")
|
||||
|
||||
|
||||
# ============ Reorder Schema ============
|
||||
|
||||
class ReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
items: list[ReorderItem]
|
||||
|
||||
@@ -9,6 +9,16 @@ from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
_token_version_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def get_cached_token_version(user_id: str) -> int | None:
|
||||
return _token_version_cache.get(user_id)
|
||||
|
||||
|
||||
def set_cached_token_version(user_id: str, version: int):
|
||||
_token_version_cache[user_id] = version
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
@@ -30,6 +40,8 @@ def decode_access_token(token: str) -> dict:
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> dict:
|
||||
if hasattr(request.state, "user") and request.state.user:
|
||||
return request.state.user
|
||||
token = request.cookies.get("access_token", "")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-DHLFfahW.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B7eroNyE.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-C7J9BJ23.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DXzKHHP4.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-D84OQMcE.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Z-X1qCVU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
container_name: elysia-todo-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ToDoList
|
||||
POSTGRES_PASSWORD: 53N2PTSjMBPDy6zY
|
||||
POSTGRES_DB: ToDoList
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
elysia-todo:
|
||||
build: .
|
||||
container_name: elysia-todo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "23994:23994"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://ToDoList:53N2PTSjMBPDy6zY@db:5432/ToDoList"
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
# 挂载前端编译产物,本地修改后可立即生效
|
||||
- ./api/webui:/app/api/webui:ro
|
||||
# 挂载数据库文件,持久化数据
|
||||
- ./api/data:/app/api/data
|
||||
# 挂载日志目录,方便查看日志
|
||||
- ./api/logs:/app/api/logs
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -5,5 +5,5 @@ pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
psycopg2-binary==2.9.11
|
||||
|
||||
Reference in New Issue
Block a user