release: Elysia ToDo v1.0.0

鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
This commit is contained in:
祀梦
2026-03-14 22:21:26 +08:00
commit 2979197b1c
104 changed files with 21737 additions and 0 deletions

24
WebUI/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
WebUI/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
WebUI/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2499
WebUI/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
WebUI/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.5",
"pinia": "^3.0.4",
"pinyin-pro": "^3.28.0",
"sass": "^1.98.0",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

1
WebUI/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

154
WebUI/src/App.vue Normal file
View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
import AppHeader from '@/components/AppHeader.vue'
import TaskDialog from '@/components/TaskDialog.vue'
import CategoryDialog from '@/components/CategoryDialog.vue'
const route = useRoute()
const router = useRouter()
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const userSettingsStore = useUserSettingsStore()
// 路由变化时同步 currentView
watch(() => route.meta.view, (view) => {
if (view) {
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets')
}
}, { immediate: true })
// 网站名称变化时更新页面标题
watch(() => userSettingsStore.siteName, (name) => {
const page = (route.meta.title as string) || ''
document.title = page ? `${page} - ${name}` : name
})
onMounted(async () => {
await userSettingsStore.fetchAndSync()
// 根据用户设置初始化默认排序
taskStore.setFilters({
sort_by: userSettingsStore.defaultSortBy as 'priority' | 'due_date' | 'created_at',
sort_order: userSettingsStore.defaultSortOrder as 'asc' | 'desc'
})
// 首次访问根路径时根据设置跳转到默认视图
if (route.path === '/') {
const viewMap: Record<string, string> = {
list: '/tasks',
calendar: '/calendar',
quadrant: '/quadrant'
}
const target = viewMap[userSettingsStore.defaultView] || '/tasks'
router.replace(target)
}
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags()
])
})
</script>
<template>
<div class="app-container">
<div class="decoration-star" style="top: 20%; right: 8%; animation-delay: 0.5s;"></div>
<div class="decoration-star" style="top: 60%; left: 3%; animation-delay: 1s;"></div>
<div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div>
<AppHeader />
<div class="app-main">
<main
class="main-content"
:class="{
'full-width': uiStore.currentView !== 'list'
}"
>
<router-view v-slot="{ Component }">
<Transition name="view-fade" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
</main>
</div>
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries' && uiStore.currentView !== 'assets'" class="fab-container">
<el-button
type="primary"
circle
size="large"
class="add-btn btn-glow"
@click="uiStore.openTaskDialog()"
>
<el-icon :size="24"><Plus /></el-icon>
</el-button>
</div>
<TaskDialog />
<CategoryDialog />
</div>
</template>
<style scoped lang="scss">
.app-container {
min-height: 100vh;
position: relative;
}
.app-main {
margin-top: 60px;
min-height: calc(100vh - 60px);
}
.main-content {
padding: 0;
&.full-width {
padding: 0;
}
}
.fab-container {
position: fixed;
bottom: 32px;
right: 32px;
z-index: 100;
.add-btn {
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
box-shadow: var(--shadow-lg);
&:hover {
transform: scale(1.1);
}
}
}
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.view-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.view-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

64
WebUI/src/api/accounts.ts Normal file
View File

@@ -0,0 +1,64 @@
import { get, post, put, del, patch } from './request'
import type {
FinancialAccount, AccountFormData, BalanceUpdateData,
AccountHistoryResponse, DebtInstallment, DebtInstallmentFormData
} from './types'
export interface GetAccountHistoryParams {
page?: number
page_size?: number
}
export const accountApi = {
// ============ 账户 CRUD ============
getAccounts(): Promise<FinancialAccount[]> {
return get<FinancialAccount[]>('/accounts')
},
getAccount(id: number): Promise<FinancialAccount> {
return get<FinancialAccount>(`/accounts/${id}`)
},
createAccount(data: AccountFormData): Promise<FinancialAccount> {
return post<FinancialAccount>('/accounts', data)
},
updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount> {
return put<FinancialAccount>(`/accounts/${id}`, data)
},
deleteAccount(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/accounts/${id}`)
},
// ============ 余额操作 ============
updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount> {
return post<FinancialAccount>(`/accounts/${id}/balance`, data)
},
// ============ 变更历史 ============
getHistory(id: number, params?: GetAccountHistoryParams): Promise<AccountHistoryResponse> {
return get<AccountHistoryResponse>(`/accounts/${id}/history`, { params })
},
// ============ 分期计划 ============
getInstallments(): Promise<DebtInstallment[]> {
return get<DebtInstallment[]>('/debt-installments')
},
createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment> {
return post<DebtInstallment>('/debt-installments', data)
},
updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment> {
return put<DebtInstallment>(`/debt-installments/${id}`, data)
},
deleteInstallment(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/debt-installments/${id}`)
},
payInstallment(id: number): Promise<DebtInstallment> {
return patch<DebtInstallment>(`/debt-installments/${id}/pay`)
},
}

View File

@@ -0,0 +1,49 @@
import { get, post, put, del } from './request'
import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from './types'
export type AnniversaryResponse = Anniversary
export type AnniversaryCategoryResponse = AnniversaryCategory
export interface GetAnniversariesParams {
category_id?: number
}
export const anniversaryApi = {
// ============ 纪念日 ============
getAnniversaries(params?: GetAnniversariesParams): Promise<AnniversaryResponse[]> {
return get<AnniversaryResponse[]>('/anniversaries', { params })
},
getAnniversary(id: number): Promise<AnniversaryResponse> {
return get<AnniversaryResponse>(`/anniversaries/${id}`)
},
createAnniversary(data: AnniversaryFormData): Promise<AnniversaryResponse> {
return post<AnniversaryResponse>('/anniversaries', data)
},
updateAnniversary(id: number, data: Partial<AnniversaryFormData>): Promise<AnniversaryResponse> {
return put<AnniversaryResponse>(`/anniversaries/${id}`, data)
},
deleteAnniversary(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/anniversaries/${id}`)
},
// ============ 纪念日分类 ============
getCategories(): Promise<AnniversaryCategoryResponse[]> {
return get<AnniversaryCategoryResponse[]>('/anniversary-categories')
},
createCategory(data: AnniversaryCategoryFormData): Promise<AnniversaryCategoryResponse> {
return post<AnniversaryCategoryResponse>('/anniversary-categories', data)
},
updateCategory(id: number, data: Partial<AnniversaryCategoryFormData>): Promise<AnniversaryCategoryResponse> {
return put<AnniversaryCategoryResponse>(`/anniversary-categories/${id}`, data)
},
deleteCategory(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/anniversary-categories/${id}`)
},
}

View File

@@ -0,0 +1,29 @@
import { get, post, put, del, patch } from './request'
import type { CategoryFormData } from './types'
export interface CategoryResponse {
id: number
name: string
color: string
icon: string
}
export type { CategoryFormData }
export const categoryApi = {
getCategories(): Promise<CategoryResponse[]> {
return get<CategoryResponse[]>('/categories')
},
createCategory(data: CategoryFormData): Promise<CategoryResponse> {
return post<CategoryResponse>('/categories', data)
},
updateCategory(id: number, data: CategoryFormData): Promise<CategoryResponse> {
return put<CategoryResponse>(`/categories/${id}`, data)
},
deleteCategory(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/categories/${id}`)
}
}

78
WebUI/src/api/habits.ts Normal file
View File

@@ -0,0 +1,78 @@
import { get, post, put, del, patch } from './request'
import type {
Habit, HabitFormData, HabitGroup, HabitGroupFormData,
HabitCheckin, HabitStats
} from './types'
export type HabitResponse = Habit
export type HabitGroupResponse = HabitGroup
export type HabitCheckinResponse = HabitCheckin
export type HabitStatsResponse = HabitStats
export const habitGroupApi = {
getGroups(): Promise<HabitGroupResponse[]> {
return get<HabitGroupResponse[]>('/habit-groups')
},
createGroup(data: HabitGroupFormData): Promise<HabitGroupResponse> {
return post<HabitGroupResponse>('/habit-groups', data)
},
updateGroup(id: number, data: Partial<HabitGroupFormData>): Promise<HabitGroupResponse> {
return put<HabitGroupResponse>(`/habit-groups/${id}`, data)
},
deleteGroup(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habit-groups/${id}`)
}
}
export interface GetHabitsParams {
include_archived?: boolean
}
export const habitApi = {
getHabits(params?: GetHabitsParams): Promise<HabitResponse[]> {
return get<HabitResponse[]>('/habits', { params })
},
getHabit(id: number): Promise<HabitResponse> {
return get<HabitResponse>(`/habits/${id}`)
},
createHabit(data: HabitFormData): Promise<HabitResponse> {
return post<HabitResponse>('/habits', data)
},
updateHabit(id: number, data: Partial<HabitFormData>): Promise<HabitResponse> {
return put<HabitResponse>(`/habits/${id}`, data)
},
deleteHabit(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habits/${id}`)
},
toggleArchive(id: number): Promise<HabitResponse> {
return patch<HabitResponse>(`/habits/${id}/archive`, {})
},
getCheckins(habitId: number, fromDate?: string, toDate?: string): Promise<HabitCheckinResponse[]> {
const params: Record<string, string> = {}
if (fromDate) params.from_date = fromDate
if (toDate) params.to_date = toDate
return get<HabitCheckinResponse[]>(`/habits/${habitId}/checkins`, { params })
},
checkin(habitId: number, count?: number): Promise<HabitCheckinResponse> {
const data = count !== undefined ? { count } : {}
return post<HabitCheckinResponse>(`/habits/${habitId}/checkins`, data)
},
cancelCheckin(habitId: number, count: number = 1): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habits/${habitId}/checkins`, { params: { count } })
},
getStats(habitId: number): Promise<HabitStatsResponse> {
return get<HabitStatsResponse>(`/habits/${habitId}/checkins/stats`)
}
}

72
WebUI/src/api/request.ts Normal file
View File

@@ -0,0 +1,72 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
const instance: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error) => {
let message = '服务器开小差了,请稍后再试~'
if (error.response) {
const data = error.response.data
if (data?.detail) {
message = Array.isArray(data.detail)
? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ')
: data.detail
}
switch (error.response.status) {
case 400:
message = data?.detail || '请求参数有误,请检查一下~'
break
case 401:
message = '登录状态已失效~'
break
case 403:
message = '没有权限访问呢~'
break
case 404:
message = '请求的资源不存在~'
break
case 500:
message = '服务器内部错误~'
break
}
}
ElMessage.error({
message,
duration: 3000,
showClose: true
})
return Promise.reject(error)
}
)
export default instance
export function get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
return instance.get(url, config).then((res) => res.data)
}
export function post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.post(url, data, config).then((res) => res.data)
}
export function put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.put(url, data, config).then((res) => res.data)
}
export function del<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
return instance.delete(url, config).then((res) => res.data)
}
export function patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.patch(url, data, config).then((res) => res.data)
}

23
WebUI/src/api/tags.ts Normal file
View File

@@ -0,0 +1,23 @@
import { get, post, del } from './request'
import type { TagFormData } from './types'
export interface TagResponse {
id: number
name: string
}
export type { TagFormData }
export const tagApi = {
getTags(): Promise<TagResponse[]> {
return get<TagResponse[]>('/tags')
},
createTag(data: TagFormData): Promise<TagResponse> {
return post<TagResponse>('/tags', data)
},
deleteTag(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/tags/${id}`)
}
}

44
WebUI/src/api/tasks.ts Normal file
View File

@@ -0,0 +1,44 @@
import { get, post, put, del, patch } from './request'
import type { TaskFormData } from './types'
export type TaskResponse = import('./types').Task
export type CategoryResponse = import('./types').Category
export type TagResponse = import('./types').Tag
export type { TaskFormData }
export interface GetTasksParams {
status?: string
category_id?: number
priority?: string
sort_by?: string
sort_order?: string
skip?: number
limit?: number
}
export const taskApi = {
getTasks(params?: GetTasksParams): Promise<TaskResponse[]> {
return get<TaskResponse[]>('/tasks', { params })
},
getTask(id: number): Promise<TaskResponse> {
return get<TaskResponse>(`/tasks/${id}`)
},
createTask(data: TaskFormData): Promise<TaskResponse> {
return post<TaskResponse>('/tasks', data)
},
updateTask(id: number, data: TaskFormData): Promise<TaskResponse> {
return put<TaskResponse>(`/tasks/${id}`, data)
},
deleteTask(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/tasks/${id}`)
},
toggleTask(id: number): Promise<TaskResponse> {
return patch<TaskResponse>(`/tasks/${id}/toggle`, {})
}
}

278
WebUI/src/api/types.ts Normal file
View File

@@ -0,0 +1,278 @@
export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
export interface Task {
id: number
title: string
description?: string
priority: QuadrantPriority
due_date?: string
is_completed: boolean
category_id?: number
category?: Category
tags?: Tag[]
created_at: string
updated_at: string
}
export interface Category {
id: number
name: string
color: string
icon: string
}
export interface Tag {
id: number
name: string
}
export interface TaskFormData {
title: string
description?: string
priority: QuadrantPriority
due_date?: string
category_id?: number
tag_ids?: number[]
}
export interface CategoryFormData {
name: string
color: string
icon: string
}
export interface TagFormData {
name: string
}
export interface TaskFilters {
status?: 'all' | 'active' | 'completed'
category_id?: number
sort_by?: 'priority' | 'due_date' | 'created_at'
sort_order?: 'asc' | 'desc'
search?: string
}
export interface UserSettings {
id: number
nickname: string
avatar?: string
signature?: string
birthday?: string
email?: string
site_name: string
theme: string
language: string
default_view: string
default_sort_by: string
default_sort_order: string
created_at: string
updated_at: string
}
export interface UserSettingsUpdate {
nickname?: string
avatar?: string
signature?: string
birthday?: string
email?: string
site_name?: string
theme?: string
language?: string
default_view?: string
default_sort_by?: string
default_sort_order?: string
}
// ============ 习惯相关 ============
export interface HabitGroup {
id: number
name: string
color: string
icon: string
sort_order: number
}
export interface HabitGroupFormData {
name: string
color: string
icon: string
sort_order?: number
}
export type HabitFrequency = 'daily' | 'weekly'
export interface Habit {
id: number
name: string
description?: string
group_id?: number
target_count: number
frequency: HabitFrequency
active_days?: string
is_archived: boolean
created_at: string
updated_at: string
group?: HabitGroup
}
export interface HabitFormData {
name: string
description?: string
group_id?: number | null
target_count: number
frequency: HabitFrequency
active_days?: string | null
}
export interface HabitCheckin {
id: number
habit_id: number
checkin_date: string
count: number
created_at: string
}
export interface HabitStats {
total_days: number
current_streak: number
longest_streak: number
today_count: number
today_completed: boolean
}
// ============ 纪念日相关 ============
export interface AnniversaryCategory {
id: number
name: string
icon: string
color: string
sort_order: number
}
export interface AnniversaryCategoryFormData {
name: string
icon: string
color: string
sort_order?: number
}
export interface Anniversary {
id: number
title: string
date: string
year?: number | null
category_id?: number | null
description?: string | null
is_recurring: boolean
remind_days_before: number
created_at: string
updated_at: string
category?: AnniversaryCategory | null
next_date?: string | null
days_until?: number | null
year_count?: number | null
}
export interface AnniversaryFormData {
title: string
date: string
year?: number | null
category_id?: number | null
description?: string | null
is_recurring: boolean
remind_days_before: number
}
// ============ 资产账户相关 ============
export type AccountType = 'savings' | 'debt'
export interface FinancialAccount {
id: number
name: string
account_type: AccountType
balance: number
icon: string
color: string
sort_order: number
is_active: boolean
description?: string | null
created_at: string
updated_at: string
installments?: InstallmentInfo[]
}
export interface AccountFormData {
name: string
account_type: AccountType
balance: number
icon: string
color: string
sort_order: number
is_active: boolean
description?: string | null
}
export interface BalanceUpdateData {
new_balance: number
note?: string | null
}
export interface InstallmentInfo {
next_payment_date: string | null
days_until_payment: number | null
remaining_periods: number
}
export interface AccountHistoryRecord {
id: number
account_id: number
change_amount: number
balance_before: number
balance_after: number
note?: string | null
created_at: string
}
export interface AccountHistoryResponse {
total: number
page: number
page_size: number
records: AccountHistoryRecord[]
}
// ============ 分期还款计划相关 ============
export interface DebtInstallment {
id: number
account_id: number
total_amount: number
total_periods: number
current_period: number
payment_day: number
payment_amount: number
start_date: string
is_completed: boolean
created_at: string
updated_at: string
next_payment_date: string | null
days_until_payment: number | null
remaining_periods: number | null
account_name: string | null
account_icon: string | null
account_color: string | null
}
export interface DebtInstallmentFormData {
account_id: number
total_amount: number
total_periods: number
current_period: number
payment_day: number
payment_amount: number
start_date: string
is_completed: boolean
}

View File

@@ -0,0 +1,10 @@
import { get, put } from './request'
import type { UserSettings, UserSettingsUpdate } from './types'
export function getUserSettings(): Promise<UserSettings> {
return get<UserSettings>('/user-settings')
}
export function updateUserSettings(data: UserSettingsUpdate): Promise<UserSettings> {
return put<UserSettings>('/user-settings', data)
}

