release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor
This commit is contained in:
24
WebUI/.gitignore
vendored
Normal file
24
WebUI/.gitignore
vendored
Normal 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
5
WebUI/README.md
Normal 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
13
WebUI/index.html
Normal 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
2499
WebUI/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
WebUI/package.json
Normal file
30
WebUI/package.json
Normal 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
1
WebUI/public/vite.svg
Normal 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
154
WebUI/src/App.vue
Normal 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
64
WebUI/src/api/accounts.ts
Normal 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`)
|
||||
},
|
||||
}
|
||||
49
WebUI/src/api/anniversaries.ts
Normal file
49
WebUI/src/api/anniversaries.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
29
WebUI/src/api/categories.ts
Normal file
29
WebUI/src/api/categories.ts
Normal 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
78
WebUI/src/api/habits.ts
Normal 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
72
WebUI/src/api/request.ts
Normal 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
23
WebUI/src/api/tags.ts
Normal 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
44
WebUI/src/api/tasks.ts
Normal 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
278
WebUI/src/api/types.ts
Normal 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
|
||||
}
|
||||
10
WebUI/src/api/userSettings.ts
Normal file
10
WebUI/src/api/userSettings.ts
Normal 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
1
WebUI/src/assets/vue.svg
Normal 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 |
361
WebUI/src/components/AccountDialog.vue
Normal file
361
WebUI/src/components/AccountDialog.vue
Normal 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>
|
||||
235
WebUI/src/components/AccountHistoryDialog.vue
Normal file
235
WebUI/src/components/AccountHistoryDialog.vue
Normal 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>
|
||||
192
WebUI/src/components/AnniversaryCategoryDialog.vue
Normal file
192
WebUI/src/components/AnniversaryCategoryDialog.vue
Normal 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>
|
||||
257
WebUI/src/components/AnniversaryDialog.vue
Normal file
257
WebUI/src/components/AnniversaryDialog.vue
Normal 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>
|
||||
271
WebUI/src/components/AppHeader.vue
Normal file
271
WebUI/src/components/AppHeader.vue
Normal 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>
|
||||
185
WebUI/src/components/BalanceDialog.vue
Normal file
185
WebUI/src/components/BalanceDialog.vue
Normal 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>
|
||||
227
WebUI/src/components/CategoryDialog.vue
Normal file
227
WebUI/src/components/CategoryDialog.vue
Normal 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>
|
||||
698
WebUI/src/components/CategorySidebar.vue
Normal file
698
WebUI/src/components/CategorySidebar.vue
Normal 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>
|
||||
330
WebUI/src/components/HabitDialog.vue
Normal file
330
WebUI/src/components/HabitDialog.vue
Normal 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>
|
||||
468
WebUI/src/components/HabitGroupDialog.vue
Normal file
468
WebUI/src/components/HabitGroupDialog.vue
Normal 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>
|
||||
270
WebUI/src/components/InstallmentDialog.vue
Normal file
270
WebUI/src/components/InstallmentDialog.vue
Normal 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>
|
||||
376
WebUI/src/components/QuadrantTaskCard.vue
Normal file
376
WebUI/src/components/QuadrantTaskCard.vue
Normal 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>
|
||||
368
WebUI/src/components/TaskCard.vue
Normal file
368
WebUI/src/components/TaskCard.vue
Normal 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>
|
||||
330
WebUI/src/components/TaskDialog.vue
Normal file
330
WebUI/src/components/TaskDialog.vue
Normal 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
21
WebUI/src/main.ts
Normal 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
78
WebUI/src/router/index.ts
Normal 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
|
||||
208
WebUI/src/stores/useAccountStore.ts
Normal file
208
WebUI/src/stores/useAccountStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
180
WebUI/src/stores/useAnniversaryStore.ts
Normal file
180
WebUI/src/stores/useAnniversaryStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
65
WebUI/src/stores/useCategoryStore.ts
Normal file
65
WebUI/src/stores/useCategoryStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
227
WebUI/src/stores/useHabitStore.ts
Normal file
227
WebUI/src/stores/useHabitStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
50
WebUI/src/stores/useTagStore.ts
Normal file
50
WebUI/src/stores/useTagStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
180
WebUI/src/stores/useTaskStore.ts
Normal file
180
WebUI/src/stores/useTaskStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
72
WebUI/src/stores/useUIStore.ts
Normal file
72
WebUI/src/stores/useUIStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
74
WebUI/src/stores/useUserSettingsStore.ts
Normal file
74
WebUI/src/stores/useUserSettingsStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
33
WebUI/src/styles/_variables.scss
Normal file
33
WebUI/src/styles/_variables.scss
Normal 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
340
WebUI/src/styles/main.scss
Normal 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
113
WebUI/src/utils/date.ts
Normal 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-6,0为周日)
|
||||
* @returns 中文名称
|
||||
*/
|
||||
export function getWeekDayName(dayOfWeek: number): string {
|
||||
const names = ['日', '一', '二', '三', '四', '五', '六']
|
||||
return names[dayOfWeek] ?? ''
|
||||
}
|
||||
177
WebUI/src/utils/pinyin.ts
Normal file
177
WebUI/src/utils/pinyin.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮显示匹配的文本
|
||||
* @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
|
||||
}
|
||||
27
WebUI/src/utils/priority.ts
Normal file
27
WebUI/src/utils/priority.ts
Normal 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
|
||||
}
|
||||
905
WebUI/src/views/AnniversaryPage.vue
Normal file
905
WebUI/src/views/AnniversaryPage.vue
Normal 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>
|
||||
1134
WebUI/src/views/AssetPage.vue
Normal file
1134
WebUI/src/views/AssetPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
17
WebUI/src/views/CalendarPage.vue
Normal file
17
WebUI/src/views/CalendarPage.vue
Normal 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>
|
||||
109
WebUI/src/views/CalendarView.vue
Normal file
109
WebUI/src/views/CalendarView.vue
Normal 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>
|
||||
1251
WebUI/src/views/HabitPage.vue
Normal file
1251
WebUI/src/views/HabitPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
708
WebUI/src/views/MonthlyView.vue
Normal file
708
WebUI/src/views/MonthlyView.vue
Normal 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>
|
||||
376
WebUI/src/views/ProfileView.vue
Normal file
376
WebUI/src/views/ProfileView.vue
Normal 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>
|
||||
17
WebUI/src/views/QuadrantPage.vue
Normal file
17
WebUI/src/views/QuadrantPage.vue
Normal 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>
|
||||
362
WebUI/src/views/QuadrantView.vue
Normal file
362
WebUI/src/views/QuadrantView.vue
Normal 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>
|
||||
669
WebUI/src/views/SettingsView.vue
Normal file
669
WebUI/src/views/SettingsView.vue
Normal 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>
|
||||
283
WebUI/src/views/TaskList.vue
Normal file
283
WebUI/src/views/TaskList.vue
Normal 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>
|
||||
57
WebUI/src/views/TaskListView.vue
Normal file
57
WebUI/src/views/TaskListView.vue
Normal 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>
|
||||
447
WebUI/src/views/WeeklyView.vue
Normal file
447
WebUI/src/views/WeeklyView.vue
Normal 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
19
WebUI/tsconfig.app.json
Normal 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
7
WebUI/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
WebUI/tsconfig.node.json
Normal file
26
WebUI/tsconfig.node.json
Normal 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
32
WebUI/vite.config.ts
Normal 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'
|
||||
})
|
||||
Reference in New Issue
Block a user