1411 lines
32 KiB
Vue
1411 lines
32 KiB
Vue
<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>
|