1
WebUI/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editAccount?: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
editAccount: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editAccount)
const dialogTitle = computed(() => isEdit.value ? '编辑账户' : '新建账户')
const form = ref({
name: '',
account_type: 'savings' as 'savings' | 'debt',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
})
const iconOptions = [
{ label: '钱包', value: 'wallet' },
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行卡', value: 'bank' },
{ label: '信用卡', value: 'credit-card' },
{ label: '花呗', value: 'huabei' },
{ label: '白条', value: 'baitiao' },
{ label: '现金', value: 'cash' },
{ label: '投资', value: 'investment' },
{ label: '其他', value: 'other' },
]
const colorOptions = [
'#FFB7C5', '#C8A2C8', '#98D8C8', '#FFB347',
'#87CEEB', '#FF6B6B', '#A8E6CF', '#DDA0DD',
'#F0E68C', '#20B2AA', '#FF8C69', '#9370DB',
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editAccount) {
const a = props.editAccount
form.value = {
name: a.name,
account_type: a.account_type,
balance: a.balance,
icon: a.icon,
color: a.color,
is_active: a.is_active,
description: a.description || ''
}
} else {
form.value = {
name: '',
account_type: 'savings',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
}
}
}
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入账户名称~')
return
}
const data = {
name: form.value.name.trim(),
account_type: form.value.account_type,
balance: form.value.balance,
icon: form.value.icon,
color: form.value.color,
sort_order: 0,
is_active: form.value.is_active,
description: form.value.description.trim() || null
}
if (isEdit.value && props.editAccount) {
const result = await store.updateAccount(props.editAccount.id, data)
if (result) ElMessage.success('账户更新成功~')
} else {
const result = await store.createAccount(data)
if (result) ElMessage.success('账户创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="480px"
@close="handleClose"
class="account-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">账户名称</label>
<el-input
v-model="form.name"
placeholder="如:微信、支付宝、花呗、招商银行..."
maxlength="100"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">账户类型</label>
<div class="type-switch">
<button
class="type-btn"
:class="{ active: form.account_type === 'savings' }"
@click="form.account_type = 'savings'"
>
<el-icon><Wallet /></el-icon>
<span>存款</span>
</button>
<button
class="type-btn"
:class="{ active: form.account_type === 'debt' }"
@click="form.account_type = 'debt'"
>
<el-icon><CreditCard /></el-icon>
<span>欠款</span>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">
当前余额
<span class="form-hint">{{ form.account_type === 'savings' ? '存款金额' : '欠款金额' }}</span>
</label>
<el-input-number
v-model="form.balance"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">图标</label>
<div class="icon-grid">
<button
v-for="opt in iconOptions"
:key="opt.value"
class="icon-btn"
:class="{ active: form.icon === opt.value }"
@click="form.icon = opt.value"
:title="opt.label"
>
<el-icon :size="18">
<Wallet v-if="opt.value === 'wallet'" />
<ChatDotRound v-else-if="opt.value === 'wechat'" />
<ShoppingCart v-else-if="opt.value === 'alipay'" />
<CreditCard v-else-if="opt.value === 'bank' || opt.value === 'credit-card' || opt.value === 'huabei'" />
<Ticket v-else-if="opt.value === 'baitiao'" />
<Money v-else-if="opt.value === 'cash'" />
<TrendCharts v-else-if="opt.value === 'investment'" />
<MoreFilled v-else />
</el-icon>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">主题色</label>
<div class="color-grid">
<button
v-for="color in colorOptions"
:key="color"
class="color-btn"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="500"
/>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">启用状态</label>
<el-switch v-model="form.is_active" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
.form-hint {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
}
}
.type-switch {
display: flex;
gap: 12px;
.type-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px solid rgba(255, 183, 197, 0.2);
border-radius: var(--radius-md);
background: white;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.2);
}
}
}
.icon-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 183, 197, 0.15);
border-radius: var(--radius-sm);
background: white;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
}
}
}
.color-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.color-btn {
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--text-primary);
transform: scale(1.1);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount, AccountHistoryRecord } from '@/api/types'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const records = ref<AccountHistoryRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
async function fetchHistory() {
if (!props.account) return
loading.value = true
const result = await store.fetchHistory(props.account.id, page.value, pageSize)
records.value = result.records
total.value = result.total
loading.value = false
}
watch(() => props.visible, (val) => {
if (val && props.account) {
page.value = 1
fetchHistory()
}
})
function handlePageChange(newPage: number) {
page.value = newPage
fetchHistory()
}
function formatAmount(amount: number): string {
if (amount > 0) return `+${amount.toFixed(2)}`
return amount.toFixed(2)
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="account ? `${account.name} - 变更历史` : '变更历史'"
width="600px"
@close="handleClose"
class="history-dialog"
>
<div class="history-content">
<div v-if="loading" class="loading-state">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="records.length === 0" class="empty-state">
<el-icon :size="40" color="#C8A2C8"><Document /></el-icon>
<p>暂无变更记录</p>
</div>
<div v-else class="history-list">
<div
v-for="record in records"
:key="record.id"
class="history-item"
>
<div class="history-left">
<div
class="amount-badge"
:class="{ positive: record.change_amount > 0, negative: record.change_amount < 0 }"
>
<el-icon v-if="record.change_amount > 0"><Top /></el-icon>
<el-icon v-else-if="record.change_amount < 0"><Bottom /></el-icon>
<el-icon v-else><Minus /></el-icon>
<span>{{ formatAmount(record.change_amount) }}</span>
</div>
<div class="history-info">
<span class="history-note">{{ record.note || '未备注' }}</span>
<span class="history-date">{{ formatDate(record.created_at) }}</span>
</div>
</div>
<div class="history-right">
<span class="balance-change">
{{ record.balance_before.toFixed(2) }}
<el-icon :size="12"><Right /></el-icon>
{{ record.balance_after.toFixed(2) }}
</span>
</div>
</div>
</div>
<div v-if="total > pageSize" class="pagination-wrapper">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.history-content {
min-height: 200px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
p {
margin: 0;
font-size: 14px;
}
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px dashed rgba(255, 183, 197, 0.15);
&:last-child {
border-bottom: none;
}
}
.history-left {
display: flex;
align-items: center;
gap: 14px;
}
.amount-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.history-info {
display: flex;
flex-direction: column;
gap: 2px;
.history-note {
font-size: 14px;
color: var(--text-primary);
}
.history-date {
font-size: 12px;
color: var(--text-secondary);
}
}
.history-right {
.balance-change {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
padding-top: 16px;
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { AnniversaryCategory } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editCategory?: AnniversaryCategory | null
}
const props = withDefaults(defineProps<Props>(), {
editCategory: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAnniversaryStore()
const isEdit = computed(() => !!props.editCategory)
const dialogTitle = computed(() => isEdit.value ? '编辑分类' : '新建分类')
const form = ref({
name: '',
icon: 'calendar',
color: '#FFB7C5',
sort_order: 0
})
const presetColors = [
'#FFB7C5', '#FF9AA2', '#FFB347', '#FFDAC1',
'#B5EAD7', '#98D8C8', '#C7CEEA', '#E2F0CB',
'#FF6B6B', '#A86A7A', '#C8A2C8', '#87CEEB'
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editCategory) {
form.value = {
name: props.editCategory.name,
icon: props.editCategory.icon,
color: props.editCategory.color,
sort_order: props.editCategory.sort_order
}
} else {
form.value = {
name: '',
icon: 'calendar',
color: '#FFB7C5',
sort_order: store.categories.length
}
}
}
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入分类名称~')
return
}
const data = {
name: form.value.name.trim(),
icon: form.value.icon,
color: form.value.color,
sort_order: form.value.sort_order
}
if (isEdit.value && props.editCategory) {
const result = await store.updateCategory(props.editCategory.id, data)
if (result) ElMessage.success('分类更新成功~')
} else {
const result = await store.createCategory(data)
if (result) ElMessage.success('分类创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="400px"
@close="handleClose"
class="anniversary-category-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">分类名称</label>
<el-input
v-model="form.name"
placeholder="如:生日、纪念日、节日..."
maxlength="50"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">颜色</label>
<div class="color-picker">
<div
v-for="color in presetColors"
:key="color"
class="color-dot"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
></div>
</div>
</div>
<div class="form-item">
<label class="form-label">排序</label>
<el-input-number
v-model="form.sort_order"
:min="0"
:max="99"
size="default"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
display: block;
}
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
.color-dot {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px rgba(139, 69, 87, 0.2);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { Anniversary } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editAnniversary?: Anniversary | null
}
const props = withDefaults(defineProps<Props>(), {
editAnniversary: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAnniversaryStore()
const isEdit = computed(() => !!props.editAnniversary)
const dialogTitle = computed(() => isEdit.value ? '编辑纪念日' : '新建纪念日')
const form = ref({
title: '',
date: '',
year: null as number | null,
category_id: null as number | null,
description: '',
is_recurring: true,
remind_days_before: 3
})
watch(() => props.visible, (val) => {
if (val) {
if (props.editAnniversary) {
const a = props.editAnniversary
form.value = {
title: a.title,
date: a.date,
year: a.year ?? null,
category_id: a.category_id ?? null,
description: a.description || '',
is_recurring: a.is_recurring,
remind_days_before: a.remind_days_before
}
} else {
form.value = {
title: '',
date: '',
year: null,
category_id: null,
description: '',
is_recurring: true,
remind_days_before: 3
}
}
}
})
async function handleSave() {
if (!form.value.title.trim()) {
ElMessage.warning('请输入纪念日标题~')
return
}
if (!form.value.date) {
ElMessage.warning('请选择纪念日日期~')
return
}
const data = {
title: form.value.title.trim(),
date: form.value.date,
year: form.value.year,
category_id: form.value.category_id,
description: form.value.description.trim() || null,
is_recurring: form.value.is_recurring,
remind_days_before: form.value.remind_days_before
}
if (isEdit.value && props.editAnniversary) {
const result = await store.updateAnniversary(props.editAnniversary.id, data)
if (result) ElMessage.success('纪念日更新成功~')
} else {
const result = await store.createAnniversary(data)
if (result) ElMessage.success('纪念日创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="anniversary-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">纪念日标题</label>
<el-input
v-model="form.title"
placeholder="如:小明的生日、结婚纪念日..."
maxlength="200"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">日期</label>
<el-date-picker
v-model="form.date"
type="date"
placeholder="选择日期"
format="MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
<div class="form-item">
<label class="form-label">
年份
<span class="form-hint">可选用于计算周年</span>
</label>
<el-date-picker
v-model="form.year"
type="year"
placeholder="选择年份(可留空)"
format="YYYY"
value-format="YYYY"
style="width: 100%"
clearable
/>
</div>
<div class="form-item">
<label class="form-label">分类</label>
<el-select
v-model="form.category_id"
placeholder="选择分类(可留空)"
clearable
style="width: 100%"
>
<el-option
v-for="cat in store.categories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
>
<span class="category-option">
<span
class="cat-dot"
:style="{ background: cat.color }"
></span>
{{ cat.name }}
</span>
</el-option>
</el-select>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="500"
/>
</div>
<div class="form-item">
<label class="form-label">每年重复</label>
<el-switch v-model="form.is_recurring" />
</div>
<div v-if="form.is_recurring" class="form-item">
<label class="form-label">提前提醒天数</label>
<el-input-number
v-model="form.remind_days_before"
:min="0"
:max="90"
size="default"
/>
<span class="form-hint" style="margin-left: 8px;"></span>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
.form-hint {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
}
}
.category-option {
display: flex;
align-items: center;
gap: 8px;
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/useUIStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const uiStore = useUIStore()
const userSettingsStore = useUserSettingsStore()
const router = useRouter()
const route = useRoute()
const displayAvatar = computed(() => {
const name = userSettingsStore.nickname
if (!name) return '爱'
return name.charAt(0)
})
function setView(view: string) {
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant')
router.push(`/${view === 'list' ? 'tasks' : view}`)
}
function handleCommand(command: string) {
router.push(`/${command}`)
}
const currentRouteName = computed(() => route.name as string)
</script>
<template>
<header class="app-header">
<div class="header-left">
<el-button
v-if="currentRouteName === 'tasks'"
text
class="menu-btn"
@click="uiStore.toggleSidebar()"
>
<el-icon :size="20">
<Fold v-if="!uiStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
</el-button>
<div class="logo">
<span class="logo-icon"></span>
<span class="logo-text">{{ userSettingsStore.siteName }}</span>
</div>
</div>
<nav class="header-nav">
<button
class="nav-item"
:class="{ active: currentRouteName === 'tasks' }"
@click="setView('list')"
>
<el-icon><List /></el-icon>
<span>列表</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'calendar' }"
@click="setView('calendar')"
>
<el-icon><Calendar /></el-icon>
<span>日历</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'quadrant' }"
@click="setView('quadrant')"
>
<el-icon><Grid /></el-icon>
<span>四象限</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'habits' }"
@click="router.push('/habits')"
>
<el-icon><Flag /></el-icon>
<span>习惯</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'anniversaries' }"
@click="router.push('/anniversaries')"
>
<el-icon><Cherry /></el-icon>
<span>纪念日</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'assets' }"
@click="router.push('/assets')"
>
<el-icon><Wallet /></el-icon>
<span>资产</span>
</button>
</nav>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<div class="avatar-btn" @click.stop>
<el-avatar
v-if="userSettingsStore.avatar"
:size="36"
:src="userSettingsStore.avatar"
class="user-avatar"
/>
<el-avatar
v-else
:size="36"
class="user-avatar default-avatar"
>
{{ displayAvatar }}
</el-avatar>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人信息</span>
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>
<span>偏好设置</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<style scoped lang="scss">
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: white;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 1000;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 200px;
.logo {
display: flex;
align-items: center;
gap: 8px;
.logo-icon {
font-size: 28px;
color: var(--primary);
animation: twinkle 2s ease-in-out infinite;
}
.logo-text {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.menu-btn {
color: var(--text-secondary);
&:hover {
color: var(--primary);
}
}
}
.header-nav {
display: flex;
align-items: center;
gap: 8px;
background: var(--background);
padding: 4px;
border-radius: var(--radius-lg);
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border: none;
background: transparent;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.2);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
.el-icon {
font-size: 16px;
}
}
}
.header-right {
min-width: 200px;
display: flex;
justify-content: flex-end;
align-items: center;
.avatar-btn {
cursor: pointer;
display: flex;
align-items: center;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.08);
}
}
.user-avatar {
border: 2px solid var(--primary);
transition: box-shadow 0.2s ease;
}
.avatar-btn:hover .user-avatar {
box-shadow: 0 0 0 3px rgba(255, 183, 197, 0.3);
}
.default-avatar {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
font-size: 16px;
font-weight: 600;
}
}
@keyframes twinkle {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const newBalance = ref(0)
const note = ref('')
const saving = ref(false)
const changeAmount = computed(() => {
if (!props.account) return 0
return Math.round((newBalance.value - props.account.balance) * 100) / 100
})
const changeText = computed(() => {
const diff = changeAmount.value
if (diff > 0) return `+${diff.toFixed(2)}`
if (diff < 0) return diff.toFixed(2)
return '0.00'
})
const isPositive = computed(() => changeAmount.value >= 0)
watch(() => props.visible, (val) => {
if (val && props.account) {
newBalance.value = props.account.balance
note.value = ''
saving.value = false
}
})
async function handleSave() {
if (!props.account) return
saving.value = true
try {
const result = await store.updateBalance(props.account.id, {
new_balance: newBalance.value,
note: note.value.trim() || null
})
if (result) {
ElMessage.success('余额更新成功~')
emit('update:visible', false)
}
} finally {
saving.value = false
}
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
title="更新余额"
width="420px"
@close="handleClose"
class="balance-dialog"
>
<div class="form-content">
<div v-if="account" class="balance-preview">
<span class="current-label">{{ account.name }}</span>
<span class="current-balance">{{ account.balance.toFixed(2) }}</span>
</div>
<div class="change-indicator" :class="{ positive: isPositive, negative: !isPositive }">
<el-icon><Top v-if="isPositive" /><Bottom v-else /></el-icon>
<span>{{ changeText }}</span>
</div>
<div class="form-item">
<label class="form-label">新余额</label>
<el-input-number
v-model="newBalance"
:precision="2"
:step="100"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="note"
placeholder="如:工资到账、还花呗、日常消费..."
maxlength="200"
show-word-limit
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">
确认更新
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.balance-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 183, 197, 0.08);
border-radius: var(--radius-md);
.current-label {
font-size: 14px;
color: var(--text-secondary);
}
.current-balance {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
}
.change-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.form-item {
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useUIStore } from '@/stores/useUIStore'
import type { CategoryFormData } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
const categoryStore = useCategoryStore()
const uiStore = useUIStore()
const form = ref<CategoryFormData>({
name: '',
color: '#FFB7C5',
icon: 'Folder'
})
const formRef = ref()
const loading = ref(false)
const isEditMode = computed(() => !!uiStore.editingCategory)
const dialogTitle = computed(() => isEditMode.value ? '编辑分类' : '新建分类')
const submitButtonText = computed(() => isEditMode.value ? '保存' : '创建')
const colorOptions = [
'#FFB7C5', '#FFC0CB', '#C8A2C8', '#98D8C8',
'#FFB347', '#FF6B6B', '#87CEEB', '#DDA0DD'
]
const iconOptions = [
'Folder', 'Document', 'FolderOpened', 'Notebook',
'Star', 'Sunny', 'Moon', 'Flag',
'Calendar', 'Clock', 'Bell', 'AlarmClock',
'House', 'Briefcase', 'ShoppingCart', 'Goods',
'Coffee', 'Goblet', 'Dish', 'IceCream',
'Headset', 'VideoCamera', 'Camera', 'Picture',
'Present', 'Trophy', 'Medal', 'Cherry'
]
const rules = {
name: [
{ required: true, message: '请输入分类名称~', trigger: 'blur' },
{ min: 1, max: 100, message: '名称长度在 1 到 100 个字符~', trigger: 'blur' }
]
}
watch(() => uiStore.categoryDialogVisible, (visible) => {
if (visible) {
if (uiStore.editingCategory) {
form.value = {
name: uiStore.editingCategory.name,
color: uiStore.editingCategory.color,
icon: uiStore.editingCategory.icon
}
} else {
form.value = {
name: '',
color: '#FFB7C5',
icon: 'Folder'
}
}
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
loading.value = true
try {
if (isEditMode.value && uiStore.editingCategory) {
await categoryStore.updateCategory(uiStore.editingCategory.id, form.value)
ElMessage.success('分类更新成功~')
} else {
await categoryStore.createCategory(form.value)
ElMessage.success('分类创建成功~')
}
uiStore.closeCategoryDialog()
} finally {
loading.value = false
}
})
}
function handleClose() {
uiStore.closeCategoryDialog()
}
</script>
<template>
<el-dialog
v-model="uiStore.categoryDialogVisible"
:title="dialogTitle"
width="400px"
:close-on-click-modal="false"
class="category-dialog"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="category-form"
@submit.prevent="handleSubmit"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称~"
maxlength="100"
/>
</el-form-item>
<el-form-item label="选择颜色">
<div class="color-picker">
<div
v-for="color in colorOptions"
:key="color"
class="color-option"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</el-form-item>
<el-form-item label="选择图标">
<div class="icon-picker">
<div
v-for="icon in iconOptions"
:key="icon"
class="icon-option"
:class="{ active: form.icon === icon }"
@click="form.icon = icon"
>
<el-icon :size="20"><component :is="icon" /></el-icon>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
>
{{ submitButtonText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.category-form {
padding: 8px 0;
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.color-option {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-fast);
border: 2px solid transparent;
&:hover {
transform: scale(1.1);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--primary);
}
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
background: var(--background);
color: var(--text-primary);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
&.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(255, 183, 197, 0.4);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,698 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Category, Tag } from '@/api/types'
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const statusFilters = [
{ label: '全部任务', value: 'all', icon: 'List' },
{ label: '进行中', value: 'active', icon: 'Clock' },
{ label: '已完成', value: 'completed', icon: 'CircleCheck' }
]
const currentStatus = computed(() => taskStore.filters.status)
const currentCategoryId = computed(() => taskStore.filters.category_id)
const categoryCollapsed = ref(false)
const tagCollapsed = ref(false)
function setStatusFilter(status: 'all' | 'active' | 'completed') {
taskStore.setFilters({ status })
}
function setCategoryFilter(categoryId?: number) {
taskStore.setFilters({ category_id: categoryId })
}
function handleEditCategory(category: Category, event: Event) {
event.stopPropagation()
uiStore.openCategoryDialog(category)
}
async function handleDeleteCategory(category: Category, event: Event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除分类「${category.name}」吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const success = await categoryStore.deleteCategory(category.id)
if (success) {
ElMessage.success('分类删除成功~')
if (currentCategoryId.value === category.id) {
taskStore.setFilters({ category_id: undefined })
}
} else {
ElMessage.error('该分类下存在关联任务,无法删除~')
}
} catch {
// 用户取消删除
}
}
async function handleAddTag() {
try {
const { value } = await ElMessageBox.prompt(
'请输入标签名称~',
'新建标签',
{
confirmButtonText: '创建',
cancelButtonText: '取消',
inputPlaceholder: '标签名称',
inputPattern: /\S+/,
inputErrorMessage: '标签名称不能为空哦~'
}
)
const name = (value as string).trim()
if (!name) return
const newTag = await tagStore.createTag({ name })
if (newTag) {
ElMessage.success(`标签「${name}」创建成功~`)
} else {
ElMessage.error('标签创建失败,可能已存在同名标签~')
}
} catch {
// 用户取消
}
}
async function handleDeleteTag(tag: Tag, event: Event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除标签「${tag.name}」吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const success = await tagStore.deleteTag(tag.id)
if (success) {
ElMessage.success('标签已删除~')
}
} catch {
// 用户取消
}
}
</script>
<template>
<aside class="sidebar" :class="{ collapsed: uiStore.sidebarCollapsed }">
<div class="sidebar-scroll">
<div class="sidebar-section">
<h3 class="section-title">任务筛选</h3>
<ul class="filter-list">
<li
v-for="filter in statusFilters"
:key="filter.value"
class="filter-item"
:class="{ active: currentStatus === filter.value && !currentCategoryId }"
@click="setStatusFilter(filter.value as 'all' | 'active' | 'completed')"
>
<el-icon><component :is="filter.icon" /></el-icon>
<span>{{ filter.label }}</span>
<span class="count">
{{ filter.value === 'all' ? taskStore.totalTasks.length :
filter.value === 'active' ? taskStore.activeTasks.length :
taskStore.completedTasks.length }}
</span>
</li>
</ul>
</div>
<div class="sidebar-section">
<div class="section-header clickable" @click="categoryCollapsed = !categoryCollapsed">
<div class="section-title-row">
<h3 class="section-title no-margin">分类</h3>
<el-icon class="collapse-icon" :class="{ rotated: categoryCollapsed }"><ArrowRight /></el-icon>
</div>
<div class="section-actions" v-show="!categoryCollapsed" @click.stop>
<el-button
v-if="currentCategoryId"
text
size="small"
class="clear-filter-btn"
@click="setCategoryFilter()"
>
<el-icon><Close /></el-icon>
<span>清除</span>
</el-button>
<el-button
text
size="small"
class="add-category-btn"
@click="uiStore.openCategoryDialog()"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
<Transition name="collapse">
<ul v-show="!categoryCollapsed" class="category-list">
<li
v-for="category in categoryStore.categories"
:key="category.id"
class="category-item"
:class="{ active: currentCategoryId === category.id }"
@click="setCategoryFilter(category.id)"
>
<span class="category-dot" :style="{ background: category.color }"></span>
<span class="category-name">{{ category.name }}</span>
<div class="category-actions">
<el-button
text
size="small"
class="action-btn"
@click="handleEditCategory(category, $event)"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
text
size="small"
class="action-btn delete-btn"
@click="handleDeleteCategory(category, $event)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</li>
</ul>
</Transition>
</div>
<div class="sidebar-section">
<div class="section-header clickable" @click="tagCollapsed = !tagCollapsed">
<div class="section-title-row">
<h3 class="section-title no-margin">标签</h3>
<el-icon class="collapse-icon" :class="{ rotated: tagCollapsed }"><ArrowRight /></el-icon>
</div>
<el-button
v-show="!tagCollapsed"
text
size="small"
class="add-category-btn"
@click.stop="handleAddTag"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<Transition name="collapse">
<div v-show="!tagCollapsed">
<ul v-if="tagStore.tags.length > 0" class="tag-list">
<li
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-item"
>
<span class="tag-icon">#</span>
<span class="tag-name">{{ tag.name }}</span>
<el-button
text
size="small"
class="tag-delete-btn"
@click="handleDeleteTag(tag, $event)"
>
<el-icon><Delete /></el-icon>
</el-button>
</li>
</ul>
<p v-else class="tag-empty">还没有标签创建一个吧~</p>
</div>
</Transition>
</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-section" style="margin-bottom: 0;">
<h3 class="section-title">排序</h3>
<div class="sort-options">
<el-select
:model-value="taskStore.filters.sort_by"
placeholder="排序方式"
@change="taskStore.setFilters({ sort_by: $event })"
>
<el-option label="优先级" value="priority" />
<el-option label="截止日期" value="due_date" />
<el-option label="创建时间" value="created_at" />
</el-select>
<el-button
:icon="taskStore.filters.sort_order === 'asc' ? 'ArrowUp' : 'ArrowDown'"
@click="taskStore.setFilters({ sort_order: taskStore.filters.sort_order === 'asc' ? 'desc' : 'asc' })"
/>
</div>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.sidebar {
position: fixed;
top: 60px;
left: 0;
width: 240px;
height: calc(100vh - 60px);
background: white;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.15);
display: flex;
flex-direction: column;
z-index: 100;
will-change: transform;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.collapsed {
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@media (max-width: 768px) {
transform: translateX(0);
}
}
@media (max-width: 768px) {
width: 200px;
}
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
min-height: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--background-dark);
border-radius: 2px;
}
}
.sidebar-footer {
flex-shrink: 0;
padding: 16px;
border-top: 1px solid var(--background-dark);
}
.sidebar-section {
margin-bottom: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
&.no-margin {
margin-bottom: 0;
}
}
.section-title-row {
display: flex;
align-items: center;
gap: 4px;
}
.collapse-icon {
font-size: 12px;
color: var(--text-secondary);
transition: transform 0.25s ease;
&.rotated {
transform: rotate(0deg);
}
&:not(.rotated) {
transform: rotate(90deg);
}
}
.section-header.clickable {
cursor: pointer;
user-select: none;
&:hover {
.section-title-row {
.section-title {
color: var(--primary);
}
}
.collapse-icon {
color: var(--primary);
}
}
}
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
}
.collapse-enter-to,
.collapse-leave-from {
opacity: 1;
max-height: 500px;
}
.section-actions {
display: flex;
align-items: center;
gap: 4px;
}
.add-category-btn {
color: var(--text-secondary);
font-size: 16px;
&:hover {
color: var(--primary);
}
}
.clear-filter-btn {
color: #f56c6c;
font-size: 12px;
padding: 2px 6px;
height: auto;
display: flex;
align-items: center;
gap: 2px;
span {
font-weight: 500;
}
&:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
}
.filter-list,
.category-list {
list-style: none;
}
.filter-item,
.category-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--text-primary);
&:hover {
background: var(--background);
}
&.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
font-weight: 500;
.count {
background: rgba(255, 255, 255, 0.3);
color: white;
}
}
.count {
margin-left: auto;
font-size: 12px;
padding: 2px 8px;
background: var(--background-dark);
border-radius: 10px;
color: var(--text-secondary);
}
}
.category-item {
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
.action-btn {
padding: 4px;
min-width: 24px;
height: 24px;
color: var(--text-secondary);
&:hover {
color: var(--primary);
background: rgba(255, 114, 159, 0.1);
}
&.delete-btn:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
}
}
&:hover .category-actions {
opacity: 1;
}
&.active .category-actions .action-btn {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: white;
background: rgba(255, 255, 255, 0.2);
}
&.delete-btn:hover {
color: white;
background: rgba(245, 108, 108, 0.3);
}
}
}
.tag-list {
list-style: none;
}
.tag-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
color: var(--text-primary);
&:hover {
background: var(--background);
.tag-delete-btn {
opacity: 1;
}
}
.tag-icon {
font-size: 14px;
font-weight: 700;
color: var(--primary);
flex-shrink: 0;
}
.tag-name {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-delete-btn {
opacity: 0;
transition: opacity var(--transition-fast);
padding: 2px;
min-width: 20px;
height: 20px;
color: var(--text-secondary);
&:hover {
color: #f56c6c;
}
}
}
.tag-empty {
font-size: 12px;
color: var(--text-secondary);
text-align: center;
padding: 8px 0;
}
.sort-options {
display: flex;
gap: 8px;
:deep(.el-select) {
flex: 1;
.el-select__wrapper {
background: var(--background);
border: 1px solid var(--background-dark);
border-radius: var(--radius-md);
box-shadow: none;
transition: all var(--transition-fast);
padding: 6px 12px;
min-height: 36px;
&:hover {
background: white;
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.15);
}
&.is-focused {
background: white;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255, 114, 159, 0.15), 0 2px 8px rgba(255, 183, 197, 0.2);
}
.el-select__selected-item {
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
}
.el-select__suffix {
color: var(--text-secondary);
transition: transform var(--transition-fast);
.el-select__suffix-inner {
transition: transform var(--transition-fast);
}
}
&.is-focused .el-select__suffix {
color: var(--primary);
.el-select__suffix-inner {
transform: rotate(180deg);
}
}
}
:deep(.el-select-dropdown) {
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
border: 1px solid var(--background-dark);
.el-select-dropdown__item {
color: var(--text-primary);
font-size: 14px;
padding: 8px 16px;
transition: all var(--transition-fast);
&:hover {
background: var(--background);
}
&.is-selected {
color: var(--primary);
font-weight: 600;
background: rgba(255, 114, 159, 0.08);
&::after {
content: '✓';
position: absolute;
right: 12px;
color: var(--primary);
font-weight: bold;
}
}
}
}
:deep(.el-button) {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border: none;
border-radius: var(--radius-md);
color: white;
transition: all var(--transition-fast);
min-width: 36px;
padding: 6px;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:active {
transform: translateY(0);
}
.el-icon {
font-size: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useHabitStore } from '@/stores/useHabitStore'
import type { Habit } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editHabit?: Habit | null
}
const props = withDefaults(defineProps<Props>(), {
editHabit: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const habitStore = useHabitStore()
const isEdit = computed(() => !!props.editHabit)
const dialogTitle = computed(() => isEdit.value ? '编辑习惯' : '新建习惯')
const form = ref({
name: '',
description: '',
group_id: null as number | null,
target_count: 1,
frequency: 'daily' as 'daily' | 'weekly',
active_days: [] as number[]
})
const weekDays = [
{ value: 0, label: '一' },
{ value: 1, label: '二' },
{ value: 2, label: '三' },
{ value: 3, label: '四' },
{ value: 4, label: '五' },
{ value: 5, label: '六' },
{ value: 6, label: '日' },
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editHabit) {
form.value = {
name: props.editHabit.name,
description: props.editHabit.description || '',
group_id: props.editHabit.group_id ?? null,
target_count: props.editHabit.target_count,
frequency: props.editHabit.frequency,
active_days: props.editHabit.active_days
? JSON.parse(props.editHabit.active_days)
: []
}
} else {
form.value = {
name: '',
description: '',
group_id: null,
target_count: 1,
frequency: 'daily',
active_days: []
}
}
}
})
function toggleDay(day: number) {
const idx = form.value.active_days.indexOf(day)
if (idx >= 0) {
form.value.active_days.splice(idx, 1)
} else {
form.value.active_days.push(day)
}
form.value.active_days.sort()
}
function selectAllDays() {
if (form.value.active_days.length === 7) {
form.value.active_days = []
} else {
form.value.active_days = [0, 1, 2, 3, 4, 5, 6]
}
}
const activeDaysLabel = computed(() => {
if (form.value.active_days.length === 0) return '请选择'
if (form.value.active_days.length === 7) return '每天'
return form.value.active_days.map(d => `${weekDays[d].label}`).join('、')
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入习惯名称~')
return
}
if (form.value.frequency === 'weekly' && form.value.active_days.length === 0) {
ElMessage.warning('请至少选择一天~')
return
}
const data = {
name: form.value.name.trim(),
description: form.value.description.trim() || undefined,
group_id: form.value.group_id,
target_count: form.value.target_count,
frequency: form.value.frequency,
active_days: form.value.frequency === 'weekly'
? JSON.stringify(form.value.active_days)
: null
}
if (isEdit.value && props.editHabit) {
const result = await habitStore.updateHabit(props.editHabit.id, data)
if (result) ElMessage.success('习惯更新成功~')
} else {
const result = await habitStore.createHabit(data)
if (result) ElMessage.success('习惯创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="habit-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">习惯名称</label>
<el-input
v-model="form.name"
placeholder="如:跑步、阅读、冥想..."
maxlength="50"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">备注描述</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="200"
/>
</div>
<div class="form-item">
<label class="form-label">所属分组</label>
<el-select
v-model="form.group_id"
placeholder="选择分组(可留空)"
clearable
style="width: 100%"
>
<el-option
v-for="group in habitStore.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</div>
<div class="form-item">
<label class="form-label">每日目标次数</label>
<div class="count-control">
<el-button
circle
size="small"
:disabled="form.target_count <= 1"
@click="form.target_count--"
>
<el-icon><Minus /></el-icon>
</el-button>
<span class="count-value">{{ form.target_count }}</span>
<el-button
circle
size="small"
:disabled="form.target_count >= 99"
@click="form.target_count++"
>
<el-icon><Plus /></el-icon>
</el-button>
<span class="count-hint">/</span>
</div>
</div>
<div class="form-item">
<label class="form-label">打卡频率</label>
<el-radio-group v-model="form.frequency">
<el-radio value="daily">每天</el-radio>
<el-radio value="weekly">每周特定几天</el-radio>
</el-radio-group>
</div>
<div v-if="form.frequency === 'weekly'" class="form-item days-selector">
<label class="form-label">
选择打卡日
<el-button type="primary" link size="small" @click="selectAllDays">
{{ form.active_days.length === 7 ? '清除全选' : '全选' }}
</el-button>
</label>
<div class="week-days">
<div
v-for="day in weekDays"
:key="day.value"
class="day-btn"
:class="{ active: form.active_days.includes(day.value) }"
@click="toggleDay(day.value)"
>
{{ day.label }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.count-control {
display: flex;
align-items: center;
gap: 16px;
.count-value {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
min-width: 36px;
text-align: center;
}
.count-hint {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
}
.days-selector {
.form-label {
justify-content: space-between;
}
}
.week-days {
display: flex;
gap: 8px;
flex-wrap: wrap;
.day-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: var(--background);
transition: all 0.2s ease;
user-select: none;
&:hover {
color: var(--primary);
background: rgba(255, 183, 197, 0.15);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,468 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useHabitStore } from '@/stores/useHabitStore'
import type { HabitGroup } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const habitStore = useHabitStore()
const mode = ref<'manage' | 'edit' | 'create'>('manage')
const editingGroup = ref<HabitGroup | null>(null)
const form = ref({
name: '',
color: '#FFB7C5',
icon: 'flag'
})
const colors = [
'#FFB7C5', '#FF6B6B', '#FFB347', '#FFD93D',
'#98D8C8', '#6BC5D2', '#C8A2C8', '#A8E6CF'
]
const iconOptions = [
{ value: 'flag', label: '旗帜' },
{ value: 'star', label: '星星' },
{ value: 'sunny', label: '太阳' },
{ value: 'trophy', label: '奖杯' },
{ value: 'medal', label: '奖牌' },
{ value: 'apple', label: '苹果' },
{ value: 'cherry', label: '樱桃' },
{ value: 'grape', label: '葡萄' },
]
const dialogTitle = computed(() => {
if (mode.value === 'create') return '新建分组'
if (mode.value === 'edit') return '编辑分组'
return '管理分组'
})
const dialogWidth = computed(() => {
if (mode.value === 'manage') return '480px'
return '420px'
})
watch(() => props.visible, (val) => {
if (val) {
mode.value = 'manage'
editingGroup.value = null
}
})
function openCreate() {
mode.value = 'create'
editingGroup.value = null
form.value = { name: '', color: '#FFB7C5', icon: 'flag' }
}
function openEdit(group: HabitGroup) {
mode.value = 'edit'
editingGroup.value = group
form.value = {
name: group.name,
color: group.color,
icon: group.icon
}
}
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入分组名称~')
return
}
if (mode.value === 'edit' && editingGroup.value) {
const result = await habitStore.updateGroup(editingGroup.value.id, form.value)
if (result) {
ElMessage.success('分组更新成功~')
mode.value = 'manage'
}
} else {
const result = await habitStore.createGroup(form.value)
if (result) {
ElMessage.success('分组创建成功~')
mode.value = 'manage'
}
}
}
async function handleDeleteGroup(group: HabitGroup) {
try {
await ElMessageBox.confirm(
`删除分组「${group.name}」后,其中的习惯会变为未分组状态~`,
'确认删除',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await habitStore.deleteGroup(group.id)
if (success) ElMessage.success('分组已删除~')
} catch {
// 用户取消
}
}
function goBack() {
mode.value = 'manage'
editingGroup.value = null
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
:width="dialogWidth"
@close="handleClose"
class="habit-group-dialog"
>
<!-- 管理分组列表 -->
<div v-if="mode === 'manage'" class="manage-mode">
<div v-if="habitStore.groups.length === 0" class="no-groups">
<p>还没有分组呢创建一个吧~</p>
</div>
<div v-else class="group-list">
<div
v-for="group in habitStore.groups"
:key="group.id"
class="group-item"
>
<div class="group-item-left">
<div class="group-item-icon" :style="{ background: group.color + '22', color: group.color }">
<el-icon :size="18">
<component :is="group.icon || 'Flag'" />
</el-icon>
</div>
<div class="group-item-info">
<span class="group-item-name">{{ group.name }}</span>
<span class="group-item-count">{{ habitStore.habits.filter(h => h.group_id === group.id).length }} 个习惯</span>
</div>
</div>
<div class="group-item-actions">
<button class="icon-btn" title="编辑" @click="openEdit(group)">
<el-icon :size="16"><Edit /></el-icon>
</button>
<button class="icon-btn icon-btn--danger" title="删除" @click="handleDeleteGroup(group)">
<el-icon :size="16"><Delete /></el-icon>
</button>
</div>
</div>
</div>
<button class="add-group-btn" @click="openCreate">
<el-icon :size="18"><Plus /></el-icon>
<span>新建分组</span>
</button>
</div>
<!-- 创建/编辑分组 -->
<div v-else class="form-content">
<button class="back-btn" @click="goBack">
<el-icon :size="16"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="form-item">
<label class="form-label">分组名称</label>
<el-input
v-model="form.name"
placeholder="如:运动、学习、阅读..."
maxlength="20"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">颜色</label>
<div class="color-picker">
<div
v-for="color in colors"
:key="color"
class="color-dot"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">图标</label>
<div class="icon-picker">
<div
v-for="icon in iconOptions"
:key="icon.value"
class="icon-item"
:class="{ active: form.icon === icon.value }"
:title="icon.label"
@click="form.icon = icon.value"
>
<el-icon :size="20">
<component :is="icon.value" />
</el-icon>
<span class="icon-label">{{ icon.label }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<div v-if="mode === 'manage'" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
<div v-else class="dialog-footer">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ mode === 'edit' ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
// 管理模式
.manage-mode {
display: flex;
flex-direction: column;
gap: 12px;
}
.no-groups {
text-align: center;
padding: 20px;
color: var(--text-secondary);
}
.group-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--background);
transition: all 0.2s ease;
&:hover {
background: rgba(255, 183, 197, 0.1);
.group-item-actions {
opacity: 1;
}
}
.group-item-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.group-item-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.group-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.group-item-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.group-item-count {
font-size: 12px;
color: var(--text-secondary);
}
.group-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
}
.icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 183, 197, 0.15);
color: var(--text-primary);
}
&--danger:hover {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
}
.add-group-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px dashed var(--secondary);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(255, 183, 197, 0.05);
}
}
// 表单模式
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
border-radius: 8px;
margin-bottom: 16px;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.1);
}
}
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.color-picker {
display: flex;
gap: 12px;
flex-wrap: wrap;
.color-dot {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 3px rgba(139, 69, 87, 0.2);
}
}
}
.icon-picker {
display: flex;
gap: 8px;
flex-wrap: wrap;
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 56px;
padding: 8px 4px 6px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
background: var(--background);
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.15);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
}
.icon-label {
font-size: 11px;
white-space: nowrap;
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { DebtInstallment, FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editInstallment?: DebtInstallment | null
accountId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
editInstallment: null,
accountId: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editInstallment)
const dialogTitle = computed(() => isEdit.value ? '编辑分期计划' : '新建分期计划')
const form = ref({
account_id: null as number | null,
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
})
const debtAccounts = computed(() => store.debtAccounts)
watch(() => props.visible, (val) => {
if (val) {
if (props.editInstallment) {
const inst = props.editInstallment
form.value = {
account_id: inst.account_id,
total_amount: inst.total_amount,
total_periods: inst.total_periods,
current_period: inst.current_period,
payment_day: inst.payment_day,
payment_amount: inst.payment_amount,
start_date: inst.start_date,
is_completed: inst.is_completed
}
} else {
form.value = {
account_id: props.accountId || (debtAccounts.value.length > 0 ? debtAccounts.value[0].id : null),
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
}
}
}
})
watch([() => form.value.total_amount, () => form.value.total_periods], ([amount, periods]) => {
if (!isEdit.value && amount > 0 && periods > 0) {
form.value.payment_amount = Math.round((amount / periods) * 100) / 100
}
})
async function handleSave() {
if (!form.value.account_id) {
ElMessage.warning('请选择关联的欠款账户~')
return
}
if (form.value.total_amount <= 0) {
ElMessage.warning('请输入分期总额~')
return
}
if (form.value.payment_amount <= 0) {
ElMessage.warning('请输入每期还款金额~')
return
}
if (!form.value.start_date) {
ElMessage.warning('请选择首次还款日期~')
return
}
const data = {
account_id: form.value.account_id,
total_amount: form.value.total_amount,
total_periods: form.value.total_periods,
current_period: form.value.current_period,
payment_day: form.value.payment_day,
payment_amount: form.value.payment_amount,
start_date: form.value.start_date,
is_completed: form.value.is_completed
}
if (isEdit.value && props.editInstallment) {
const result = await store.updateInstallment(props.editInstallment.id, data)
if (result) ElMessage.success('分期计划更新成功~')
} else {
const result = await store.createInstallment(data)
if (result) ElMessage.success('分期计划创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="installment-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">关联账户</label>
<el-select
v-model="form.account_id"
placeholder="选择欠款账户"
style="width: 100%"
:disabled="isEdit"
>
<el-option
v-for="acc in debtAccounts"
:key="acc.id"
:label="acc.name"
:value="acc.id"
/>
</el-select>
<div v-if="debtAccounts.length === 0" class="form-hint" style="margin-top: 8px;">
暂无欠款账户请先创建一个欠款类型的账户~
</div>
</div>
<div class="form-item">
<label class="form-label">分期总额</label>
<el-input-number
v-model="form.total_amount"
:precision="2"
:step="500"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item form-row">
<div class="form-col">
<label class="form-label">总期数</label>
<el-input-number
v-model="form.total_periods"
:min="1"
:max="36"
style="width: 100%"
/>
</div>
<div class="form-col">
<label class="form-label">每月还款日</label>
<el-input-number
v-model="form.payment_day"
:min="1"
:max="31"
style="width: 100%"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">每期还款金额</label>
<el-input-number
v-model="form.payment_amount"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
<div v-if="!isEdit && form.total_amount > 0 && form.total_periods > 0" class="form-hint" style="margin-top: 8px;">
自动计算: {{ form.total_amount.toFixed(2) }} / {{ form.total_periods }} = {{ (form.total_amount / form.total_periods).toFixed(2) }}
</div>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">当前期数第几期待还</label>
<el-input-number
v-model="form.current_period"
:min="1"
:max="form.total_periods"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">首次还款日期</label>
<el-date-picker
v-model="form.start_date"
type="date"
placeholder="选择首次还款日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
.form-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.form-row {
display: flex;
gap: 16px;
.form-col {
flex: 1;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import type { TaskResponse } from '@/api/tasks'
import { ElMessageBox, ElMessage } from 'element-plus'
const props = defineProps<{
task: TaskResponse
}>()
const taskStore = useTaskStore()
const uiStore = useUIStore()
const isToggling = ref(false)
const isDeleting = ref(false)
const isAnimating = ref(false)
const showStars = ref(false)
const starPositions = [
{ x: -10, y: -10, delay: 0 },
{ x: 50, y: -15, delay: 50 },
{ x: 100, y: -10, delay: 100 },
{ x: -15, y: 30, delay: 75 },
{ x: 105, y: 35, delay: 125 }
]
const formattedDueDate = computed(() => {
if (!props.task.due_date) return null
const date = new Date(props.task.due_date)
const now = new Date()
const diff = date.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) return { text: '已过期', class: 'overdue' }
if (days === 0) return { text: '今天截止', class: 'today' }
if (days === 1) return { text: '明天截止', class: 'tomorrow' }
return { text: `${date.getMonth() + 1}/${date.getDate()}`, class: '' }
})
const categoryColor = computed(() => props.task.category?.color)
const categoryName = computed(() => props.task.category?.name)
const tags = computed(() => props.task.tags ?? [])
async function handleToggle() {
if (isToggling.value) return
if (!props.task.is_completed) {
isAnimating.value = true
showStars.value = true
await new Promise(r => setTimeout(r, 500))
isAnimating.value = false
setTimeout(() => {
showStars.value = false
}, 600)
}
isToggling.value = true
try {
const result = await taskStore.toggleTask(props.task.id)
if (result && result.is_completed) {
ElMessage.success({
message: '太棒了!任务完成啦~',
duration: 2000
})
}
} finally {
isToggling.value = false
}
}
function handleEdit() {
uiStore.openTaskDialog(props.task)
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'确定要删除这个任务吗?删除后可就找不回来了呢~',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
isDeleting.value = true
const success = await taskStore.deleteTask(props.task.id)
if (success) {
ElMessage.success('任务已删除')
}
} catch {
// 用户取消
} finally {
isDeleting.value = false
}
}
</script>
<template>
<div
class="quadrant-task-card"
:class="{
completed: task.is_completed,
'task-complete': isAnimating
}"
>
<div
v-if="showStars"
v-for="star in starPositions"
:key="`star-${star.delay}-${star.x}`"
class="star-burst"
:style="{
left: star.x + '%',
top: star.y + '%',
animationDelay: star.delay + 'ms'
}"
/>
<div class="card-content">
<el-checkbox
:model-value="task.is_completed"
:loading="isToggling"
size="small"
class="task-checkbox"
@change="handleToggle"
/>
<div class="task-info">
<div class="task-title-row">
<h4
class="task-title"
:class="{ 'line-through': task.is_completed }"
>
{{ task.title }}
</h4>
<span
v-if="formattedDueDate"
class="task-due"
:class="formattedDueDate.class"
>
<el-icon><Calendar /></el-icon>
{{ formattedDueDate.text }}
</span>
<el-button
text
size="small"
class="action-btn edit-btn"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
</el-button>
</div>
<div class="task-bottom-row">
<div v-if="categoryName || tags.length > 0" class="task-tags">
<span
v-if="categoryName"
class="meta-chip category-chip"
:style="{ '--chip-color': categoryColor }"
>
{{ categoryName }}
</span>
<span
v-for="tag in tags"
:key="tag.id"
class="meta-chip tag-chip"
>
#{{ tag.name }}
</span>
</div>
<el-button
text
size="small"
class="action-btn delete-btn"
:loading="isDeleting"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.quadrant-task-card {
position: relative;
padding: 10px 12px;
background: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: flex;
align-items: flex-start;
gap: 16px;
transition: all var(--transition-normal);
border-left: 3px solid var(--quadrant-color, var(--priority-q4));
overflow: hidden;
&:hover {
box-shadow: var(--shadow-md);
}
&.completed {
opacity: 0.6;
background: var(--background-dark);
.task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
}
}
.task-checkbox {
flex-shrink: 0;
margin-right: 8px;
:deep(.el-checkbox__inner) {
border-radius: 50%;
border-color: var(--primary);
width: 18px;
height: 18px;
&::after {
height: 8px;
width: 4px;
}
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: var(--success);
border-color: var(--success);
}
:deep(.el-checkbox__label) {
display: none;
}
}
.card-content {
display: flex;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.task-info {
flex: 1;
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
&.line-through {
text-decoration: line-through;
}
}
.task-due {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
padding: 2px 6px;
background: var(--background-dark);
border-radius: 4px;
flex-shrink: 0;
&.overdue {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
&.today {
background: rgba(255, 179, 71, 0.15);
color: var(--warning);
}
&.tomorrow {
background: rgba(152, 216, 200, 0.15);
color: var(--success);
}
}
.task-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.task-bottom-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.meta-chip {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
line-height: 1.5;
}
.category-chip {
background: color-mix(in srgb, var(--chip-color, #ccc) 15%, transparent);
color: var(--chip-color, var(--text-secondary));
font-weight: 500;
}
.tag-chip {
background: var(--background-dark);
color: var(--text-secondary);
}
.action-btn {
color: var(--text-secondary);
padding: 4px;
min-height: auto;
flex-shrink: 0;
&:hover {
color: var(--primary);
}
&.delete-btn:hover {
color: var(--danger);
}
}
.star-burst {
position: absolute;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
animation: starBurst 0.6s ease forwards;
}
@keyframes starBurst {
0% {
opacity: 1;
transform: scale(0) translate(0, 0);
}
50% {
opacity: 1;
transform: scale(1) translate(10px, -10px);
}
100% {
opacity: 0;
transform: scale(0.5) translate(20px, -20px);
}
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import type { Task } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
import { highlightMatch } from '@/utils/pinyin'
import { getPriorityConfig } from '@/utils/priority'
const props = defineProps<{
task: Task
}>()
const taskStore = useTaskStore()
const uiStore = useUIStore()
const isToggling = ref(false)
const isDeleting = ref(false)
const isAnimating = ref(false)
const showStars = ref(false)
// 星星位置配置
const starPositions = [
{ x: -10, y: -10, delay: 0 },
{ x: 50, y: -15, delay: 50 },
{ x: 100, y: -10, delay: 100 },
{ x: -15, y: 30, delay: 75 },
{ x: 105, y: 35, delay: 125 }
]
const priority = computed(() => getPriorityConfig(props.task.priority))
const formattedDueDate = computed(() => {
if (!props.task.due_date) return null
const date = new Date(props.task.due_date)
const now = new Date()
const diff = date.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) return { text: '已过期', class: 'overdue' }
if (days === 0) return { text: '今天截止', class: 'today' }
if (days === 1) return { text: '明天截止', class: 'tomorrow' }
return { text: `${date.getMonth() + 1}/${date.getDate()}`, class: '' }
})
const categoryColor = computed(() => props.task.category?.color || 'var(--primary)')
// 搜索关键词
const searchKeyword = computed(() => taskStore.filters.search || '')
// 高亮后的标题和描述
const highlightedTitle = computed(() => {
if (!searchKeyword.value.trim()) return props.task.title
return highlightMatch(props.task.title, searchKeyword.value)
})
const highlightedDescription = computed(() => {
if (!searchKeyword.value.trim() || !props.task.description) return props.task.description
return highlightMatch(props.task.description, searchKeyword.value)
})
async function handleToggle() {
if (isToggling.value) return
// 如果当前任务未完成,先播放动画
if (!props.task.is_completed) {
isAnimating.value = true
showStars.value = true
// 等待动画播放
await new Promise(r => setTimeout(r, 500))
isAnimating.value = false
// 延迟隐藏星星,让动画播放完毕
setTimeout(() => {
showStars.value = false
}, 600)
}
isToggling.value = true
try {
const result = await taskStore.toggleTask(props.task.id)
if (result && result.is_completed) {
ElMessage.success({
message: '太棒了!任务完成啦~ ✨',
duration: 2000
})
}
} finally {
isToggling.value = false
}
}
function handleEdit() {
uiStore.openTaskDialog(props.task)
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'确定要删除这个任务吗?删除后可就找不回来了呢~',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
isDeleting.value = true
const success = await taskStore.deleteTask(props.task.id)
if (success) {
ElMessage.success('任务已删除')
}
} catch {
// 用户取消
} finally {
isDeleting.value = false
}
}
</script>
<template>
<div
class="task-card card"
:class="{
completed: task.is_completed,
'task-complete': isAnimating
}"
>
<!-- 星星飞散效果 -->
<div
v-if="showStars"
v-for="(star, index) in starPositions"
:key="index"
class="star-burst"
:style="{
left: star.x + '%',
top: star.y + '%',
animationDelay: star.delay + 'ms'
}"
/>
<div class="card-header">
<el-checkbox
:model-value="task.is_completed"
:loading="isToggling"
size="large"
class="task-checkbox"
@change="handleToggle"
/>
<div class="task-priority" :style="{ background: priority?.color || 'var(--priority-q4)' }">
{{ priority?.label || 'Q4 不重要不紧急' }}
</div>
</div>
<div class="card-body">
<h3
class="task-title"
:class="{ 'line-through': task.is_completed }"
v-html="highlightedTitle"
/>
<p
v-if="task.description"
class="task-description"
v-html="highlightedDescription"
/>
<div class="task-meta">
<span
v-if="task.category"
class="task-category"
:style="{ background: categoryColor }"
>
{{ task.category.name }}
</span>
<span
v-if="formattedDueDate"
class="task-due"
:class="formattedDueDate.class"
>
<el-icon><Calendar /></el-icon>
{{ formattedDueDate.text }}
</span>
</div>
</div>
<div class="card-footer">
<div class="task-actions">
<el-button
text
size="small"
class="action-btn edit-btn"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
text
size="small"
class="action-btn delete-btn"
:loading="isDeleting"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.task-card {
position: relative;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all var(--transition-normal);
cursor: default;
overflow: hidden;
&:hover {
transform: translateY(-4px);
.task-actions {
opacity: 1;
}
}
&.completed {
opacity: 0.7;
background: var(--background-dark);
.task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.task-checkbox {
:deep(.el-checkbox__inner) {
border-radius: 50%;
border-color: var(--primary);
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: var(--success);
border-color: var(--success);
}
}
.task-priority {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
color: white;
text-transform: uppercase;
}
.card-body {
flex: 1;
}
.task-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.line-through {
text-decoration: line-through;
}
}
.task-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.task-category {
font-size: 11px;
padding: 4px 10px;
border-radius: 10px;
color: white;
}
.task-due {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--background-dark);
border-radius: 8px;
&.overdue {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
&.today {
background: rgba(255, 179, 71, 0.15);
color: var(--warning);
}
&.tomorrow {
background: rgba(152, 216, 200, 0.15);
color: var(--success);
}
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.task-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.action-btn {
color: var(--text-secondary);
&:hover {
color: var(--primary);
}
&.delete-btn:hover {
color: var(--danger);
}
}
// 搜索高亮样式
:deep(.search-highlight) {
background: linear-gradient(135deg, rgba(255, 183, 197, 0.4) 0%, rgba(255, 183, 197, 0.6) 100%);
color: var(--primary-dark);
padding: 0 2px;
border-radius: 3px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import type { TaskFormData } from '@/api/types'
import { ElMessage } from 'element-plus'
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const form = ref<TaskFormData>({
title: '',
description: '',
priority: 'q4',
due_date: '',
category_id: undefined
})
const formRef = ref()
const loading = ref(false)
const rules = {
title: [
{ required: true, message: '请输入任务标题~', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符~', trigger: 'blur' }
]
}
const isEdit = computed(() => !!uiStore.editingTask)
const dialogTitle = computed(() => isEdit.value ? '编辑任务' : '新建任务')
watch(() => uiStore.taskDialogVisible, (visible) => {
if (visible) {
if (uiStore.editingTask) {
const task = uiStore.editingTask
form.value = {
title: task.title,
description: task.description || '',
priority: task.priority,
due_date: task.due_date || '',
category_id: task.category_id,
tag_ids: task.tags?.map(t => t.id)
}
} else {
form.value = {
title: '',
description: '',
priority: 'q4',
due_date: '',
category_id: undefined
}
}
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
loading.value = true
try {
if (isEdit.value && uiStore.editingTask) {
await taskStore.updateTask(uiStore.editingTask.id, form.value)
ElMessage.success('任务已更新~')
} else {
await taskStore.createTask(form.value)
ElMessage.success('任务创建成功~')
}
uiStore.closeTaskDialog()
} finally {
loading.value = false
}
})
}
function handleClose() {
uiStore.closeTaskDialog()
}
</script>
<template>
<el-dialog
v-model="uiStore.taskDialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
class="task-dialog"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="task-form"
>
<el-form-item label="任务标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入任务标题~"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="任务描述">
<el-input
v-model="form.description"
type="textarea"
placeholder="添加一些描述吧~(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="优先级">
<div class="quadrant-selector">
<el-radio-group
v-model="form.priority"
class="priority-group"
:class="`${form.priority}-selected`"
>
<el-radio-button value="q1">
<div class="priority-option q1">
<span class="priority-label">Q1</span>
<span class="priority-desc">重要紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q2">
<div class="priority-option q2">
<span class="priority-label">Q2</span>
<span class="priority-desc">重要不紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q3">
<div class="priority-option q3">
<span class="priority-label">Q3</span>
<span class="priority-desc">不重要紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q4">
<div class="priority-option q4">
<span class="priority-label">Q4</span>
<span class="priority-desc">不重要不紧急</span>
</div>
</el-radio-button>
</el-radio-group>
</div>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker
v-model="form.due_date"
type="datetime"
placeholder="选择截止日期~(可选)"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类">
<el-select
v-model="form.category_id"
placeholder="选择分类~(可选)"
clearable
style="width: 100%"
>
<el-option
v-for="category in categoryStore.categories"
:key="category.id"
:label="category.name"
:value="category.id"
>
<span class="category-option">
<span class="category-dot" :style="{ background: category.color }"></span>
{{ category.name }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tag_ids"
multiple
placeholder="选择标签~(可选)"
clearable
style="width: 100%"
>
<el-option
v-for="tag in tagStore.tags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
>
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.task-form {
padding: 8px 0;
}
.quadrant-selector {
width: 100%;
}
.priority-group {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
:deep(.el-radio-button) {
.el-radio-button__inner {
width: 100%;
border-radius: var(--radius-md) !important;
border: 2px solid var(--secondary) !important;
background: var(--background);
padding: 12px;
transition: all 0.2s ease;
}
&:hover .el-radio-button__inner {
border-color: var(--primary) !important;
}
&.is-active {
.el-radio-button__inner {
background: white !important;
box-shadow: var(--shadow-md);
transform: scale(1.02);
}
}
}
}
// 每个象限选中时的特殊边框颜色
.priority-group.q1-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q1) !important;
}
.priority-group.q2-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q2) !important;
}
.priority-group.q3-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q3) !important;
}
.priority-group.q4-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q4) !important;
}
.priority-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.priority-label {
font-size: 16px;
font-weight: 700;
}
.priority-desc {
font-size: 11px;
color: var(--text-secondary);
}
&.q1 { color: var(--priority-q1); }
&.q2 { color: var(--priority-q2); }
&.q3 { color: var(--priority-q3); }
&.q4 { color: var(--priority-q4); }
}
// 选中状态下的样式增强
.priority-group :deep(.el-radio-button.is-active) {
.priority-label {
transform: scale(1.1);
}
.priority-desc {
color: var(--text-primary) !important;
font-weight: 500;
}
}
.category-option {
display: flex;
align-items: center;
gap: 8px;
}
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

