Files
simple-memo-wx/pages/todo/todo.vue
T

1411 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="todo-page page-enter theme-transition" :style="{ background: themeConfig.bgColor }">
<PageHeader
title="待办清单"
subtitle="高效规划每一天"
:showRight="true"
>
<template #right>
<view
class="header-btn"
:style="{ background: themeConfig.cardBgColor, boxShadow: `0 4rpx 16rpx ${themeConfig.shadowLight}` }"
@click="toggleBatchMode"
>
<FaIcon icon="bars" :size="20" :color="themeConfig.textPrimary" />
</view>
</template>
</PageHeader>
<view class="sticky-filters">
<view class="filter-section card-enter">
<view
class="search-wrap"
:style="{ background: themeConfig.cardBgColor, boxShadow: `0 6rpx 24rpx ${themeConfig.shadowLight}` }"
>
<FaIcon icon="search" :size="22" :color="themeConfig.textSecondary" />
<input
v-model="searchWord"
@input="handleSearchInput"
placeholder="搜索待办"
:style="{ color: themeConfig.textPrimary }"
/>
<FaIcon
v-if="searchWord"
icon="xmark"
:size="14"
:color="themeConfig.textSecondary"
@click="clearSearch"
/>
</view>
<view class="filter-row">
<CapsuleFilter
:options="timeFilterOptions"
:value="activeTimeTag"
:color="themeConfig.primaryColor"
size="small"
@change="handleTimeFilterChange"
/>
</view>
<view class="filter-row">
<CapsuleFilter
:options="statusFilterOptions"
:value="activeStatus"
:color="themeConfig.primaryColor"
size="small"
@change="activeStatus = $event"
/>
</view>
</view>
</view>
<scroll-view class="main-content" scroll-y :show-scrollbar="false">
<view class="calendar-section" v-if="activeTimeTag !== 'today'">
<InlineCalendar
:value="selectedDate"
:markDates="markDates"
:markType="'dot'"
:rangeSelect="activeTimeTag === 'custom'"
:startDate="customDateRange.start"
:endDate="customDateRange.end"
@change="handleDateSelect"
@rangeChange="handleRangeChange"
/>
</view>
<view class="list-section card-enter">
<view class="list-header">
<view class="header-left">
<view class="accent-dot" :style="{ background: themeConfig.primaryColor }"></view>
<text class="header-label" :style="{ color: themeConfig.textPrimary }">任务列表</text>
</view>
<text class="header-count" :style="{ color: themeConfig.textSecondary }">{{ filteredList.length }} </text>
</view>
<SkeletonScreen v-if="loading && !firstLoadDone" type="list" :count="5" />
<view class="task-list" v-else>
<SwipeableItem
v-for="item in filteredList"
:key="`task-${item.ID}`"
:actions="getSwipeActions(item)"
:disabled="batchMode"
:index="item.ID"
:autoClose="true"
@action="(key) => handleSwipeAction(key, item)"
@open="handleSwipeOpen"
>
<view
class="task-card"
:style="{ background: themeConfig.cardBgColor }"
@longpress="enterBatch(item)"
>
<view class="task-left">
<view
class="batch-checkbox"
v-if="batchMode"
:class="{ checked: selectedIds.includes(item.ID) }"
:style="{
borderColor: selectedIds.includes(item.ID) ? themeConfig.primaryColor : themeConfig.borderColor,
background: selectedIds.includes(item.ID) ? themeConfig.gradientSoft : 'transparent'
}"
@click.stop="toggleSelect(item.ID)"
>
<FaIcon
v-if="selectedIds.includes(item.ID)"
icon="check"
:size="14"
:color="'#fff'"
/>
</view>
<view
class="priority-badge"
:class="getPriorityClass(item.priority)"
:style="{ background: getPriorityBg(item.priority) }"
>
{{ getPriorityIcon(item.priority) }}
</view>
<view class="task-info">
<view class="task-title-row">
<text
class="task-title"
:class="{ done: item.done }"
:style="{ color: item.done ? themeConfig.textDisabled : themeConfig.textPrimary }"
>
<FaIcon
v-if="item.done"
icon="check"
:size="14"
:color="themeConfig.secondaryColor"
/>
{{ item.title }}
</text>
<text
class="cate-badge"
v-if="item.category"
:style="{ background: `${themeConfig.secondaryColor}15`, color: themeConfig.secondaryColor }"
>
{{ getCateLabel(item.category) }}
</text>
</view>
<view class="task-meta">
<text
class="meta-text"
v-if="item.start_time && item.end_time"
:style="{ color: themeConfig.textSecondary }"
>
{{ item.start_time }} - {{ item.end_time }}
</text>
<text
class="meta-text"
v-if="item.remark"
:style="{ color: themeConfig.textSecondary }"
>
{{ item.remark }}
</text>
<text class="meta-date" :style="{ color: themeConfig.textSecondary }">
{{ formatShortDate(item.date) }}
</text>
</view>
</view>
</view>
</view>
</SwipeableItem>
<EmptyState
v-if="filteredList.length === 0 && !loading"
type="todo"
title="暂无待办"
desc="点击下方按钮添加新任务"
actionText="添加待办"
@action="openAddSheet"
/>
</view>
</view>
<view
class="batch-bar"
v-if="batchMode"
:style="{ background: themeConfig.cardBgColor, boxShadow: `0 -8rpx 32rpx ${themeConfig.shadowMedium}` }"
>
<text class="batch-count" :style="{ color: themeConfig.textPrimary }">已选 {{ selectedIds.length }} </text>
<view class="batch-btn btn btn--primary btn--sm" @click="batchSetDone">标为完成</view>
<view class="batch-btn danger btn btn--danger btn--sm" @click="batchDelete">删除</view>
<view
class="batch-btn cancel"
:style="{ background: themeConfig.borderColor, color: themeConfig.textSecondary }"
@click="exitBatch"
>
取消
</view>
</view>
<view class="bottom-spacer"></view>
</scroll-view>
</view>
<view class="add-button" v-if="!batchMode && !showAddSheet" @click="openAddSheet">
<view
class="add-btn-inner"
:style="{ background: themeConfig.gradientVibrant, boxShadow: `0 8rpx 32rpx ${themeConfig.shadowColor}` }"
>
<text class="add-icon">+</text>
</view>
</view>
<BottomSheet
:visible.sync="showAddSheet"
:title="isEdit ? '编辑待办' : '新增待办'"
:maskClosable="true"
height="85vh"
@close="closeAddSheet"
>
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">待办标题</text>
<input
v-model="form.title"
placeholder="请输入待办标题"
class="form-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
/>
</view>
<view class="form-row">
<view class="form-group half">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">日期</text>
<picker mode="date" :value="form.date" @change="e => form.date = e.detail.value">
<view
class="form-input picker-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>
{{ formatDate(form.date) }}
</view>
</picker>
</view>
<view class="form-group half">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">优先级</text>
<view class="priority-options">
<view
v-for="p in TODO_PRIORITY_LIST"
:key="p.value"
class="priority-option"
:class="{ active: form.priority === p.value }"
:style="{
background: form.priority === p.value ? getPriorityBg(p.value) : themeConfig.borderColor,
color: form.priority === p.value ? '#fff' : themeConfig.textSecondary
}"
@click="form.priority = p.value"
>
{{ p.label }}
</view>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">时间段</text>
<view class="time-picker-row">
<picker mode="time" :value="form.start_time" @change="e => form.start_time = formatTime(e.detail.value)">
<view
class="time-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>
{{ form.start_time }}
</view>
</picker>
<text class="time-sep" :style="{ color: themeConfig.textSecondary }"></text>
<picker mode="time" :value="form.end_time" @change="e => form.end_time = formatTime(e.detail.value)">
<view
class="time-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>
{{ form.end_time }}
</view>
</picker>
</view>
</view>
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">分类</text>
<view class="cate-options">
<view
v-for="c in showCate"
:key="c.value"
class="cate-option"
:class="{ active: form.category === c.value }"
:style="{
background: form.category === c.value ? themeConfig.gradientSoft : themeConfig.borderColor,
color: form.category === c.value ? '#fff' : themeConfig.textSecondary
}"
@click="form.category = c.value"
>
{{ c.label }}
</view>
</view>
</view>
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">备注</text>
<textarea
v-model="form.remark"
placeholder="选填备注信息"
class="form-textarea"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
auto-height
></textarea>
</view>
<template #footer>
<view class="modal-footer">
<view
class="btn-cancel"
:style="{ color: themeConfig.textSecondary }"
@click="closeAddSheet"
>
取消
</view>
<view
class="btn-confirm"
:style="{ background: themeConfig.gradientVibrant }"
@click="submitTodo"
>
{{ isEdit ? '保存修改' : '确认添加' }}
</view>
</view>
</template>
</BottomSheet>
<CenterModal
:visible.sync="showDeleteModal"
title="确认删除"
:confirmColor="themeConfig.dangerColor"
confirmText="删除"
cancelText="取消"
@confirm="confirmDelete"
>
<text class="modal-body-text" :style="{ color: themeConfig.textPrimary }">
确定要删除这条待办吗
</text>
</CenterModal>
<CenterModal
:visible.sync="showCustomDateModal"
title="选择时间范围"
@confirm="confirmCustomDate"
>
<view class="custom-date-body">
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">开始日期</text>
<picker mode="date" :value="customStartDate" @change="e => customStartDate = e.detail.value">
<view
class="form-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>
{{ customStartDate }}
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">结束日期</text>
<picker mode="date" :value="customEndDate" @change="e => customEndDate = e.detail.value">
<view
class="form-input"
:style="{ background: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>
{{ customEndDate }}
</view>
</picker>
</view>
</view>
</CenterModal>
<CenterModal
:visible.sync="showBatchDeleteModal"
title="确认批量删除"
:confirmColor="themeConfig.dangerColor"
confirmText="删除"
cancelText="取消"
@confirm="confirmBatchDelete"
>
<text class="modal-body-text" :style="{ color: themeConfig.textPrimary }">
确定要删除选中的 {{ selectedIds.length }} 条待办吗
</text>
</CenterModal>
<GrowthModal
:visible="growthModalVisible"
:title="growthModalTitle"
:message="growthModalMessage"
:icon="growthModalIcon"
:confirm-text="growthModalConfirmText"
@close="growthModalVisible = false"
/>
</template>
<script>
import {
getTodoList,
addTodo,
editTodo,
deleteTodo,
doneTodo
} from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import CapsuleFilter from '@/components/CapsuleFilter.vue'
import InlineCalendar from '@/components/InlineCalendar.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import CenterModal from '@/components/CenterModal.vue'
import SwipeableItem from '@/components/SwipeableItem.vue'
import EmptyState from '@/components/EmptyState.vue'
import FaIcon from '@/components/FaIcon.vue'
import GrowthModal from '@/components/GrowthModal.vue'
import SkeletonScreen from '@/components/SkeletonScreen.vue'
import themeMixin from '@/mixins/themeMixin.js'
import growthModalMixin from '@/mixins/growthModalMixin.js'
import {
TODO_BASE_CATE,
TODO_PRIORITY_LIST,
WEEK_DAYS,
TIME_FILTER_OPTIONS,
STATUS_FILTER_OPTIONS
} from '@/utils/constants.js'
import {
THEME_PRESETS
} from '@/utils/theme'
import { refreshNavBarTheme } from '@/utils/themeGlobal'
export default {
components: {
PageHeader,
CapsuleFilter,
InlineCalendar,
BottomSheet,
CenterModal,
SwipeableItem,
EmptyState,
FaIcon,
GrowthModal,
SkeletonScreen
},
mixins: [themeMixin, growthModalMixin],
data() {
return {
TIME_FILTER_OPTIONS,
STATUS_FILTER_OPTIONS,
TODO_BASE_CATE,
TODO_PRIORITY_LIST,
searchWord: '',
activeTimeTag: 'today',
activeStatus: 'all',
loading: false,
firstLoadDone: false,
todoList: [],
selectedIds: [],
batchMode: false,
showCustomDateModal: false,
customStartDate: '',
customEndDate: '',
customDateRange: {
start: new Date().toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
},
weekHeaders: WEEK_DAYS,
weekDays: [],
monthDays: [],
customDays: [],
selectedDate: '',
showAddSheet: false,
isEdit: false,
currentEditId: '',
submitting: false,
form: {
title: '',
remark: '',
category: 'work',
priority: 2,
date: '',
start_time: '',
end_time: ''
},
customDisplayText: '自定义',
showDeleteModal: false,
deleteTargetId: '',
showBatchDeleteModal: false,
searchTimer: null
}
},
computed: {
showCate() {
return [...this.TODO_BASE_CATE]
},
filteredList() {
let list = [...this.todoList]
list = this.filterByTimeRange(list)
list = this.filterByStatus(list)
list = this.filterBySearch(list)
list = this.filterBySelectedDate(list)
return list
},
markDates() {
return this.todoList.map(t => t.date)
},
timeFilterOptions() {
return this.TIME_FILTER_OPTIONS.map(opt => {
if (opt.value === 'custom' && this.customDisplayText && this.customDisplayText !== '自定义') {
return { ...opt, label: this.customDisplayText }
}
return opt
})
},
statusFilterOptions() {
return this.STATUS_FILTER_OPTIONS
}
},
onLoad(options) {
const today = new Date().toISOString().split('T')[0]
const paramsDate = options?.date || today
this.form.date = paramsDate
this.customStartDate = paramsDate
this.customEndDate = paramsDate
this.selectedDate = paramsDate
const now = new Date()
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const defaultTime = `${hours}:${minutes}`
this.form.start_time = defaultTime
const endDate = new Date(now.getTime() + 30 * 60000)
this.form.end_time = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`
this.initWeekDays()
this.initMonthDays()
this.getList()
},
onShow() {
this.syncThemeConfig()
refreshNavBarTheme()
},
onUnload() {
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
},
onPullDownRefresh() {
this.getList().finally(() => uni.stopPullDownRefresh())
},
watch: {
activeTimeTag(newVal) {
if (newVal === 'week') this.initWeekDays()
else if (newVal === 'month') this.initMonthDays()
else if (newVal === 'custom') this.initCustomDays()
this.selectedDate = ''
this.getList()
},
activeStatus() {
this.getList()
}
},
methods: {
getSwipeActions(item) {
const actions = []
if (!item.done) {
actions.push({ key: 'done', label: '完成' })
}
actions.push({ key: 'edit', label: '编辑' })
actions.push({ key: 'delete', label: '删除' })
return actions
},
handleSwipeAction(key, item) {
if (key === 'done') {
this.handleDone(item.ID)
} else if (key === 'edit') {
this.handleEdit(item)
} else if (key === 'delete') {
this.handleDelete(item.ID)
}
},
handleSwipeOpen(id) {
if (this._lastOpenId && this._lastOpenId !== id) {
this._lastOpenId = id
}
},
handleTimeFilterChange(val) {
if (val === 'custom') {
this.showCustomDateModal = true
} else {
this.activeTimeTag = val
}
},
handleDateSelect(dateStr) {
this.selectedDate = this.selectedDate === dateStr ? '' : dateStr
},
handleRangeChange(range) {
if (range.start && range.end) {
this.customDateRange = {
start: range.start,
end: range.end
}
this.customDisplayText = `${range.start.slice(5)} ~ ${range.end.slice(5)}`
this.getList()
}
},
filterByTimeRange(list) {
const now = new Date()
const today = now.toISOString().split('T')[0]
const rangeMap = {
today: { start: today, end: today },
week: this.getWeekRange(now),
month: this.getMonthRange(now),
custom: {
start: this.customDateRange.start || today,
end: this.customDateRange.end || today
}
}
const { start, end } = rangeMap[this.activeTimeTag] || rangeMap.today
return list.filter(item => item.date >= start && item.date <= end)
},
filterByStatus(list) {
const map = {
all: () => true,
undone: item => !item.done,
done: item => item.done
}
return list.filter(map[this.activeStatus] || map.all)
},
filterBySearch(list) {
if (!this.searchWord) return list
const keyword = this.searchWord.trim().toLowerCase()
return list.filter(item => {
return (item.title || '').toLowerCase().includes(keyword) ||
(item.remark || '').toLowerCase().includes(keyword)
})
},
filterBySelectedDate(list) {
if (this.selectedDate && ['week', 'month', 'custom'].includes(this.activeTimeTag)) {
return list.filter(item => item.date === this.selectedDate)
}
return list
},
getWeekRange(date) {
const dayOfWeek = date.getDay() || 7
const diff = dayOfWeek - 1
const start = new Date(date)
start.setDate(date.getDate() - diff)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0]
}
},
getMonthRange(date) {
const start = new Date(date.getFullYear(), date.getMonth(), 1)
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0)
return {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0]
}
},
initWeekDays() {
const now = new Date()
const days = []
const dayOfWeek = now.getDay() || 7
const diff = dayOfWeek - 1
const startOfWeek = new Date(now)
startOfWeek.setDate(now.getDate() - diff)
for (let i = 0; i < 7; i++) {
const d = new Date(startOfWeek)
d.setDate(startOfWeek.getDate() + i)
days.push(d)
}
this.weekDays = days
},
initMonthDays() {
const now = new Date()
const days = []
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const startPadding = firstDay.getDay() || 7
const startDay = new Date(firstDay)
startDay.setDate(firstDay.getDate() - (startPadding - 1))
for (let i = 0; i < 42; i++) {
const d = new Date(startDay)
d.setDate(startDay.getDate() + i)
days.push(d)
}
this.monthDays = days
},
initCustomDays() {
const days = []
const start = new Date(this.customDateRange.start)
const end = new Date(this.customDateRange.end)
const diff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
for (let i = 0; i < Math.min(diff, 35); i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
days.push(d)
}
this.customDays = days
},
toggleBatchMode() {
this.batchMode = !this.batchMode
if (!this.batchMode) this.selectedIds = []
},
handleSearchInput() {
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
this.searchTimer = setTimeout(() => {
this.getList()
}, 300)
},
clearSearch() {
this.searchWord = ''
this.getList()
},
openAddSheet() {
this.isEdit = false
this.form = {
title: '',
remark: '',
category: 'work',
priority: 2,
date: new Date().toISOString().split('T')[0],
start_time: this.form.start_time,
end_time: this.form.end_time
}
this.showAddSheet = true
},
closeAddSheet() {
this.showAddSheet = false
},
openCustomDatePicker() {
this.showCustomDateModal = true
},
confirmCustomDate() {
this.customDateRange = {
start: this.customStartDate,
end: this.customEndDate
}
this.customDisplayText = `${this.customStartDate.slice(5)} ~ ${this.customEndDate.slice(5)}`
this.activeTimeTag = 'custom'
this.showCustomDateModal = false
},
enterBatch(item) {
this.batchMode = true
if (item && item.ID) {
this.toggleSelect(item.ID)
}
},
toggleSelect(id) {
const index = this.selectedIds.indexOf(id)
if (index > -1) {
this.selectedIds.splice(index, 1)
} else {
this.selectedIds.push(id)
}
},
async handleDone(id) {
try {
await doneTodo({ id, status: 1 })
uni.showToast({ title: '已完成', icon: 'success' })
this.getList()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
handleEdit(item) {
this.isEdit = true
this.currentEditId = item.ID
this.form = {
title: item.title,
remark: item.remark || '',
category: item.category || 'work',
priority: item.priority || 2,
date: item.date,
start_time: item.start_time || '',
end_time: item.end_time || ''
}
this.showAddSheet = true
},
handleDelete(id) {
this.deleteTargetId = id
this.showDeleteModal = true
},
async confirmDelete() {
try {
await deleteTodo({ id: this.deleteTargetId })
uni.showToast({ title: '已删除', icon: 'success' })
this.showDeleteModal = false
this.getList()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
exitBatch() {
this.batchMode = false
this.selectedIds = []
},
async batchSetDone() {
if (this.selectedIds.length === 0) return
try {
await Promise.all(
this.selectedIds.map(id => doneTodo({ id, status: 1 }))
)
uni.showToast({ title: '批量完成成功', icon: 'success' })
this.batchMode = false
this.selectedIds = []
this.getList()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
batchDelete() {
if (this.selectedIds.length === 0) return
this.showBatchDeleteModal = true
},
async confirmBatchDelete() {
try {
await Promise.all(
this.selectedIds.map(id => deleteTodo({ id }))
)
uni.showToast({ title: '批量删除成功', icon: 'success' })
this.showBatchDeleteModal = false
this.batchMode = false
this.selectedIds = []
this.getList()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
async submitTodo() {
if (!this.form.title.trim()) {
uni.showToast({ title: '请输入待办标题', icon: 'none' })
return
}
this.submitting = true
try {
if (this.isEdit) {
await editTodo({
id: this.currentEditId,
...this.form
})
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
await addTodo(this.form)
uni.showToast({ title: '添加成功', icon: 'success' })
}
this.showAddSheet = false
this.getList()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
this.submitting = false
}
},
async getList() {
this.loading = true
try {
const res = await getTodoList({
start_date: this.getStartDate(),
end_date: this.getEndDate()
})
this.todoList = res || []
} catch (e) {
this.todoList = []
} finally {
this.loading = false
this.firstLoadDone = true
}
},
getStartDate() {
if (this.activeTimeTag === 'today') {
return new Date().toISOString().split('T')[0]
}
if (this.activeTimeTag === 'week') {
return this.getWeekRange(new Date()).start
}
if (this.activeTimeTag === 'month') {
return this.getMonthRange(new Date()).start
}
return this.customDateRange.start
},
getEndDate() {
if (this.activeTimeTag === 'today') {
return new Date().toISOString().split('T')[0]
}
if (this.activeTimeTag === 'week') {
return this.getWeekRange(new Date()).end
}
if (this.activeTimeTag === 'month') {
return this.getMonthRange(new Date()).end
}
return this.customDateRange.end
},
formatDate(dateStr) {
const parts = dateStr.split('-')
return `${parts[1]}${parts[2]}日`
},
formatShortDate(dateStr) {
const parts = dateStr.split('-')
return `${parts[1]}/${parts[2]}`
},
formatTime(time) {
return time.slice(0, 5)
},
getCateLabel(cateValue) {
const cate = this.TODO_BASE_CATE.find(c => c.value === cateValue)
return cate ? cate.label : cateValue
},
getPriorityClass(priority) {
const map = { 1: 'high', 2: 'middle', 3: 'low' }
return map[priority] || 'middle'
},
getPriorityIcon(priority) {
const map = { 1: '!', 2: '•', 3: '○' }
return map[priority] || '•'
},
getPriorityBg(priority) {
const map = {
1: this.themeConfig.dangerColor,
2: this.themeConfig.warningColor,
3: this.themeConfig.successColor
}
return map[priority] || this.themeConfig.warningColor
}
}
}
</script>
<style scoped>
.todo-page {
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sticky-filters {
flex-shrink: 0;
z-index: 10;
}
.main-content {
flex: 1;
height: 0;
overflow-y: auto;
}
.bottom-spacer {
height: 140rpx;
flex-shrink: 0;
}.header-btn {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
}
.header-btn:active {
transform: scale(0.92);
}
.filter-section {
padding: 0 32rpx;
padding-top: 12rpx;
margin-bottom: 20rpx;
}
.search-wrap {
display: flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 18rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.search-wrap input {
flex: 1;
font-size: 26rpx;
}
.filter-row {
margin-bottom: 12rpx;
}
.calendar-section {
padding: 0 32rpx;
margin-bottom: 20rpx;
}
.list-section {
padding: 0 32rpx;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.header-left {
display: flex;
align-items: center;
gap: 10rpx;
}
.accent-dot {
width: 8rpx;
height: 28rpx;
border-radius: 12rpx;
}
.header-label {
font-size: 28rpx;
font-weight: 700;
}
.header-count {
font-size: 24rpx;
}
.skeleton-wrap {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
background: rgba(0, 0, 0, 0.03);
border-radius: 12rpx;
}
.skeleton-icon {
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 10rpx;
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-texts {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
height: 24rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 6rpx;
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-line.short {
width: 60%;
}
@keyframes skeletonPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pageFadeIn {
0% { opacity: 0; transform: translateY(20rpx); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes cardEnter {
0% { opacity: 0; transform: translateY(16rpx); }
100% { opacity: 1; transform: translateY(0); }
}
.content-fade {
transition: opacity 0.25s ease;
}
.theme-transition {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease,
opacity 0.25s ease,
transform 0.25s ease;
}
.task-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.task-card {
display: flex;
align-items: center;
padding: 20rpx;
border-radius: 12rpx;
position: relative;
z-index: 2;
transition: transform 0.3s ease;
}
.task-left {
display: flex;
align-items: center;
gap: 14rpx;
flex: 1;
}
.batch-checkbox {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
border: 2rpx solid;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s ease;
}
.priority-badge {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-size: 20rpx;
font-weight: 700;
}
.task-info {
flex: 1;
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 6rpx;
}
.task-title {
font-size: 28rpx;
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-title.done {
text-decoration: line-through;
text-decoration-color: currentColor;
}
.cate-badge {
font-size: 20rpx;
padding: 4rpx 10rpx;
border-radius: 6rpx;
font-weight: 500;
flex-shrink: 0;
}
.task-meta {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.meta-text {
font-size: 22rpx;
}
.meta-date {
font-size: 22rpx;
margin-left: auto;
}
.batch-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-around;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
z-index: 100;
}
.batch-count {
font-size: 26rpx;
font-weight: 600;
}
.batch-btn {
padding: 12rpx 24rpx;
}
.add-button {
position: fixed;
border-radius: 50%;
bottom: calc(40rpx + constant(safe-area-inset-bottom));
bottom: calc(40rpx + env(safe-area-inset-bottom));
right: 30rpx;
z-index: 50;
}
.add-btn-inner {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.add-btn-inner:active {
transform: scale(0.9);
}
.add-icon {
font-size: 48rpx;
color: #fff;
font-weight: 500;
}
.form-group {
margin-bottom: 20rpx;
}
.form-group.half {
flex: 1;
}
.form-row {
display: flex;
gap: 16rpx;
}
.form-label {
font-size: 24rpx;
font-weight: 500;
display: block;
margin-bottom: 10rpx;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border-radius: 12rpx;
border: 2rpx solid;
font-size: 28rpx;
}
.picker-input {
display: flex;
align-items: center;
}
.time-picker-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.time-input {
flex: 1;
height: 80rpx;
padding: 0 20rpx;
border-radius: 12rpx;
border: 2rpx solid;
font-size: 28rpx;
display: flex;
align-items: center;
}
.time-sep {
font-size: 28rpx;
}
.priority-options,
.cate-options {
display: flex;
gap: 10rpx;
flex-wrap: wrap;
}
.priority-option,
.cate-option {
padding: 10rpx 20rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: 500;
transition: all 0.25s ease;
}
.priority-option:active,
.cate-option:active {
transform: scale(0.95);
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 16rpx 20rpx;
border-radius: 12rpx;
border: 2rpx solid;
font-size: 28rpx;
}
.modal-footer {
display: flex;
gap: 20rpx;
}
.btn-cancel {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 600;
background: rgba(0, 0, 0, 0.06);
transition: all 0.25s ease;
}
.btn-cancel:active {
transform: scale(0.97);
background: rgba(0, 0, 0, 0.1);
}
.btn-confirm {
flex: 2;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 600;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
transition: all 0.25s ease;
}
.btn-confirm:active {
transform: scale(0.97);
}
.modal-body-text {
font-size: 28rpx;
line-height: 1.6;
text-align: center;
display: block;
}
.custom-date-body {
padding: 0 10rpx;
}
</style>