21
WebUI/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import '@/styles/main.scss'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

78
WebUI/src/router/index.ts Normal file
View File

@@ -0,0 +1,78 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/tasks'
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/TaskListView.vue'),
meta: { title: '待办列表', view: 'list' }
},
{
path: '/calendar',
name: 'calendar',
component: () => import('@/views/CalendarPage.vue'),
meta: { title: '日历视图', view: 'calendar' }
},
{
path: '/quadrant',
name: 'quadrant',
component: () => import('@/views/QuadrantPage.vue'),
meta: { title: '四象限', view: 'quadrant' }
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { title: '个人信息', view: 'profile' }
},
{
path: '/habits',
name: 'habits',
component: () => import('@/views/HabitPage.vue'),
meta: { title: '习惯打卡', view: 'habits' }
},
{
path: '/anniversaries',
name: 'anniversaries',
component: () => import('@/views/AnniversaryPage.vue'),
meta: { title: '纪念日', view: 'anniversaries' }
},
{
path: '/assets',
name: 'assets',
component: () => import('@/views/AssetPage.vue'),
meta: { title: '资产总览', view: 'assets' }
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: { title: '偏好设置', view: 'settings' }
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(_to, _from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
})
router.beforeEach((to) => {
const page = (to.meta.title as string) || ''
const userStore = useUserSettingsStore()
const siteName = userStore.siteName || '爱莉希雅待办'
document.title = page ? `${page} - ${siteName}` : siteName
})
export default router

View File

@@ -0,0 +1,208 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { accountApi } from '@/api/accounts'
import type {
FinancialAccount, AccountFormData, BalanceUpdateData,
AccountHistoryRecord, DebtInstallment, DebtInstallmentFormData
} from '@/api/types'
export const useAccountStore = defineStore('account', () => {
const accounts = ref<FinancialAccount[]>([])
const installments = ref<DebtInstallment[]>([])
const loading = ref(false)
const savingsAccounts = computed(() =>
accounts.value.filter(a => a.account_type === 'savings' && a.is_active)
)
const debtAccounts = computed(() =>
accounts.value.filter(a => a.account_type === 'debt' && a.is_active)
)
const totalSavings = computed(() =>
savingsAccounts.value.reduce((sum, a) => sum + a.balance, 0)
)
const totalDebt = computed(() =>
debtAccounts.value.reduce((sum, a) => sum + a.balance, 0)
)
const netAssets = computed(() => totalSavings.value - totalDebt.value)
const activeInstallments = computed(() =>
installments.value.filter(i => !i.is_completed && i.days_until_payment !== null)
)
const upcomingPayments = computed(() =>
activeInstallments.value
.filter(i => i.days_until_payment! >= 0)
.sort((a, b) => a.days_until_payment! - b.days_until_payment!)
)
// ============ 账户操作 ============
async function fetchAccounts() {
loading.value = true
try {
accounts.value = await accountApi.getAccounts()
} catch (error) {
console.error('获取账户列表失败:', error)
} finally {
loading.value = false
}
}
async function createAccount(data: AccountFormData): Promise<FinancialAccount | null> {
try {
const account = await accountApi.createAccount(data)
accounts.value.push(account)
return account
} catch (error) {
console.error('创建账户失败:', error)
return null
}
}
async function updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount | null> {
try {
const updated = await accountApi.updateAccount(id, data)
const index = accounts.value.findIndex(a => a.id === id)
if (index !== -1) {
accounts.value[index] = updated
}
return updated
} catch (error) {
console.error('更新账户失败:', error)
return null
}
}
async function deleteAccount(id: number): Promise<boolean> {
try {
await accountApi.deleteAccount(id)
accounts.value = accounts.value.filter(a => a.id !== id)
return true
} catch (error) {
console.error('删除账户失败:', error)
return false
}
}
async function updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount | null> {
try {
const updated = await accountApi.updateBalance(id, data)
const index = accounts.value.findIndex(a => a.id === id)
if (index !== -1) {
accounts.value[index] = updated
}
return updated
} catch (error) {
console.error('更新余额失败:', error)
return null
}
}
async function fetchHistory(id: number, page = 1, pageSize = 20): Promise<AccountHistoryResponse> {
try {
return await accountApi.getHistory(id, { page, page_size: pageSize })
} catch (error) {
console.error('获取变更历史失败:', error)
return { total: 0, page: 1, page_size: pageSize, records: [] }
}
}
// ============ 分期计划操作 ============
async function fetchInstallments() {
try {
installments.value = await accountApi.getInstallments()
} catch (error) {
console.error('获取分期计划失败:', error)
}
}
async function createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment | null> {
try {
const inst = await accountApi.createInstallment(data)
installments.value.push(inst)
installments.value.sort((a, b) => {
const aActive = !a.is_completed && a.days_until_payment !== null ? 0 : 1
const bActive = !b.is_completed && b.days_until_payment !== null ? 0 : 1
if (aActive !== bActive) return aActive - bActive
return (a.days_until_payment ?? 9999) - (b.days_until_payment ?? 9999)
})
return inst
} catch (error) {
console.error('创建分期计划失败:', error)
return null
}
}
async function updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment | null> {
try {
const updated = await accountApi.updateInstallment(id, data)
const index = installments.value.findIndex(i => i.id === id)
if (index !== -1) {
installments.value[index] = updated
}
return updated
} catch (error) {
console.error('更新分期计划失败:', error)
return null
}
}
async function deleteInstallment(id: number): Promise<boolean> {
try {
await accountApi.deleteInstallment(id)
installments.value = installments.value.filter(i => i.id !== id)
return true
} catch (error) {
console.error('删除分期计划失败:', error)
return false
}
}
async function payInstallment(id: number): Promise<DebtInstallment | null> {
try {
const updated = await accountApi.payInstallment(id)
const index = installments.value.findIndex(i => i.id === id)
if (index !== -1) {
installments.value[index] = updated
}
return updated
} catch (error) {
console.error('标记还款失败:', error)
return null
}
}
async function init() {
await Promise.all([fetchAccounts(), fetchInstallments()])
}
return {
accounts,
installments,
loading,
savingsAccounts,
debtAccounts,
totalSavings,
totalDebt,
netAssets,
activeInstallments,
upcomingPayments,
fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
updateBalance,
fetchHistory,
fetchInstallments,
createInstallment,
updateInstallment,
deleteInstallment,
payInstallment,
init,
}
})

View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { anniversaryApi } from '@/api/anniversaries'
import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from '@/api/types'
export const useAnniversaryStore = defineStore('anniversary', () => {
const anniversaries = ref<Anniversary[]>([])
const categories = ref<AnniversaryCategory[]>([])
const loading = ref(false)
const activeCategoryId = ref<number | null>(null)
const filteredAnniversaries = computed(() => {
if (activeCategoryId.value === null) {
return anniversaries.value
}
return anniversaries.value.filter(a => a.category_id === activeCategoryId.value)
})
const upcomingAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until !== null && a.days_until! >= 0)
)
const pastAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until === null || a.days_until! < 0)
)
const todayAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until === 0)
)
const remindAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a =>
a.days_until !== null && a.days_until! >= 0 && a.days_until! <= a.remind_days_before
)
)
// ============ 纪念日操作 ============
async function fetchAnniversaries() {
loading.value = true
try {
const params = activeCategoryId.value !== null
? { category_id: activeCategoryId.value }
: undefined
anniversaries.value = await anniversaryApi.getAnniversaries(params)
} catch (error) {
console.error('获取纪念日列表失败:', error)
} finally {
loading.value = false
}
}
async function createAnniversary(data: AnniversaryFormData): Promise<Anniversary | null> {
try {
const newAnniversary = await anniversaryApi.createAnniversary(data)
anniversaries.value.unshift(newAnniversary)
reSort()
return newAnniversary
} catch (error) {
console.error('创建纪念日失败:', error)
return null
}
}
async function updateAnniversary(id: number, data: Partial<AnniversaryFormData>): Promise<Anniversary | null> {
try {
const updated = await anniversaryApi.updateAnniversary(id, data)
const index = anniversaries.value.findIndex(a => a.id === id)
if (index !== -1) {
anniversaries.value[index] = updated
}
reSort()
return updated
} catch (error) {
console.error('更新纪念日失败:', error)
return null
}
}
async function deleteAnniversary(id: number): Promise<boolean> {
try {
await anniversaryApi.deleteAnniversary(id)
anniversaries.value = anniversaries.value.filter(a => a.id !== id)
return true
} catch (error) {
console.error('删除纪念日失败:', error)
return false
}
}
// ============ 分类操作 ============
async function fetchCategories() {
try {
categories.value = await anniversaryApi.getCategories()
} catch (error) {
console.error('获取纪念日分类失败:', error)
}
}
async function createCategory(data: AnniversaryCategoryFormData): Promise<AnniversaryCategory | null> {
try {
const newCat = await anniversaryApi.createCategory(data)
categories.value.push(newCat)
categories.value.sort((a, b) => a.sort_order - b.sort_order)
return newCat
} catch (error) {
console.error('创建纪念日分类失败:', error)
return null
}
}
async function updateCategory(id: number, data: Partial<AnniversaryCategoryFormData>): Promise<AnniversaryCategory | null> {
try {
const updated = await anniversaryApi.updateCategory(id, data)
const index = categories.value.findIndex(c => c.id === id)
if (index !== -1) {
categories.value[index] = updated
}
categories.value.sort((a, b) => a.sort_order - b.sort_order)
return updated
} catch (error) {
console.error('更新纪念日分类失败:', error)
return null
}
}
async function deleteCategory(id: number): Promise<boolean> {
try {
await anniversaryApi.deleteCategory(id)
categories.value = categories.value.filter(c => c.id !== id)
if (activeCategoryId.value === id) {
activeCategoryId.value = null
}
return true
} catch (error) {
console.error('删除纪念日分类失败:', error)
return false
}
}
function setFilter(categoryId: number | null) {
activeCategoryId.value = categoryId
}
function reSort() {
anniversaries.value.sort((a, b) => {
const aUpcoming = a.days_until !== null && a.days_until >= 0 ? 0 : 1
const bUpcoming = b.days_until !== null && b.days_until >= 0 ? 0 : 1
if (aUpcoming !== bUpcoming) return aUpcoming - bUpcoming
return (a.days_until ?? 9999) - (b.days_until ?? 9999)
})
}
async function init() {
await Promise.all([fetchAnniversaries(), fetchCategories()])
}
return {
anniversaries,
categories,
loading,
activeCategoryId,
filteredAnniversaries,
upcomingAnniversaries,
pastAnniversaries,
todayAnniversaries,
remindAnniversaries,
fetchAnniversaries,
createAnniversary,
updateAnniversary,
deleteAnniversary,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
setFilter,
init,
}
})

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { categoryApi, type CategoryResponse } from '@/api/categories'
import type { CategoryFormData } from '@/api/types'
export const useCategoryStore = defineStore('category', () => {
const categories = ref<CategoryResponse[]>([])
const loading = ref(false)
async function fetchCategories() {
loading.value = true
try {
categories.value = await categoryApi.getCategories()
} catch (error) {
console.error('获取分类列表失败:', error)
} finally {
loading.value = false
}
}
async function createCategory(data: CategoryFormData) {
try {
const newCategory = await categoryApi.createCategory(data)
categories.value.push(newCategory)
return newCategory
} catch (error) {
console.error('创建分类失败:', error)
return null
}
}
async function updateCategory(id: number, data: CategoryFormData) {
try {
const updatedCategory = await categoryApi.updateCategory(id, data)
const index = categories.value.findIndex((c: CategoryResponse) => c.id === id)
if (index !== -1) {
categories.value[index] = updatedCategory
}
return updatedCategory
} catch (error) {
console.error('更新分类失败:', error)
return null
}
}
async function deleteCategory(id: number) {
try {
await categoryApi.deleteCategory(id)
categories.value = categories.value.filter((c: CategoryResponse) => c.id !== id)
return true
} catch (error) {
console.error('删除分类失败:', error)
return false
}
}
return {
categories,
loading,
fetchCategories,
createCategory,
updateCategory,
deleteCategory
}
})

View File

@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { habitApi, habitGroupApi, type HabitResponse, type HabitGroupResponse, type HabitStatsResponse } from '@/api/habits'
import type { HabitFormData, HabitGroupFormData } from '@/api/types'
export const useHabitStore = defineStore('habit', () => {
const habits = ref<HabitResponse[]>([])
const groups = ref<HabitGroupResponse[]>([])
const statsMap = ref<Record<number, HabitStatsResponse>>({})
const loading = ref(false)
// 按分组组织习惯
const groupedHabits = computed(() => {
const map = new Map<number, { group: HabitGroupResponse | null; habits: HabitResponse[] }>()
// 先添加有分组的
for (const group of groups.value) {
map.set(group.id, { group, habits: [] })
}
for (const habit of habits.value) {
if (habit.group_id && map.has(habit.group_id)) {
map.get(habit.group_id)!.habits.push(habit)
} else {
// 未分组
if (!map.has(0)) {
map.set(0, { group: null, habits: [] })
}
map.get(0)!.habits.push(habit)
}
}
return Array.from(map.values())
})
// 今日统计
const todaySummary = computed(() => {
const todayHabits = habits.value.filter(h => !h.is_archived)
const total = todayHabits.length
let completed = 0
let maxStreak = 0
for (const habit of todayHabits) {
const stats = statsMap.value[habit.id]
if (stats?.today_completed) completed++
if (stats && stats.current_streak > maxStreak) maxStreak = stats.current_streak
}
return { total, completed, maxStreak }
})
async function fetchHabits(includeArchived = false) {
loading.value = true
try {
habits.value = await habitApi.getHabits({ include_archived: includeArchived })
} catch (error) {
console.error('获取习惯列表失败:', error)
} finally {
loading.value = false
}
}
async function fetchGroups() {
try {
groups.value = await habitGroupApi.getGroups()
} catch (error) {
console.error('获取习惯分组失败:', error)
}
}
async function fetchStats(habitId: number) {
try {
statsMap.value[habitId] = await habitApi.getStats(habitId)
} catch (error) {
console.error('获取习惯统计失败:', error)
}
}
async function fetchAllStats() {
for (const habit of habits.value) {
await fetchStats(habit.id)
}
}
async function createHabit(data: HabitFormData) {
try {
const newHabit = await habitApi.createHabit(data)
habits.value.unshift(newHabit)
await fetchStats(newHabit.id)
return newHabit
} catch (error) {
console.error('创建习惯失败:', error)
return null
}
}
async function updateHabit(id: number, data: Partial<HabitFormData>) {
try {
const updated = await habitApi.updateHabit(id, data)
const index = habits.value.findIndex(h => h.id === id)
if (index !== -1) habits.value[index] = updated
return updated
} catch (error) {
console.error('更新习惯失败:', error)
return null
}
}
async function deleteHabit(id: number) {
try {
await habitApi.deleteHabit(id)
habits.value = habits.value.filter(h => h.id !== id)
delete statsMap.value[id]
return true
} catch (error) {
console.error('删除习惯失败:', error)
return false
}
}
async function toggleArchive(id: number) {
try {
const updated = await habitApi.toggleArchive(id)
const index = habits.value.findIndex(h => h.id === id)
if (index !== -1) habits.value[index] = updated
return updated
} catch (error) {
console.error('切换归档状态失败:', error)
return null
}
}
async function checkin(habitId: number, count?: number) {
try {
const result = await habitApi.checkin(habitId, count)
await fetchStats(habitId)
return result
} catch (error) {
console.error('打卡失败:', error)
return null
}
}
async function cancelCheckin(habitId: number, count: number = 1) {
try {
await habitApi.cancelCheckin(habitId, count)
await fetchStats(habitId)
return true
} catch (error) {
console.error('取消打卡失败:', error)
return false
}
}
async function createGroup(data: HabitGroupFormData) {
try {
const newGroup = await habitGroupApi.createGroup(data)
groups.value.push(newGroup)
return newGroup
} catch (error) {
console.error('创建分组失败:', error)
return null
}
}
async function updateGroup(id: number, data: Partial<HabitGroupFormData>) {
try {
const updated = await habitGroupApi.updateGroup(id, data)
const index = groups.value.findIndex(g => g.id === id)
if (index !== -1) groups.value[index] = updated
// 同步更新 habits 中的 group 引用
for (const habit of habits.value) {
if (habit.group_id === id) habit.group = updated
}
return updated
} catch (error) {
console.error('更新分组失败:', error)
return null
}
}
async function deleteGroup(id: number) {
try {
await habitGroupApi.deleteGroup(id)
groups.value = groups.value.filter(g => g.id !== id)
// 清空关联习惯的 group_id
for (const habit of habits.value) {
if (habit.group_id === id) {
habit.group_id = undefined
habit.group = undefined
}
}
return true
} catch (error) {
console.error('删除分组失败:', error)
return false
}
}
async function init() {
await Promise.all([fetchGroups(), fetchHabits()])
await fetchAllStats()
}
return {
habits,
groups,
statsMap,
loading,
groupedHabits,
todaySummary,
fetchHabits,
fetchGroups,
fetchStats,
fetchAllStats,
createHabit,
updateHabit,
deleteHabit,
toggleArchive,
checkin,
cancelCheckin,
createGroup,
updateGroup,
deleteGroup,
init
}
})

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tagApi, type TagResponse } from '@/api/tags'
import type { TagFormData } from '@/api/types'
export const useTagStore = defineStore('tag', () => {
const tags = ref<TagResponse[]>([])
const loading = ref(false)
async function fetchTags() {
loading.value = true
try {
tags.value = await tagApi.getTags()
} catch (error) {
console.error('获取标签列表失败:', error)
} finally {
loading.value = false
}
}
async function createTag(data: TagFormData) {
try {
const newTag = await tagApi.createTag(data)
tags.value.push(newTag)
return newTag
} catch (error) {
console.error('创建标签失败:', error)
return null
}
}
async function deleteTag(id: number) {
try {
await tagApi.deleteTag(id)
tags.value = tags.value.filter((t: TagResponse) => t.id !== id)
return true
} catch (error) {
console.error('删除标签失败:', error)
return false
}
}
return {
tags,
loading,
fetchTags,
createTag,
deleteTag
}
})

View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { taskApi, type TaskResponse } from '@/api/tasks'
import type { TaskFormData, TaskFilters } from '@/api/types'
import { matchWithPinyin } from '@/utils/pinyin'
import { formatDate } from '@/utils/date'
export const useTaskStore = defineStore('task', () => {
const allTasks = ref<TaskResponse[]>([])
const loading = ref(false)
const filters = ref<TaskFilters>({
status: 'all',
sort_by: 'priority',
sort_order: 'desc'
})
// 所有任务
const tasks = computed(() => {
let result = [...allTasks.value]
// 搜索过滤(支持拼音)
if (filters.value.search?.trim()) {
const keyword = filters.value.search.trim()
result = result.filter(t =>
matchWithPinyin(t.title, keyword) ||
(t.description && matchWithPinyin(t.description, keyword)) ||
t.tags?.some(tag => matchWithPinyin(tag.name, keyword))
)
}
// 状态筛选
if (filters.value.status === 'active') {
result = result.filter(t => !t.is_completed)
} else if (filters.value.status === 'completed') {
result = result.filter(t => t.is_completed)
}
// 分类筛选
if (filters.value.category_id) {
result = result.filter(t => t.category_id === filters.value.category_id)
}
// 排序
if (filters.value.sort_by) {
result.sort((a, b) => {
let comparison = 0
if (filters.value.sort_by === 'priority') {
const priorityOrder: Record<string, number> = { q1: 4, q2: 3, q3: 2, q4: 1 }
const aOrder = priorityOrder[a.priority] || 0
const bOrder = priorityOrder[b.priority] || 0
comparison = aOrder - bOrder
} else if (filters.value.sort_by === 'due_date') {
if (!a.due_date && !b.due_date) comparison = 0
else if (!a.due_date) comparison = 1
else if (!b.due_date) comparison = -1
else comparison = new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
} else {
comparison = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}
return filters.value.sort_order === 'asc' ? -comparison : comparison
})
}
return result
})
// 计算进行中和已完成的任务数量(基于所有任务)
const activeTasks = computed(() => allTasks.value.filter(t => !t.is_completed))
const completedTasks = computed(() => allTasks.value.filter(t => t.is_completed))
const totalTasks = computed(() => allTasks.value)
// 按日期分组的任务(用于月视图)
const tasksByDate = computed(() => {
const grouped = new Map<string, TaskResponse[]>()
const today = new Date()
const todayStr = formatDate(today)
allTasks.value.forEach(task => {
let dateKey: string
if (task.due_date) {
dateKey = formatDate(new Date(task.due_date))
} else {
// 无截止日期的任务显示在当天
dateKey = todayStr
}
if (!grouped.has(dateKey)) {
grouped.set(dateKey, [])
}
grouped.get(dateKey)!.push(task)
})
return grouped
})
async function fetchTasks() {
loading.value = true
try {
// 一次获取所有任务,在前端进行筛选和排序
allTasks.value = await taskApi.getTasks({ status: 'all' })
} catch (error) {
console.error('获取任务列表失败:', error)
} finally {
loading.value = false
}
}
async function createTask(data: TaskFormData) {
try {
const newTask = await taskApi.createTask(data)
allTasks.value.unshift(newTask)
return newTask
} catch (error) {
console.error('创建任务失败:', error)
return null
}
}
async function updateTask(id: number, data: TaskFormData) {
try {
const updatedTask = await taskApi.updateTask(id, data)
const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id)
if (index !== -1) {
allTasks.value[index] = updatedTask
}
return updatedTask
} catch (error) {
console.error('更新任务失败:', error)
return null
}
}
async function deleteTask(id: number) {
try {
await taskApi.deleteTask(id)
allTasks.value = allTasks.value.filter((t: TaskResponse) => t.id !== id)
return true
} catch (error) {
console.error('删除任务失败:', error)
return false
}
}
async function toggleTask(id: number) {
try {
const updatedTask = await taskApi.toggleTask(id)
const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id)
if (index !== -1) {
allTasks.value[index] = updatedTask
}
return updatedTask
} catch (error) {
console.error('切换任务状态失败:', error)
return null
}
}
function setFilters(newFilters: TaskFilters) {
filters.value = { ...filters.value, ...newFilters }
// 筛选现在在前端完成,不需要重新请求
}
return {
tasks,
loading,
filters,
activeTasks,
completedTasks,
totalTasks: allTasks,
tasksByDate,
fetchTasks,
createTask,
updateTask,
deleteTask,
toggleTask,
setFilters
}
})

View File

@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Task, Category } from '@/api/types'
export const useUIStore = defineStore('ui', () => {
const router = useRouter()
const taskDialogVisible = ref(false)
const editingTask = ref<Task | null>(null)
const categoryDialogVisible = ref(false)
const editingCategory = ref<Category | null>(null)
const sidebarCollapsed = ref(false)
const globalLoading = ref(false)
const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list')
const calendarMode = ref<'week' | 'monthly'>('monthly')
function openTaskDialog(task?: Task) {
editingTask.value = task || null
taskDialogVisible.value = true
}
function closeTaskDialog() {
taskDialogVisible.value = false
editingTask.value = null
}
function openCategoryDialog(category?: Category) {
editingCategory.value = category || null
categoryDialogVisible.value = true
}
function closeCategoryDialog() {
categoryDialogVisible.value = false
editingCategory.value = null
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function setLoading(loading: boolean) {
globalLoading.value = loading
}
function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') {
currentView.value = view
}
function setCalendarMode(mode: 'week' | 'monthly') {
calendarMode.value = mode
}
return {
taskDialogVisible,
editingTask,
categoryDialogVisible,
editingCategory,
sidebarCollapsed,
globalLoading,
currentView,
calendarMode,
openTaskDialog,
closeTaskDialog,
openCategoryDialog,
closeCategoryDialog,
toggleSidebar,
setLoading,
setCurrentView,
setCalendarMode
}
})

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { UserSettings, UserSettingsUpdate } from '@/api/types'
import { getUserSettings, updateUserSettings as apiUpdateSettings } from '@/api/userSettings'
export const useUserSettingsStore = defineStore('userSettings', () => {
const settings = ref<UserSettings | null>(null)
const loading = ref(false)
async function fetchSettings() {
loading.value = true
try {
settings.value = await getUserSettings()
} finally {
loading.value = false
}
}
async function updateSettings(data: UserSettingsUpdate) {
loading.value = true
try {
settings.value = await apiUpdateSettings(data)
} finally {
loading.value = false
}
}
const nickname = ref('爱莉希雅')
const avatar = ref('')
const signature = ref('')
const birthday = ref('')
const email = ref('')
const siteName = ref('爱莉希雅待办')
const defaultView = ref('list')
const defaultSortBy = ref('priority')
const defaultSortOrder = ref('desc')
function syncFromSettings(s: UserSettings) {
nickname.value = s.nickname || ''
avatar.value = s.avatar || ''
signature.value = s.signature || ''
birthday.value = s.birthday || ''
email.value = s.email || ''
siteName.value = s.site_name || '爱莉希雅待办'
defaultView.value = s.default_view || 'list'
defaultSortBy.value = s.default_sort_by || 'priority'
defaultSortOrder.value = s.default_sort_order || 'desc'
}
async function fetchAndSync() {
await fetchSettings()
if (settings.value) {
syncFromSettings(settings.value)
}
}
return {
settings,
loading,
fetchSettings,
updateSettings,
fetchAndSync,
nickname,
avatar,
signature,
birthday,
email,
siteName,
defaultView,
defaultSortBy,
defaultSortOrder,
syncFromSettings
}
})

View File

@@ -0,0 +1,33 @@
// 爱莉希雅主题配色方案
$primary: #FFB7C5;
$secondary: #FFC0CB;
$accent: #C8A2C8;
$background: #FFF5F7;
$background-dark: #F8F0F5;
$text-primary: #8B4557;
$text-secondary: #A86A7A;
$success: #98D8C8;
$warning: #FFB347;
$danger: #FF6B6B;
// 四象限优先级颜色
$priority-q1: #FF6B6B; // 重要紧急 - 紧急红
$priority-q2: #FFB347; // 重要不紧急 - 警示橙
$priority-q3: #98D8C8; // 不重要紧急 - 平和绿
$priority-q4: #C8A2C8; // 不重要不紧急 - 淡雅紫
// 圆角
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
// 阴影
$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15);
$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2);
$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25);
// 过渡
$transition-fast: 150ms ease;
$transition-normal: 300ms ease;
$transition-slow: 500ms ease;

340
WebUI/src/styles/main.scss Normal file
View File

@@ -0,0 +1,340 @@
// 爱莉希雅主题全局样式
$primary: #FFB7C5;
$secondary: #FFC0CB;
$accent: #C8A2C8;
$background: #FFF5F7;
$background-dark: #F8F0F5;
$text-primary: #8B4557;
$text-secondary: #A86A7A;
$success: #98D8C8;
$warning: #FFB347;
$danger: #FF6B6B;
$priority-q1: #FF6B6B;
$priority-q2: #FFB347;
$priority-q3: #98D8C8;
$priority-q4: #C8A2C8;
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15);
$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2);
$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25);
$transition-fast: 150ms ease;
$transition-normal: 300ms ease;
$transition-slow: 500ms ease;
:root {
--primary: #{$primary};
--secondary: #{$secondary};
--accent: #{$accent};
--background: #{$background};
--background-dark: #{$background-dark};
--text-primary: #{$text-primary};
--text-secondary: #{$text-secondary};
--success: #{$success};
--warning: #{$warning};
--danger: #{$danger};
--priority-q1: #{$priority-q1};
--priority-q2: #{$priority-q2};
--priority-q3: #{$priority-q3};
--priority-q4: #{$priority-q4};
--radius-sm: #{$radius-sm};
--radius-md: #{$radius-md};
--radius-lg: #{$radius-lg};
--radius-xl: #{$radius-xl};
--shadow-sm: #{$shadow-sm};
--shadow-md: #{$shadow-md};
--shadow-lg: #{$shadow-lg};
--transition-fast: #{$transition-fast};
--transition-normal: #{$transition-normal};
--transition-slow: #{$transition-slow};
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--background);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
&:hover {
background: var(--accent);
}
}
.page-enter-active,
.page-leave-active {
transition: opacity $transition-normal, transform $transition-normal;
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp $transition-normal ease forwards;
}
@keyframes completeTask {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.task-complete {
animation: completeTask 0.3s ease;
}
@keyframes starBurst {
0% {
opacity: 1;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
100% {
opacity: 0;
transform: scale(0) rotate(360deg);
}
}
.star-burst {
position: absolute;
width: 20px;
height: 20px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
animation: starBurst 0.6s ease forwards;
}
.btn-glow {
transition: box-shadow $transition-fast, transform $transition-fast;
&:hover {
box-shadow: 0 0 20px rgba(255, 183, 197, 0.5);
}
&:active {
transform: scale(0.95);
}
}
// Element Plus 按钮主题覆盖(粉色主题)
.el-button--primary {
--el-button-bg-color: var(--primary);
--el-button-border-color: var(--primary);
--el-button-hover-bg-color: #ffaabb;
--el-button-hover-border-color: #ffaabb;
--el-button-hover-text-color: white;
--el-button-active-bg-color: var(--accent);
--el-button-active-border-color: var(--accent);
--el-button-active-text-color: white;
--el-button-text-color: white;
--el-button-disabled-bg-color: #ffd6e0;
--el-button-disabled-border-color: #ffd6e0;
--el-button-disabled-text-color: white;
}
// 默认按钮 hover 也不变蓝
.el-button--default {
--el-button-hover-bg-color: rgba(255, 183, 197, 0.1);
--el-button-hover-border-color: var(--primary);
--el-button-hover-text-color: var(--text-primary);
}
// text 类型按钮 hover 变粉色
.el-button--default:not(.el-button--primary):not(.is-disabled):hover {
color: var(--text-primary) !important;
border-color: var(--primary) !important;
background-color: rgba(255, 183, 197, 0.08) !important;
}
// link 类型按钮
.el-button.is-link:not(.is-disabled):hover {
color: var(--text-primary) !important;
}
.el-button--primary.is-link:not(.is-disabled):hover {
color: var(--text-primary) !important;
}
// Switch 开关颜色
.el-switch {
--el-switch-on-color: #{$primary};
}
.el-switch.is-checked .el-switch__core {
background-color: #{$primary} !important;
border-color: #{$primary} !important;
}
.el-switch.is-checked .el-switch__action {
left: calc(100% - 20px);
color: white;
}
// 单选框颜色
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
box-shadow: -1px 0 0 0 var(--primary) !important;
}
.el-radio.is-checked .el-radio__inner {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
.el-radio.is-checked .el-radio__label {
color: var(--text-primary) !important;
}
.el-radio__inner:hover {
border-color: var(--primary) !important;
}
// Message box 确认按钮
.el-message-box__btns .el-button--primary {
background-color: var(--primary);
border-color: var(--primary);
}
.el-message-box__btns .el-button--primary:hover {
background-color: #ffaabb;
border-color: #ffaabb;
}
.el-input__wrapper {
border-radius: var(--radius-md) !important;
box-shadow: none !important;
border: 1px solid var(--secondary) !important;
&:hover {
border-color: var(--primary) !important;
}
&.is-focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px rgba(255, 183, 197, 0.2) !important;
}
}
.el-select .el-input__wrapper {
border-radius: var(--radius-md) !important;
}
// Element Plus 下拉菜单选中项样式
.el-select-dropdown__item.is-selected {
color: var(--primary) !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: var(--background) !important;
}
.el-dialog {
border-radius: var(--radius-lg) !important;
.el-dialog__header {
border-bottom: 1px solid var(--background-dark);
padding-bottom: 16px;
}
.el-dialog__title {
color: var(--text-primary);
font-weight: 600;
}
}
.el-message {
border-radius: var(--radius-md) !important;
}
.card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow $transition-normal, transform $transition-normal;
&:hover {
box-shadow: var(--shadow-md);
}
}
.decoration-star {
position: absolute;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
opacity: 0.6;
animation: twinkle 2s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@media (max-width: 768px) {
html, body {
font-size: 13px;
}
}

113
WebUI/src/utils/date.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* 日期工具函数
*/
/**
* 格式化日期为 YYYY-MM-DD 格式
* @param date 日期对象
* @returns 格式化后的日期字符串
*/
export function formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 格式化时间为 HH:mm 格式
* @param date 日期对象
* @returns 格式化后的时间字符串
*/
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
/**
* 获取某一周的第一天(周日)
* @param date 日期对象
* @returns 该周第一天的日期对象
*/
export function getWeekStart(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
d.setDate(d.getDate() - day)
d.setHours(0, 0, 0, 0)
return d
}
/**
* 获取某一周的最后一天(周六)
* @param date 日期对象
* @returns 该周最后一天的日期对象
*/
export function getWeekEnd(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
d.setDate(d.getDate() + (6 - day))
d.setHours(23, 59, 59, 999)
return d
}
/**
* 获取一周的所有日期
* @param date 参考日期
* @returns 7天的日期数组周日到周六
*/
export function getWeekDays(date: Date): Date[] {
const weekStart = getWeekStart(date)
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const d = new Date(weekStart)
d.setDate(weekStart.getDate() + i)
days.push(d)
}
return days
}
/**
* 获取一年中的第几周
* @param date 日期对象
* @returns 周数1-53
*/
export function getWeekNumber(date: Date): number {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
// 设置为周四ISO周定义
d.setDate(d.getDate() + 4 - (d.getDay() || 7))
// 获取年初
const yearStart = new Date(d.getFullYear(), 0, 1)
// 计算周数
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
}
/**
* 判断两个日期是否是同一天
* @param date1 日期1
* @param date2 日期2
* @returns 是否是同一天
*/
export function isSameDay(date1: Date, date2: Date): boolean {
return formatDate(date1) === formatDate(date2)
}
/**
* 判断日期是否是今天
* @param date 日期对象
* @returns 是否是今天
*/
export function isToday(date: Date): boolean {
return isSameDay(date, new Date())
}
/**
* 获取星期几的中文名称
* @param dayOfWeek 星期几0-60为周日
* @returns 中文名称
*/
export function getWeekDayName(dayOfWeek: number): string {
const names = ['日', '一', '二', '三', '四', '五', '六']
return names[dayOfWeek] ?? ''
}

177
WebUI/src/utils/pinyin.ts Normal file
View File

@@ -0,0 +1,177 @@
import { pinyin } from 'pinyin-pro'
/**
* 将中文文本转换为拼音首字母(小写)
* @param text 中文文本
* @returns 拼音首字母字符串
*/
export function toPinyinInitials(text: string): string {
return pinyin(text, { pattern: 'first', toneType: 'none' })
.replace(/\s/g, '')
.toLowerCase()
}
/**
* 将中文文本转换为完整拼音(小写)
* @param text 中文文本
* @returns 完整拼音字符串
*/
export function toPinyinFull(text: string): string {
return pinyin(text, { pattern: 'pinyin', toneType: 'none' })
.replace(/\s/g, '')
.toLowerCase()
}
/**
* 检查搜索词是否匹配目标文本(支持拼音匹配)
* 匹配规则:
* 1. 直接包含搜索词
* 2. 拼音首字母匹配
* 3. 完整拼音匹配
* @param target 目标文本
* @param keyword 搜索词
* @returns 是否匹配
*/
export function matchWithPinyin(target: string, keyword: string): boolean {
const targetLower = target.toLowerCase()
const keywordLower = keyword.toLowerCase()
// 直接匹配
if (targetLower.includes(keywordLower)) {
return true
}
// 拼音首字母匹配
const initials = toPinyinInitials(target)
if (initials.includes(keywordLower)) {
return true
}
// 完整拼音匹配
const fullPinyin = toPinyinFull(target)
if (fullPinyin.includes(keywordLower)) {
return true
}
return false
}
/**
* 获取匹配的位置信息
* @param target 目标文本
* @param keyword 搜索词
* @returns 匹配的字符索引数组,如果没有匹配则返回空数组
*/
export function getMatchIndices(target: string, keyword: string): number[] {
const targetLower = target.toLowerCase()
const keywordLower = keyword.toLowerCase()
const indices: number[] = []
// 如果是直接文本匹配
let startPos = targetLower.indexOf(keywordLower)
if (startPos !== -1) {
for (let i = startPos; i < startPos + keyword.length; i++) {
indices.push(i)
}
return indices
}
// 拼音匹配 - 需要找出哪些字符被匹配
const keywordChars = keywordLower.split('')
for (let i = 0; i < target.length; i++) {
const char = target[i]
if (!char) continue
const charInitial = toPinyinInitials(char)
const charFullPinyin = toPinyinFull(char)
// 检查是否有任何剩余的搜索关键词可以匹配这个字符
for (let j = 0; j < keywordChars.length; j++) {
const remaining = keywordChars.slice(j).join('')
// 首字母匹配
if (charInitial.length > 0 && remaining.startsWith(charInitial)) {
indices.push(i)
keywordChars.splice(j, charInitial.length)
break
}
// 完整拼音匹配
if (charFullPinyin.length > 0 && remaining.startsWith(charFullPinyin)) {
indices.push(i)
keywordChars.splice(j, charFullPinyin.length)
break
}
}
}
return indices
}
/**
* 将文本中的特殊字符转义为 HTML 实体,防止 XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 高亮显示匹配的文本
* @param text 原始文本
* @param keyword 搜索词
* @returns 包含高亮标记的 HTML 字符串
*/
export function highlightMatch(text: string, keyword: string): string {
if (!keyword.trim()) {
return escapeHtml(text)
}
const indices = getMatchIndices(text, keyword)
if (indices.length === 0) {
return escapeHtml(text)
}
let result = ''
let inHighlight = false
let highlightStart = -1
const indicesSet = new Set(indices)
// 找出连续的高亮区间
const ranges: Array<{ start: number; end: number }> = []
let currentRange: { start: number; end: number } | null = null
for (let i = 0; i < text.length; i++) {
if (indicesSet.has(i)) {
if (currentRange === null) {
currentRange = { start: i, end: i }
} else {
currentRange.end = i
}
} else {
if (currentRange !== null) {
ranges.push(currentRange)
currentRange = null
}
}
}
if (currentRange !== null) {
ranges.push(currentRange)
}
// 构建结果字符串(先转义再包裹高亮标签)
let lastIndex = 0
for (const range of ranges) {
result += escapeHtml(text.slice(lastIndex, range.start))
result += `<mark class="search-highlight">${escapeHtml(text.slice(range.start, range.end + 1))}</mark>`
lastIndex = range.end + 1
}
result += escapeHtml(text.slice(lastIndex))
return result
}

View File

@@ -0,0 +1,27 @@
import type { QuadrantPriority } from '@/api/types'
const priorityColors: Record<string, string> = {
q1: 'var(--priority-q1)',
q2: 'var(--priority-q2)',
q3: 'var(--priority-q3)',
q4: 'var(--priority-q4)'
}
const defaultPriorityColor = 'var(--priority-q4)'
export function getPriorityColor(priority: string): string {
return priorityColors[priority] ?? defaultPriorityColor
}
const priorityConfig: Record<string, { label: string; color: string }> = {
q1: { label: 'Q1 重要紧急', color: 'var(--priority-q1)' },
q2: { label: 'Q2 重要不紧急', color: 'var(--priority-q2)' },
q3: { label: 'Q3 不重要紧急', color: 'var(--priority-q3)' },
q4: { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' }
}
const defaultPriorityConfig = { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' }
export function getPriorityConfig(priority: string) {
return priorityConfig[priority] || defaultPriorityConfig
}

View File

@@ -0,0 +1,905 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { Anniversary, AnniversaryCategory } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
import AnniversaryDialog from '@/components/AnniversaryDialog.vue'
import AnniversaryCategoryDialog from '@/components/AnniversaryCategoryDialog.vue'
const store = useAnniversaryStore()
const showAnniversaryDialog = ref(false)
const showCategoryDialog = ref(false)
const editingAnniversary = ref<Anniversary | null>(null)
const editingCategory = ref<AnniversaryCategory | null>(null)
const showCategoryManage = ref(false)
onMounted(async () => {
await store.init()
})
watch(() => store.activeCategoryId, () => {
store.fetchAnniversaries()
})
// ============ 格式化工具 ============
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return `${d.getMonth() + 1}${d.getDate()}`
}
function formatNextDate(dateStr: string | null): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
}
function getCountdownText(item: Anniversary): string {
if (item.days_until === null) return '无日期'
if (item.days_until === 0) return '就是今天'
if (item.days_until > 0) return `还有 ${item.days_until}`
return `已过 ${Math.abs(item.days_until)}`
}
function getCountdownType(item: Anniversary): 'today' | 'upcoming' | 'past' | 'remind' {
if (item.days_until === 0) return 'today'
if (item.days_until === null) return 'past'
if (item.days_until > item.remind_days_before) return 'upcoming'
if (item.days_until > 0) return 'remind'
return 'past'
}
function getYearCountText(item: Anniversary): string {
if (item.year_count === null || item.year_count === undefined) return ''
if (item.year_count === 0) return '今年'
return `${item.year_count}`
}
// ============ 分类筛选 ============
const filterTabs = computed(() => {
const tabs: { id: number | null; name: string; color?: string }[] = [
{ id: null, name: '全部' }
]
for (const cat of store.categories) {
tabs.push({ id: cat.id, name: cat.name, color: cat.color })
}
return tabs
})
function setFilter(categoryId: number | null) {
store.setFilter(categoryId)
}
// ============ 纪念日操作 ============
function openCreate() {
editingAnniversary.value = null
showAnniversaryDialog.value = true
}
function openEdit(item: Anniversary) {
editingAnniversary.value = item
showAnniversaryDialog.value = true
}
async function handleDelete(item: Anniversary) {
try {
await ElMessageBox.confirm(
`确定要删除「${item.title}」吗?`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await store.deleteAnniversary(item.id)
if (success) ElMessage.success('删除成功~')
} catch {
// 用户取消
}
}
// ============ 分类管理 ============
function openCategoryDialog(cat?: AnniversaryCategory) {
editingCategory.value = cat || null
showCategoryDialog.value = true
}
async function handleDeleteCategory(cat: AnniversaryCategory) {
try {
await ElMessageBox.confirm(
`确定要删除分类「${cat.name}」吗?该分类下的纪念日将变为未分类。`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await store.deleteCategory(cat.id)
if (success) ElMessage.success('分类删除成功~')
} catch {
// 用户取消
}
}
// ============ 概览数据 ============
const totalCount = computed(() => store.anniversaries.length)
const upcomingCount = computed(() => store.upcomingAnniversaries.length)
const todayCount = computed(() => store.todayAnniversaries.length)
</script>
<template>
<div class="anniversary-page">
<div class="anniversary-container">
<!-- 概览统计 -->
<div class="overview-card">
<div class="overview-item">
<div class="overview-icon overview-icon--total">
<el-icon :size="22"><Calendar /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ totalCount }}</span>
<span class="overview-label">全部纪念日</span>
</div>
</div>
<div class="overview-divider"></div>
<div class="overview-item">
<div class="overview-icon overview-icon--today">
<el-icon :size="22"><Star /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ todayCount }}</span>
<span class="overview-label">今天是</span>
</div>
</div>
<div class="overview-divider"></div>
<div class="overview-item">
<div class="overview-icon overview-icon--upcoming">
<el-icon :size="22"><Timer /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ upcomingCount }}</span>
<span class="overview-label">即将到来</span>
</div>
</div>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<div class="toolbar-top">
<div class="toolbar-left">
<button class="action-btn action-btn--outline" @click="showCategoryManage = !showCategoryManage">
<el-icon :size="16"><FolderAdd /></el-icon>
<span>管理分类</span>
</button>
</div>
<div class="toolbar-right">
<button class="action-btn action-btn--primary" @click="openCreate">
<el-icon :size="16"><Plus /></el-icon>
<span>新建纪念日</span>
</button>
</div>
</div>
<!-- 分类筛选标签 -->
<div v-if="filterTabs.length > 1" class="group-tabs">
<button
v-for="tab in filterTabs"
:key="tab.id ?? 'all'"
class="group-tab"
:class="{ active: store.activeCategoryId === tab.id }"
@click="setFilter(tab.id)"
>
<span
v-if="tab.color && tab.id !== null"
class="tab-dot"
:style="{ background: tab.color }"
/>
{{ tab.name }}
</button>
</div>
</div>
<!-- 分类管理面板 -->
<Transition name="slide-down">
<div v-if="showCategoryManage" class="category-manage-panel">
<div class="panel-header">
<span class="panel-title">分类管理</span>
<button class="action-btn action-btn--primary action-btn--sm" @click="openCategoryDialog()">
<el-icon :size="14"><Plus /></el-icon>
<span>新建分类</span>
</button>
</div>
<div v-if="store.categories.length === 0" class="panel-empty">
还没有分类点击新建分类创建吧~
</div>
<div v-else class="category-list">
<div
v-for="cat in store.categories"
:key="cat.id"
class="category-item"
>
<div class="cat-left">
<span class="cat-dot" :style="{ background: cat.color }"></span>
<span class="cat-name">{{ cat.name }}</span>
</div>
<div class="cat-actions">
<el-button text size="small" @click="openCategoryDialog(cat)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDeleteCategory(cat)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</Transition>
<!-- 加载状态 -->
<div v-if="store.loading" class="loading-state">
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="store.filteredAnniversaries.length === 0" class="empty-state">
<div class="empty-icon">
<el-icon :size="64" color="#C8A2C8"><Calendar /></el-icon>
</div>
<h3>还没有纪念日呢</h3>
<p>点击新建纪念日记录重要的日子吧~</p>
<button class="action-btn action-btn--primary" @click="openCreate">
<el-icon :size="16"><Plus /></el-icon>
<span>新建纪念日</span>
</button>
</div>
<!-- 纪念日卡片列表 -->
<div v-else class="anniversary-list">
<!-- 即将到来 -->
<div v-if="store.upcomingAnniversaries.length > 0" class="list-section">
<div class="section-header">
<el-icon :size="16" color="#98D8C8"><Clock /></el-icon>
<span>即将到来</span>
<span class="section-count">{{ store.upcomingAnniversaries.length }}</span>
</div>
<div class="cards-grid">
<div
v-for="item in store.upcomingAnniversaries"
:key="item.id"
class="anniversary-card"
:class="[`card--${getCountdownType(item)}`, { 'card--today': item.days_until === 0 }]"
>
<div class="card-color-bar" :style="{ background: item.category?.color || '#FFB7C5' }"></div>
<div class="card-body">
<div class="card-top">
<div class="card-title-row">
<h4 class="card-title">{{ item.title }}</h4>
<div v-if="item.category" class="card-category-tag" :style="{ background: item.category.color + '20', color: item.category.color }">
{{ item.category.name }}
</div>
</div>
<div class="card-meta-row">
<span v-if="getYearCountText(item)" class="year-badge">{{ getYearCountText(item) }}</span>
</div>
</div>
<div class="card-bottom">
<div class="card-date">
<el-icon :size="14"><Calendar /></el-icon>
<span>{{ formatDate(item.date) }}</span>
</div>
<div class="card-countdown" :class="`countdown--${getCountdownType(item)}`">
<span>{{ getCountdownText(item) }}</span>
</div>
</div>
<div v-if="item.description" class="card-desc">{{ item.description }}</div>
<div class="card-actions">
<el-button text size="small" @click="openEdit(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDelete(item)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 已过去 -->
<div v-if="store.pastAnniversaries.length > 0" class="list-section">
<div class="section-header">
<el-icon :size="16" color="#C8A2C8"><Finished /></el-icon>
<span>已过去</span>
<span class="section-count">{{ store.pastAnniversaries.length }}</span>
</div>
<div class="cards-grid">
<div
v-for="item in store.pastAnniversaries"
:key="item.id"
class="anniversary-card card--past"
>
<div class="card-color-bar" :style="{ background: item.category?.color || '#C8A2C8' }"></div>
<div class="card-body">
<div class="card-top">
<div class="card-title-row">
<h4 class="card-title">{{ item.title }}</h4>
<div v-if="item.category" class="card-category-tag" :style="{ background: item.category.color + '20', color: item.category.color }">
{{ item.category.name }}
</div>
</div>
<div class="card-meta-row">
<span v-if="getYearCountText(item)" class="year-badge">{{ getYearCountText(item) }}</span>
</div>
</div>
<div class="card-bottom">
<div class="card-date">
<el-icon :size="14"><Calendar /></el-icon>
<span>{{ formatDate(item.date) }}</span>
</div>
<div class="card-countdown countdown--past">
<span>{{ getCountdownText(item) }}</span>
</div>
</div>
<div v-if="item.description" class="card-desc">{{ item.description }}</div>
<div class="card-actions">
<el-button text size="small" @click="openEdit(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDelete(item)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 弹窗 -->
<AnniversaryDialog
:visible="showAnniversaryDialog"
:edit-anniversary="editingAnniversary"
@update:visible="showAnniversaryDialog = $event"
/>
<AnniversaryCategoryDialog
:visible="showCategoryDialog"
:edit-category="editingCategory"
@update:visible="showCategoryDialog = $event"
/>
</div>
</template>
<style scoped lang="scss">
.anniversary-page {
min-height: calc(100vh - 60px);
padding: 24px;
display: flex;
justify-content: center;
}
.anniversary-container {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ============ 概览统计 ============
.overview-card {
display: flex;
align-items: center;
justify-content: space-around;
padding: 24px 32px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
animation: fadeInUp 0.4s ease;
}
.overview-item {
display: flex;
align-items: center;
gap: 14px;
.overview-icon {
width: 46px;
height: 46px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&--total {
background: rgba(255, 183, 197, 0.2);
color: #FFB7C5;
}
&--today {
background: rgba(255, 179, 71, 0.2);
color: #FFB347;
}
&--upcoming {
background: rgba(152, 216, 200, 0.2);
color: #98D8C8;
}
}
.overview-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.overview-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.overview-label {
font-size: 12px;
color: var(--text-secondary);
}
}
.overview-divider {
width: 1px;
height: 40px;
background: rgba(255, 183, 197, 0.2);
}
// ============ 工具栏 ============
.toolbar {
display: flex;
flex-direction: column;
gap: 12px;
.toolbar-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
&--primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.5);
}
}
&--outline {
background: white;
color: var(--text-primary);
border: 1px solid rgba(255, 183, 197, 0.3);
&:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(255, 183, 197, 0.05);
}
}
&--sm {
padding: 6px 12px;
font-size: 13px;
}
}
// ============ 分类筛选标签 ============
.group-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0;
}
.group-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: white;
border: 1px solid rgba(255, 183, 197, 0.15);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--primary);
border-color: var(--primary);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border-color: transparent;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.3);
}
.tab-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
}
// ============ 分类管理面板 ============
.category-manage-panel {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 20px;
animation: fadeInUp 0.3s ease;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.panel-empty {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 14px;
}
}
.category-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: var(--radius-sm);
transition: background 0.15s;
&:hover {
background: rgba(255, 183, 197, 0.06);
}
.cat-left {
display: flex;
align-items: center;
gap: 10px;
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.cat-name {
font-size: 14px;
color: var(--text-primary);
}
}
.cat-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
&:hover .cat-actions {
opacity: 1;
}
}
// ============ 加载 & 空状态 ============
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-secondary);
.loading-icon {
font-size: 32px;
color: var(--primary);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 60px 0;
animation: fadeInUp 0.4s ease;
.empty-icon {
opacity: 0.6;
}
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
}
// ============ 纪念日列表 ============
.list-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
padding: 4px 0;
.section-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(255, 183, 197, 0.15);
padding: 2px 8px;
border-radius: 10px;
}
}
.cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.anniversary-card {
position: relative;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: all 0.2s ease;
display: flex;
&:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
.card-actions {
opacity: 1;
}
}
&.card--today {
border: 2px solid #FFB347;
background: linear-gradient(135deg, rgba(255, 179, 71, 0.05) 0%, rgba(255, 183, 197, 0.05) 100%);
}
&.card--remind {
border: 1px solid rgba(255, 179, 71, 0.4);
}
&.card--past {
opacity: 0.75;
&:hover {
opacity: 1;
}
}
}
.card-color-bar {
width: 4px;
flex-shrink: 0;
}
.card-body {
flex: 1;
padding: 14px 12px;
min-width: 0;
display: flex;
flex-direction: column;
}
.card-top {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.card-category-tag {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
flex-shrink: 0;
}
.year-badge {
font-size: 11px;
font-weight: 500;
color: var(--accent);
background: rgba(200, 162, 200, 0.15);
padding: 1px 8px;
border-radius: 8px;
white-space: nowrap;
align-self: flex-start;
}
.card-meta-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-date {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
}
.card-countdown {
font-size: 13px;
font-weight: 600;
padding: 2px 10px;
border-radius: 12px;
white-space: nowrap;
&.countdown--today {
color: #FF8C00;
background: rgba(255, 179, 71, 0.15);
}
&.countdown--remind {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
}
&.countdown--upcoming {
color: var(--primary);
background: rgba(255, 183, 197, 0.12);
}
&.countdown--past {
color: var(--text-secondary);
background: rgba(200, 162, 200, 0.1);
}
}
.card-desc {
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 0;
padding-top: 8px;
margin-top: auto;
border-top: 1px dashed rgba(255, 183, 197, 0.15);
opacity: 0;
transition: opacity 0.15s;
}
// ============ 动画 ============
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import CalendarView from '@/views/CalendarView.vue'
</script>
<template>
<div class="calendar-page">
<CalendarView />
</div>
</template>
<style scoped lang="scss">
.calendar-page {
padding: 24px 32px;
height: calc(100vh - 60px);
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUIStore } from '@/stores/useUIStore'
import WeeklyView from './WeeklyView.vue'
import MonthlyView from './MonthlyView.vue'
const uiStore = useUIStore()
// 当前日历模式
const calendarMode = computed(() => uiStore.calendarMode)
// 视图选项
const viewOptions = [
{ label: '周', value: 'week' as const },
{ label: '月', value: 'monthly' as const }
]
// 切换视图模式
function handleModeChange(value: 'week' | 'monthly') {
uiStore.setCalendarMode(value)
}
</script>
<template>
<div class="calendar-view">
<!-- 分段控制器 -->
<div class="view-switcher">
<div class="segmented-control">
<button
v-for="option in viewOptions"
:key="option.value"
class="segment-btn"
:class="{ active: calendarMode === option.value }"
@click="handleModeChange(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 视图内容 -->
<div class="calendar-content">
<Transition name="view-fade" mode="out-in">
<WeeklyView v-if="calendarMode === 'week'" />
<MonthlyView v-else />
</Transition>
</div>
</div>
</template>
<style scoped lang="scss">
.calendar-view {
height: 100%;
display: flex;
flex-direction: column;
}
.view-switcher {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
padding: 0 4px;
}
.segmented-control {
display: flex;
background: var(--background);
padding: 4px;
border-radius: var(--radius-lg);
}
.segment-btn {
padding: 8px 24px;
border: none;
background: transparent;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--text-primary);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
}
.calendar-content {
flex: 1;
overflow: hidden;
}
// 视图切换动画
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease;
}
.view-fade-enter-from,
.view-fade-leave-to {
opacity: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,708 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ArrowLeft, ArrowRight, Calendar } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import { formatDate } from '@/utils/date'
import { getPriorityColor } from '@/utils/priority'
import type { Task } from '@/api/types'
const taskStore = useTaskStore()
const uiStore = useUIStore()
const currentDate = ref(new Date())
const selectedDate = ref<string | null>(null)
const isTransitioning = ref(false)
// 星期标题
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 当前显示的年月
const currentYear = computed(() => currentDate.value.getFullYear())
const currentMonth = computed(() => currentDate.value.getMonth())
// 月份标题
const monthTitle = computed(() => {
return `${currentYear.value}${currentMonth.value + 1}`
})
// 获取当月日历数据
const calendarDays = computed(() => {
const year = currentYear.value
const month = currentMonth.value
// 当月第一天
const firstDay = new Date(year, month, 1)
// 当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 当月天数
const daysInMonth = lastDay.getDate()
// 当月第一天是星期几0-6, 0是周日
const startWeekday = firstDay.getDay()
const days: Array<{
date: Date
dateStr: string
isCurrentMonth: boolean
isToday: boolean
tasks: Task[]
}> = []
// 上个月的补充天数
const prevMonth = new Date(year, month, 0)
const prevMonthDays = prevMonth.getDate()
for (let i = startWeekday - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthDays - i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
// 当月的天数
const today = new Date()
const todayStr = formatDate(today)
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: true,
isToday: dateStr === todayStr,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
// 下个月的补充天数补齐到42天保证6行
const remainingDays = 42 - days.length
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
return days
})
// 整页滑动动画:克隆旧网格 → 更新数据 → 新网格从偏移滑入 → 旧快照淡出
async function slideAnimate(updateFn: () => void, direction: 'up' | 'down') {
if (isTransitioning.value) return
isTransitioning.value = true
const container = document.querySelector('.monthly-view .calendar-container') as HTMLElement
const grid = document.querySelector('.monthly-view .calendar-grid') as HTMLElement
if (!container || !grid) { isTransitioning.value = false; return }
// 快照旧网格
const snapshot = grid.cloneNode(true) as HTMLElement
snapshot.setAttribute('data-snapshot', 'true')
snapshot.style.position = 'absolute'
snapshot.style.top = '0'
snapshot.style.left = '0'
snapshot.style.width = '100%'
snapshot.style.height = '100%'
snapshot.style.zIndex = '1'
snapshot.style.transition = 'none'
snapshot.style.pointerEvents = 'none'
container.style.position = 'relative'
// 把快照插到 grid 后面grid 在上层z-index 更高)
grid.style.position = 'relative'
grid.style.zIndex = '2'
container.appendChild(snapshot)
const gridHeight = grid.offsetHeight
// 设置新 grid 的起始偏移
const startY = direction === 'down' ? gridHeight : -gridHeight
grid.style.transition = 'none'
grid.style.transform = `translateY(${startY}px)`
// 更新数据
updateFn()
await nextTick()
// 触发重排
void container.offsetHeight
// 新 grid 滑入
grid.style.transition = 'transform 0.8s cubic-bezier(0.25, 1, 0.5, 1)'
grid.style.transform = 'translateY(0)'
// 旧快照同步滑出
snapshot.style.transition = 'transform 0.8s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.5s ease'
snapshot.style.transform = `translateY(${-startY}px)`
setTimeout(() => {
snapshot.style.opacity = '0'
}, 300)
setTimeout(() => {
container.removeChild(snapshot)
grid.style.transition = ''
grid.style.transform = ''
grid.style.zIndex = ''
grid.style.position = ''
container.style.position = ''
isTransitioning.value = false
}, 900)
}
// 上一个月
function prevMonth() {
slideAnimate(() => {
currentDate.value = new Date(currentYear.value, currentMonth.value - 1, 1)
}, 'up')
}
// 下一个月
function nextMonth() {
slideAnimate(() => {
currentDate.value = new Date(currentYear.value, currentMonth.value + 1, 1)
}, 'down')
}
// 回到今天
function goToday() {
const now = new Date()
const targetYear = now.getFullYear()
const targetMonth = now.getMonth()
if (targetYear === currentYear.value && targetMonth === currentMonth.value) return
const oldYear = currentYear.value
const oldMonth = currentMonth.value
const diff = (targetYear * 12 + targetMonth) - (oldYear * 12 + oldMonth)
slideAnimate(() => {
currentDate.value = now
}, diff < 0 ? 'up' : 'down')
}
// 点击日期
function handleDateClick(dateStr: string) {
selectedDate.value = selectedDate.value === dateStr ? null : dateStr
}
// 打开任务详情/编辑
function handleTaskClick(task: Task, event: Event) {
event.stopPropagation()
uiStore.openTaskDialog(task)
}
// 获取任务完成状态样式
function getTaskClass(task: Task): string {
return task.is_completed ? 'completed' : ''
}
</script>
<template>
<div class="monthly-view">
<!-- 月份导航 -->
<div class="month-nav">
<div class="nav-left">
<h2 class="month-title">{{ monthTitle }}</h2>
<span class="task-summary">
{{ taskStore.totalTasks.length }} 个任务
</span>
</div>
<div class="nav-right">
<el-button-group class="mode-switch">
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'week' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('week')"
>
</el-button>
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'monthly' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('monthly')"
>
</el-button>
</el-button-group>
<el-button class="nav-btn" @click="goToday">
<el-icon><Calendar /></el-icon>
今天
</el-button>
<el-button-group class="nav-group">
<el-button class="nav-btn" @click="prevMonth">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button class="nav-btn" @click="nextMonth">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 日历网格 -->
<div class="calendar-container">
<!-- 星期标题 -->
<div class="weekdays">
<div v-for="day in weekDays" :key="day" class="weekday">{{ day }}</div>
</div>
<!-- 日期格子 -->
<div class="calendar-grid">
<div
v-for="(day, index) in calendarDays"
:key="day.dateStr"
:data-date="day.dateStr"
class="day-cell"
:class="{
'other-month': !day.isCurrentMonth,
'is-today': day.isToday,
'selected': selectedDate === day.dateStr,
'has-tasks': day.tasks.length > 0
}"
@click="handleDateClick(day.dateStr)"
>
<div class="day-header">
<span class="day-number">{{ day.date.getDate() }}</span>
<span v-if="day.tasks.length > 0" class="task-count">
{{ day.tasks.length }}
</span>
</div>
<!-- 任务列表 -->
<div v-if="day.tasks.length > 0" class="task-list">
<div
v-for="task in day.tasks.slice(0, 2)"
:key="task.id"
class="mini-task"
:class="getTaskClass(task)"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-dot"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<span class="task-title">{{ task.title }}</span>
</div>
<div v-if="day.tasks.length > 2" class="more-tasks">
+{{ day.tasks.length - 2 }} 更多
</div>
</div>
</div>
</div>
</div>
<!-- 选中日期的任务详情面板 -->
<Transition name="slide">
<div
v-if="selectedDate"
class="task-detail-panel"
>
<div class="panel-header">
<h3>{{ selectedDate }} 的任务</h3>
<el-button text @click="selectedDate = null">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="panel-content">
<div
v-for="task in taskStore.tasksByDate.get(selectedDate) || []"
:key="task.id"
class="detail-task-item"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-dot"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<div class="task-info">
<span class="task-title" :class="{ completed: task.is_completed }">
{{ task.title }}
</span>
<span v-if="task.category" class="task-category">
{{ task.category.name }}
</span>
</div>
</div>
<div
v-if="(taskStore.tasksByDate.get(selectedDate) || []).length === 0"
class="no-tasks"
>
这一天没有任务呢~
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.monthly-view {
height: 100%;
display: flex;
flex-direction: column;
}
.month-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 0 4px;
}
.nav-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.month-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.task-summary {
font-size: 14px;
color: var(--text-secondary);
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.mode-switch {
.mode-btn {
padding: 8px 16px;
font-size: 13px;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.nav-btn {
border-radius: var(--radius-md);
border-color: var(--secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.nav-group {
.nav-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.calendar-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--background-dark);
border-bottom: 1px solid var(--secondary);
}
.weekday {
padding: 8px;
text-align: center;
font-weight: 600;
color: var(--text-secondary);
font-size: 12px;
}
.calendar-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.day-cell {
padding: 4px 6px;
border-right: 1px solid var(--background-dark);
border-bottom: 1px solid var(--background-dark);
cursor: pointer;
transition: background 0.35s ease, opacity 0.35s ease;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&:nth-child(7n) {
border-right: none;
}
&:hover {
background: var(--background);
}
.day-number {
transition: color 0.35s ease, opacity 0.35s ease;
}
.task-list,
.task-count {
transition: opacity 0.35s ease;
}
&.other-month {
background: var(--background);
.day-number {
color: var(--text-secondary);
opacity: 0.4;
}
.task-list,
.task-count {
opacity: 0.35;
}
}
&.is-today {
background: rgba(255, 183, 197, 0.08);
.day-number {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
}
&.selected {
background: rgba(255, 183, 197, 0.15);
outline: 2px solid var(--primary);
outline-offset: -2px;
.day-number {
font-weight: 700;
color: var(--primary);
}
}
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3px;
}
.day-number {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.task-count {
font-size: 10px;
padding: 1px 5px;
background: var(--primary);
color: white;
border-radius: 10px;
}
.task-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.mini-task {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
background: var(--background);
border-radius: var(--radius-sm);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--background-dark);
transform: translateX(2px);
}
&.completed {
opacity: 0.6;
.task-title {
text-decoration: line-through;
}
}
.task-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
}
.priority-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.more-tasks {
font-size: 11px;
color: var(--text-secondary);
text-align: center;
padding: 2px;
}
// 任务详情面板
.task-detail-panel {
position: absolute;
right: 24px;
top: 80px;
width: 280px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 10;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--background);
border-bottom: 1px solid var(--background-dark);
h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
}
.panel-content {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.detail-task-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--background);
}
.task-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.task-title {
font-size: 14px;
color: var(--text-primary);
&.completed {
text-decoration: line-through;
color: var(--text-secondary);
}
}
.task-category {
font-size: 11px;
color: var(--text-secondary);
}
}
.no-tasks {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 14px;
}
// 滑入动画
.slide-enter-active,
.slide-leave-active {
transition: all var(--transition-normal);
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(20px);
}
@media (max-width: 768px) {
.day-cell {
min-height: 60px;
padding: 4px;
}
.mini-task {
font-size: 10px;
padding: 2px 4px;
}
.month-nav {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadProps } from 'element-plus'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const userStore = useUserSettingsStore()
const form = ref({
nickname: '',
avatar: '',
signature: '',
birthday: '',
email: '',
site_name: ''
})
const saving = ref(false)
onMounted(() => {
form.value.nickname = userStore.nickname
form.value.avatar = userStore.avatar
form.value.signature = userStore.signature
form.value.birthday = userStore.birthday
form.value.email = userStore.email
form.value.site_name = userStore.siteName
})
const displayAvatar = computed(() => {
const name = form.value.nickname || '爱莉希雅'
return name.charAt(0)
})
function beforeAvatarUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件哦~')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB 呢~')
return false
}
return true
}
const handleAvatarChange: UploadProps['onChange'] = (uploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!beforeAvatarUpload(file)) return
const reader = new FileReader()
reader.onload = (e) => {
form.value.avatar = e.target?.result as string
}
reader.readAsDataURL(file)
}
function clearAvatar() {
form.value.avatar = ''
}
async function handleSave() {
saving.value = true
try {
await userStore.updateSettings({
nickname: form.value.nickname,
avatar: form.value.avatar,
signature: form.value.signature,
birthday: form.value.birthday || undefined,
email: form.value.email || undefined,
site_name: form.value.site_name || undefined
})
userStore.syncFromSettings(userStore.settings!)
ElMessage.success('个人信息保存成功~')
} catch {
ElMessage.error('保存失败了呢,请稍后再试~')
} finally {
saving.value = false
}
}
</script>
<template>
<div class="profile-page">
<div class="profile-card">
<div class="card-header">
<h2 class="page-title">个人信息</h2>
<p class="page-subtitle">管理你的个人资料和展示信息</p>
</div>
<div class="card-body">
<!-- 头像区域 -->
<div class="avatar-section">
<div class="avatar-wrapper">
<el-avatar
v-if="form.avatar"
:size="88"
:src="form.avatar"
class="profile-avatar"
/>
<el-avatar
v-else
:size="88"
class="profile-avatar default-avatar"
>
{{ displayAvatar }}
</el-avatar>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/*"
:on-change="handleAvatarChange"
>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</el-upload>
</div>
<p v-if="form.signature" class="profile-signature">{{ form.signature }}</p>
<el-button
v-if="form.avatar"
text
type="danger"
size="small"
@click="clearAvatar"
>
移除头像
</el-button>
</div>
<!-- 表单区域 -->
<el-form
:model="form"
label-position="top"
class="profile-form"
>
<el-form-item label="网站名称">
<el-input
v-model="form.site_name"
placeholder="给你的待办应用取个名字~"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="昵称">
<el-input
v-model="form.nickname"
placeholder="给自己取个名字吧~"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="个性签名">
<el-input
v-model="form.signature"
type="textarea"
placeholder="写点什么来介绍自己吧..."
maxlength="200"
show-word-limit
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</el-form-item>
<div class="form-row">
<el-form-item label="生日" class="form-item-half">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="选择你的生日"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:teleported="false"
/>
</el-form-item>
<el-form-item label="邮箱" class="form-item-half">
<el-input
v-model="form.email"
placeholder="your@email.com"
type="email"
/>
</el-form-item>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="saving"
@click="handleSave"
class="save-btn"
>
保存信息
</el-button>
</div>
</el-form>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.profile-page {
min-height: calc(100vh - 60px);
padding: 32px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.profile-card {
width: 100%;
max-width: 580px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
animation: fadeInUp 0.4s ease;
}
.card-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
padding: 32px;
color: white;
.page-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
.page-subtitle {
font-size: 14px;
opacity: 0.85;
margin: 0;
}
}
.card-body {
padding: 32px;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.avatar-wrapper {
position: relative;
cursor: pointer;
margin-bottom: 8px;
.profile-avatar {
border: 3px solid var(--primary);
box-shadow: var(--shadow-md);
}
.default-avatar {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
font-size: 32px;
font-weight: 600;
}
.avatar-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .avatar-overlay {
opacity: 1;
}
}
.profile-signature {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 8px;
text-align: center;
max-width: 240px;
}
.profile-form {
:deep(.el-form-item__label) {
font-weight: 500;
color: var(--text-primary);
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
border-radius: var(--radius-md);
}
:deep(.el-date-editor) {
.el-input__wrapper {
border-radius: var(--radius-md);
}
}
}
.form-row {
display: flex;
gap: 16px;
.form-item-half {
flex: 1;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(255, 183, 197, 0.2);
.save-btn {
min-width: 120px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
font-weight: 500;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.4);
}
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.profile-page {
padding: 16px;
}
.card-header {
padding: 24px;
}
.card-body {
padding: 24px;
}
.form-row {
flex-direction: column;
gap: 0;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import QuadrantView from '@/views/QuadrantView.vue'
</script>
<template>
<div class="quadrant-page">
<QuadrantView />
</div>
</template>
<style scoped lang="scss">
.quadrant-page {
padding: 24px 32px;
height: calc(100vh - 60px);
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import QuadrantTaskCard from '@/components/QuadrantTaskCard.vue'
import type { TaskResponse } from '@/api/tasks'
const taskStore = useTaskStore()
type QuadrantKey = 'q1' | 'q2' | 'q3' | 'q4'
interface QuadrantInfo {
title: string
subtitle: string
description: string
color: string
bgColor: string
isUrgent?: boolean
}
const quadrantConfig: Record<QuadrantKey, QuadrantInfo> = {
q1: {
title: 'Q1 重要紧急',
subtitle: '立即执行',
description: '需要优先处理的紧急任务',
color: 'var(--priority-q1)',
bgColor: 'rgba(255, 107, 107, 0.08)',
isUrgent: true
},
q2: {
title: 'Q2 重要不紧急',
subtitle: '规划执行',
description: '对长期目标有帮助的任务',
color: 'var(--priority-q2)',
bgColor: 'rgba(255, 179, 71, 0.08)'
},
q3: {
title: 'Q3 不重要紧急',
subtitle: '委托他人',
description: '可以考虑委托或快速处理',
color: 'var(--priority-q3)',
bgColor: 'rgba(152, 216, 200, 0.08)'
},
q4: {
title: 'Q4 不重要不紧急',
subtitle: '适当考虑',
description: '可以稍后处理或删除',
color: 'var(--priority-q4)',
bgColor: 'rgba(200, 162, 200, 0.08)'
}
}
const quadrantOrder: QuadrantKey[] = ['q2', 'q1', 'q4', 'q3']
function sortByDueDate(a: TaskResponse, b: TaskResponse): number {
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
}
const tasksByPriority = computed(() => {
const grouped: Record<QuadrantKey, TaskResponse[]> = { q1: [], q2: [], q3: [], q4: [] }
for (const t of taskStore.totalTasks as TaskResponse[]) {
if (!t.is_completed && grouped[t.priority as QuadrantKey]) {
grouped[t.priority as QuadrantKey].push(t)
}
}
for (const key of quadrantOrder) {
grouped[key].sort(sortByDueDate)
}
return grouped
})
const totalActiveTasks = computed(() => {
return tasksByPriority.value.q1.length +
tasksByPriority.value.q2.length +
tasksByPriority.value.q3.length +
tasksByPriority.value.q4.length
})
</script>
<template>
<div class="quadrant-view">
<div class="quadrant-header">
<h2 class="quadrant-title">
四象限视图
<span class="task-count">{{ totalActiveTasks }} 项待办</span>
</h2>
<p class="quadrant-subtitle">按照重要紧急程度管理你的任务</p>
</div>
<div v-if="taskStore.loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="totalActiveTasks === 0" class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">还没有待办任务呢~</p>
<p class="empty-hint">点击右下角的按钮创建一个新任务吧</p>
</div>
<div v-else class="quadrant-grid">
<div
v-for="key in quadrantOrder"
:key="key"
class="quadrant"
:class="key"
:style="{ '--quadrant-color': quadrantConfig[key].color, '--quadrant-bg': quadrantConfig[key].bgColor }"
>
<div class="quadrant-header-inner">
<div class="quadrant-label" :class="{ urgent: quadrantConfig[key].isUrgent }">
{{ quadrantConfig[key].subtitle }}
</div>
<h3 class="quadrant-title-inner">{{ quadrantConfig[key].title }}</h3>
<p class="quadrant-desc">{{ quadrantConfig[key].description }}</p>
<span class="quadrant-count">{{ tasksByPriority[key].length }}</span>
</div>
<div class="quadrant-content">
<TransitionGroup name="task-list" tag="div" class="task-list">
<QuadrantTaskCard
v-for="task in tasksByPriority[key]"
:key="task.id"
:task="task"
/>
</TransitionGroup>
<div v-if="tasksByPriority[key].length === 0" class="quadrant-empty">
<span class="quadrant-empty-text">暂无任务</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.quadrant-view {
height: 100%;
display: flex;
flex-direction: column;
}
.quadrant-header {
margin-bottom: 24px;
}
.quadrant-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
.task-count {
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 20px;
}
}
.quadrant-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-top: 4px;
}
.quadrant-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 16px;
min-height: 0;
}
.quadrant {
background: var(--quadrant-bg);
border-radius: var(--radius-lg);
border: 2px solid transparent;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
border-color: var(--quadrant-color);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
&.q1 .quadrant-label.urgent {
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.quadrant-header-inner {
padding: 16px 20px;
position: relative;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.quadrant-label {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: var(--quadrant-color);
color: white;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quadrant-title-inner {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.quadrant-desc {
font-size: 12px;
color: var(--text-secondary);
}
.quadrant-count {
position: absolute;
top: 16px;
right: 16px;
font-size: 28px;
font-weight: 700;
color: var(--quadrant-color);
opacity: 0.6;
}
.quadrant-content {
flex: 1;
overflow-y: auto;
padding: 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.quadrant-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
}
.quadrant-empty-text {
font-size: 12px;
color: var(--text-secondary);
opacity: 0.5;
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-list-enter-active,
.task-list-leave-active {
transition: all 0.3s ease;
}
.task-list-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.task-list-leave-to {
opacity: 0;
transform: translateX(20px);
}
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
.empty-icon {
font-size: 64px;
color: var(--primary);
margin-bottom: 16px;
animation: twinkle 2s ease-in-out infinite;
}
.empty-text {
font-size: 18px;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: var(--text-secondary);
}
}
@keyframes twinkle {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
@media (max-width: 900px) {
.quadrant-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.quadrant {
max-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,669 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore'
import { get, post, del } from '@/api/request'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
const userStore = useUserSettingsStore()
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const habitStore = useHabitStore()
const saving = ref(false)
const exporting = ref(false)
const viewOptions = [
{ label: '列表', value: 'list' },
{ label: '日历', value: 'calendar' },
{ label: '四象限', value: 'quadrant' }
]
const sortByOptions = [
{ label: '创建时间', value: 'created_at' },
{ label: '截止日期', value: 'due_date' },
{ label: '优先级', value: 'priority' }
]
const sortOrderOptions = [
{ label: '降序 (新到旧)', value: 'desc' },
{ label: '升序 (旧到新)', value: 'asc' }
]
const prefs = ref({
site_name: '爱莉希雅待办',
default_view: 'list',
default_sort_by: 'priority',
default_sort_order: 'desc'
})
onMounted(() => {
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
prefs.value.default_view = userStore.defaultView || 'list'
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
})
async function handleSave() {
saving.value = true
try {
await userStore.updateSettings({
site_name: prefs.value.site_name,
default_view: prefs.value.default_view,
default_sort_by: prefs.value.default_sort_by,
default_sort_order: prefs.value.default_sort_order
})
userStore.syncFromSettings(userStore.settings!)
// 保存排序后立即应用
taskStore.setFilters({
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
})
ElMessage.success('偏好设置已保存~')
} catch {
ElMessage.error('保存失败了呢,请稍后再试~')
} finally {
saving.value = false
}
}
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 url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('数据导出成功~')
} catch {
ElMessage.error('导出失败了呢~')
} finally {
exporting.value = false
}
}
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
try {
await ElMessageBox.confirm(
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
'确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
)
const text = await file.text()
const data = JSON.parse(text)
if (!data.tasks || !Array.isArray(data.tasks)) {
ElMessage.error('数据格式不正确呢~')
return
}
// 先删除所有现有数据
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('导入失败了呢~')
}
}
}
input.click()
}
async function clearCompleted() {
try {
await ElMessageBox.confirm(
'确定要清除所有已完成的任务吗?这个操作不可撤销哦~',
'确认清除',
{ confirmButtonText: '确定清除', cancelButtonText: '取消', type: 'warning' }
)
const completed = taskStore.completedTasks
for (const task of completed) {
await del(`/tasks/${task.id}`)
}
await taskStore.fetchTasks()
ElMessage.success(`已清除 ${completed.length} 个已完成的任务~`)
} catch {
// 用户取消
}
}
</script>
<template>
<div class="settings-page">
<div class="settings-container">
<!-- 应用偏好 -->
<div class="settings-card">
<div class="card-header">
<div class="card-icon">
<el-icon :size="24"><Brush /></el-icon>
</div>
<div>
<h3 class="card-title">应用偏好</h3>
<p class="card-subtitle">自定义你的使用体验</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">网站名称</span>
<span class="label-desc">显示在浏览器标签页和顶部导航栏</span>
</div>
<el-input
v-model="prefs.site_name"
placeholder="爱莉希雅待办"
maxlength="50"
style="width: 200px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">默认视图</span>
<span class="label-desc">打开应用时首先显示的页面</span>
</div>
<el-select v-model="prefs.default_view" style="width: 180px">
<el-option
v-for="opt in viewOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">默认排序方式</span>
<span class="label-desc">任务列表的默认排序依据</span>
</div>
<el-select v-model="prefs.default_sort_by" style="width: 180px">
<el-option
v-for="opt in sortByOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">默认排序顺序</span>
<span class="label-desc">列表的默认排列方向</span>
</div>
<el-select v-model="prefs.default_sort_order" style="width: 180px">
<el-option
v-for="opt in sortOrderOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="saving"
@click="handleSave"
class="save-btn"
>
保存偏好
</el-button>
</div>
</div>
</div>
<!-- 数据管理 -->
<div class="settings-card">
<div class="card-header">
<div class="card-icon">
<el-icon :size="24"><FolderOpened /></el-icon>
</div>
<div>
<h3 class="card-title">数据管理</h3>
<p class="card-subtitle">备份恢复和清理你的数据</p>
</div>
</div>
<div class="card-body">
<div class="data-actions">
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导出数据</span>
<span class="action-desc">将所有任务分类标签和习惯导出为 JSON 文件</span>
</div>
<el-button
:loading="exporting"
@click="exportData"
class="action-btn"
>
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导入数据</span>
<span class="action-desc warning"> JSON 文件恢复数据会覆盖现有数据</span>
</div>
<el-button
@click="importData"
class="action-btn"
>
<el-icon><Upload /></el-icon>
导入
</el-button>
</div>
<div class="data-action-item">
<div class="action-info">
<span class="action-title">清除已完成任务</span>
<span class="action-desc danger">删除所有标记为已完成的任务不可撤销</span>
</div>
<el-button
type="danger"
plain
@click="clearCompleted"
class="action-btn"
>
<el-icon><Delete /></el-icon>
清除
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.settings-page {
min-height: calc(100vh - 60px);
padding: 32px;
display: flex;
justify-content: center;
}
.settings-container {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-card {
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
animation: fadeInUp 0.4s ease;
&:nth-child(2) {
animation-delay: 0.1s;
animation-fill-mode: both;
}
}
.card-header {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 28px;
border-bottom: 1px solid rgba(255, 183, 197, 0.15);
.card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 183, 197, 0.2) 0%, rgba(200, 162, 200, 0.2) 100%);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
flex-shrink: 0;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.card-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
}
}
.card-body {
padding: 24px 28px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
&:last-of-type {
border-bottom: none;
}
.setting-label {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
margin-right: 24px;
.label-text {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.label-desc {
font-size: 12px;
color: var(--text-secondary);
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 183, 197, 0.15);
.save-btn {
min-width: 120px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
font-weight: 500;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.4);
}
}
}
.data-actions {
display: flex;
flex-direction: column;
}
.data-action-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
&:last-child {
border-bottom: none;
}
.action-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
margin-right: 24px;
.action-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.action-desc {
font-size: 12px;
color: var(--text-secondary);
&.warning {
color: var(--warning);
}
&.danger {
color: var(--danger);
}
}
}
.action-btn {
min-width: 100px;
border-radius: var(--radius-md);
flex-shrink: 0;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.settings-page {
padding: 16px;
}
.card-header,
.card-body {
padding: 16px;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.setting-label {
margin-right: 0;
}
:deep(.el-select) {
width: 100% !important;
}
}
.data-action-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.action-info {
margin-right: 0;
}
.action-btn {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { matchWithPinyin, highlightMatch } from '@/utils/pinyin'
import TaskCard from '@/components/TaskCard.vue'
const taskStore = useTaskStore()
const statusText = computed(() => {
switch (taskStore.filters.status) {
case 'active': return '进行中'
case 'completed': return '已完成'
default: return '全部任务'
}
})
// 筛选条件的唯一标识,用于强制重新渲染列表
const filterKey = computed(() => {
const { status, category_id, sort_by, sort_order, search } = taskStore.filters
return `${status}-${category_id || 'all'}-${sort_by}-${sort_order}-${search || ''}`
})
// 搜索框
const searchInput = ref(taskStore.filters.search || '')
// 使用防抖更新搜索条件
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(searchInput, (value) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
taskStore.setFilters({ search: value })
}, 300)
})
// 组件卸载时清理防抖计时器
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
// 清空搜索
const clearSearch = () => {
searchInput.value = ''
}
// 生成搜索建议
const searchSuggestions = computed(() => {
const suggestions: string[] = []
const addedSet = new Set<string>()
taskStore.activeTasks.forEach(task => {
// 添加标题
if (task.title && !addedSet.has(task.title.toLowerCase())) {
suggestions.push(task.title)
addedSet.add(task.title.toLowerCase())
}
// 添加标签
task.tags?.forEach(tag => {
if (!addedSet.has(tag.name.toLowerCase())) {
suggestions.push(tag.name)
addedSet.add(tag.name.toLowerCase())
}
})
})
return suggestions
})
// 查询建议
const querySearch = (queryString: string, cb: (results: { value: string }[]) => void) => {
const results = queryString
? searchSuggestions.value
.filter(item => matchWithPinyin(item, queryString))
.slice(0, 8)
.map(item => ({ value: item }))
: searchSuggestions.value
.slice(0, 8)
.map(item => ({ value: item }))
cb(results)
}
// 选中建议
const handleSelect = (item: { value: string }) => {
searchInput.value = item.value
taskStore.setFilters({ search: item.value })
}
// 高亮建议中的匹配文本
const suggestionHighlight = (item: { value: string }) => {
return highlightMatch(item.value, searchInput.value)
}
</script>
<template>
<div class="task-list">
<div class="list-header">
<h2 class="list-title">
{{ statusText }}
<span class="task-count">{{ taskStore.tasks.length }}</span>
</h2>
</div>
<!-- 搜索框 -->
<div class="search-box">
<el-autocomplete
v-model="searchInput"
:fetch-suggestions="querySearch"
placeholder="搜索任务标题、描述或标签..."
clearable
class="search-input"
popper-class="search-suggestions"
@clear="clearSearch"
@select="handleSelect"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #default="{ item }">
<span class="suggestion-item" v-html="suggestionHighlight(item)" />
</template>
</el-autocomplete>
</div>
<div v-if="taskStore.loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="taskStore.tasks.length === 0" class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">
{{ searchInput ? '没有找到匹配的任务呢~' : '还没有任务呢~' }}
</p>
<p v-if="!searchInput" class="empty-hint">点击右下角的按钮创建一个新任务吧</p>
<p v-else class="empty-hint">试试换个关键词搜索吧</p>
</div>
<div v-else :key="filterKey" class="task-grid">
<TaskCard
v-for="(task, index) in taskStore.tasks"
:key="task.id"
:task="task"
:style="{ animationDelay: `${index * 50}ms` }"
class="task-item"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.task-list {
max-width: 900px;
margin: 0 auto;
}
.list-header {
margin-bottom: 16px;
}
.list-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
.task-count {
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 20px;
}
}
.search-box {
margin-bottom: 24px;
.search-input {
width: 100%;
:deep(.el-input__wrapper) {
border-radius: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid transparent;
transition: all 0.2s ease;
padding: 8px 16px;
min-height: 48px;
&:hover {
border-color: var(--primary-light);
}
&.is-focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255, 183, 197, 0.2);
}
}
:deep(.el-input__inner) {
font-size: 16px;
}
:deep(.el-input__prefix) {
color: var(--text-secondary);
font-size: 18px;
}
:deep(.el-input__clear) {
font-size: 16px;
}
}
}
.suggestion-item {
font-size: 14px;
:deep(.search-highlight) {
background: linear-gradient(135deg, rgba(255, 183, 197, 0.4) 0%, rgba(255, 183, 197, 0.6) 100%);
color: var(--primary-dark);
padding: 0 2px;
border-radius: 3px;
font-weight: 500;
}
}
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.task-item {
animation: fadeInUp 0.3s ease forwards;
opacity: 0;
}
.loading-state {
padding: 20px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
.empty-icon {
font-size: 64px;
color: var(--primary);
margin-bottom: 16px;
animation: twinkle 2s ease-in-out infinite;
}
.empty-text {
font-size: 18px;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: var(--text-secondary);
}
}
// 列表项入场动画(筛选切换时整体淡入)
.task-grid {
animation: filterFadeIn 0.2s ease;
}
@keyframes filterFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import CategorySidebar from '@/components/CategorySidebar.vue'
import TaskList from '@/views/TaskList.vue'
import { useUIStore } from '@/stores/useUIStore'
const uiStore = useUIStore()
</script>
<template>
<div class="task-list-layout">
<CategorySidebar />
<main class="main-content" :class="{ 'sidebar-collapsed': uiStore.sidebarCollapsed }">
<Transition name="view-fade" mode="out-in">
<TaskList />
</Transition>
</main>
</div>
</template>
<style scoped lang="scss">
.task-list-layout {
display: flex;
min-height: calc(100vh - 60px);
}
.main-content {
flex: 1;
padding: 24px;
margin-left: 240px;
will-change: margin-left;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.sidebar-collapsed {
margin-left: 0;
}
@media (max-width: 768px) {
margin-left: 0;
padding: 16px;
}
}
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.view-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.view-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -0,0 +1,447 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowLeft, ArrowRight, Calendar } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import {
formatDate,
getWeekDays,
getWeekNumber,
isToday
} from '@/utils/date'
import { getPriorityColor } from '@/utils/priority'
import type { Task } from '@/api/types'
const taskStore = useTaskStore()
const uiStore = useUIStore()
const currentDate = ref(new Date())
// 星期标题
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 时间刻度0-23小时
const hours = Array.from({ length: 24 }, (_, i) => i)
// 当前显示的周数据
const weekData = computed(() => {
const days = getWeekDays(currentDate.value)
return days.map((date, index) => ({
date,
dateStr: formatDate(date),
dayOfWeek: index,
dayName: weekDays[index] ?? '',
dayNumber: date.getDate(),
month: date.getMonth() + 1,
isToday: isToday(date),
tasks: taskStore.tasksByDate.get(formatDate(date)) || []
}))
})
// 周标题
const weekTitle = computed(() => {
const year = currentDate.value.getFullYear()
const weekNum = getWeekNumber(currentDate.value)
return `${year}年 第${weekNum}`
})
// 上一周
function prevWeek() {
const d = new Date(currentDate.value)
d.setDate(d.getDate() - 7)
currentDate.value = d
}
// 下一周
function nextWeek() {
const d = new Date(currentDate.value)
d.setDate(d.getDate() + 7)
currentDate.value = d
}
// 回到本周
function goToday() {
currentDate.value = new Date()
}
// 获取任务在时间轴上的位置(小时)
function getTaskHour(task: Task): number {
if (!task.due_date) return -1
const date = new Date(task.due_date)
return date.getHours()
}
// 获取某天某小时的任务列表
function getTasksForHour(dayIndex: number, hour: number): Task[] {
const day = weekData.value[dayIndex]
if (!day) return []
return day.tasks.filter(task => {
const taskHour = getTaskHour(task)
return taskHour === hour
})
}
// 格式化小时显示
function formatHour(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`
}
// 打开任务详情/编辑
function handleTaskClick(task: Task, event: Event) {
event.stopPropagation()
uiStore.openTaskDialog(task)
}
</script>
<template>
<div class="weekly-view">
<!-- 周导航 -->
<div class="week-nav">
<div class="nav-left">
<h2 class="week-title">{{ weekTitle }}</h2>
<span class="task-summary">
{{ taskStore.totalTasks.length }} 个任务
</span>
</div>
<div class="nav-right">
<el-button-group class="mode-switch">
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'week' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('week')"
>
</el-button>
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'monthly' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('monthly')"
>
</el-button>
</el-button-group>
<el-button class="nav-btn" @click="goToday">
<el-icon><Calendar /></el-icon>
本周
</el-button>
<el-button-group class="nav-group">
<el-button class="nav-btn" @click="prevWeek">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button class="nav-btn" @click="nextWeek">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 时间轴容器 -->
<div class="timeline-container">
<!-- 日期头部 -->
<div class="timeline-header">
<div class="time-label"></div>
<div
v-for="day in weekData"
:key="day.dateStr"
class="day-header"
:class="{ 'is-today': day.isToday }"
>
<span class="day-name">{{ day.dayName }}</span>
<span class="day-number">{{ day.month }}/{{ day.dayNumber }}</span>
<span v-if="day.tasks.length > 0" class="task-badge">
{{ day.tasks.length }}
</span>
</div>
</div>
<!-- 时间轴内容 -->
<div class="timeline-body">
<!-- 左侧时间刻度 -->
<div class="time-column">
<div v-for="hour in hours" :key="hour" class="time-cell">
{{ formatHour(hour) }}
</div>
</div>
<!-- 每天的任务列 -->
<div
v-for="(day, dayIndex) in weekData"
:key="day.dateStr"
class="day-column"
:class="{ 'is-today': day.isToday }"
>
<div v-for="hour in hours" :key="hour" class="hour-cell">
<!-- 该小时的任务 -->
<div
v-for="task in getTasksForHour(dayIndex, hour)"
:key="task.id"
class="task-block"
:class="{ completed: task.is_completed }"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-indicator"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<span class="task-title">{{ task.title }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.weekly-view {
height: 100%;
display: flex;
flex-direction: column;
}
.week-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.nav-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.week-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.task-summary {
font-size: 14px;
color: var(--text-secondary);
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.mode-switch {
.mode-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.nav-btn {
border-radius: var(--radius-md);
border-color: var(--secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.nav-group {
.nav-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.timeline-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.timeline-header {
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
background: var(--background-dark);
border-bottom: 1px solid var(--secondary);
}
.time-label {
padding: 12px 8px;
}
.day-header {
padding: 12px 8px;
text-align: center;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
&.is-today {
background: rgba(255, 183, 197, 0.2);
.day-name,
.day-number {
color: var(--primary);
font-weight: 600;
}
}
}
.day-name {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.day-number {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.task-badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
padding: 2px 6px;
background: var(--primary);
color: white;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
.timeline-body {
flex: 1;
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
overflow-y: auto;
}
.time-column {
background: var(--background);
border-right: 1px solid var(--secondary);
}
.time-cell {
height: 48px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 4px 8px;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--background-dark);
}
.day-column {
border-right: 1px solid var(--background-dark);
position: relative;
&:last-child {
border-right: none;
}
&.is-today {
background: rgba(255, 183, 197, 0.05);
}
}
.hour-cell {
height: 48px;
border-bottom: 1px solid var(--background-dark);
padding: 2px;
position: relative;
}
.task-block {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--background);
border-radius: var(--radius-sm);
border-left: 3px solid var(--primary);
cursor: pointer;
transition: all var(--transition-fast);
margin-bottom: 2px;
&:hover {
background: var(--background-dark);
transform: translateX(2px);
}
&.completed {
opacity: 0.6;
.task-title {
text-decoration: line-through;
}
}
}
.priority-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.task-title {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
@media (max-width: 768px) {
.week-nav {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.time-cell {
font-size: 10px;
padding: 2px 4px;
}
.hour-cell {
height: 40px;
}
.task-block {
padding: 2px 4px;
.task-title {
font-size: 10px;
}
}
}
</style>

19
WebUI/tsconfig.app.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
WebUI/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
WebUI/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

32
WebUI/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:23994',
changeOrigin: true
}
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus', '@element-plus/icons-vue'],
'vendor': ['vue', 'vue-router', 'pinia', 'axios']
}
}
}
},
cacheDir: 'node_modules/.vite'
})