466 lines
19 KiB
Vue
466 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import Icon from '../components/Icon.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import {
|
||
addPlan, deletePlan, listPlansByDate, setPlanStatus,
|
||
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
|
||
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
|
||
} from '../db/repo'
|
||
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
|
||
import { attendanceChanged } from '../lib/bus'
|
||
import { showToast } from '../lib/toast'
|
||
|
||
// ---- 长期计划 ----
|
||
const goals = ref<Goal[]>([])
|
||
const goalProg = ref<Record<string, { done: number; total: number }>>({})
|
||
const goalOpen = ref(false)
|
||
const goalEditing = ref<Goal | null>(null)
|
||
const gTitle = ref('')
|
||
const gKind = ref('学习')
|
||
const gStart = ref(todayStr())
|
||
const gEnd = ref(addDays(todayStr(), 6))
|
||
const gNote = ref('')
|
||
const gError = ref('')
|
||
const delGoal = ref<Goal | null>(null)
|
||
|
||
function openGoalCreate() {
|
||
goalEditing.value = null
|
||
gTitle.value = ''
|
||
gKind.value = '学习'
|
||
gStart.value = todayStr()
|
||
gEnd.value = addDays(todayStr(), 6)
|
||
gNote.value = ''
|
||
gError.value = ''
|
||
goalOpen.value = true
|
||
}
|
||
function openGoalEdit(g: Goal) {
|
||
goalEditing.value = g
|
||
gTitle.value = g.title
|
||
gKind.value = g.kind
|
||
gStart.value = g.start_date
|
||
gEnd.value = g.end_date
|
||
gNote.value = g.note || ''
|
||
gError.value = ''
|
||
goalOpen.value = true
|
||
}
|
||
async function submitGoal() {
|
||
if (!gTitle.value.trim()) { gError.value = '请填写计划名称'; return }
|
||
if (gEnd.value < gStart.value) { gError.value = '结束日期不能早于开始日期'; return }
|
||
if (goalEditing.value) {
|
||
await updateGoal(goalEditing.value.id as number, {
|
||
title: gTitle.value.trim(), kind: gKind.value, note: gNote.value
|
||
})
|
||
showToast('长期计划已更新')
|
||
} else {
|
||
await addGoal({ title: gTitle.value, kind: gKind.value, start_date: gStart.value, end_date: gEnd.value, note: gNote.value })
|
||
showToast('长期计划已创建,已生成每日任务')
|
||
}
|
||
goalOpen.value = false
|
||
await load()
|
||
}
|
||
async function removeGoal() {
|
||
if (!delGoal.value) return
|
||
await deleteGoal(delGoal.value.id as number)
|
||
showToast('已删除长期计划及其每日任务')
|
||
delGoal.value = null
|
||
await load()
|
||
}
|
||
|
||
const date = ref(todayStr())
|
||
const items = ref<PlanItem[]>([])
|
||
const loading = ref(true)
|
||
const now = ref(Date.now())
|
||
let tick: number | null = null
|
||
|
||
const title = computed(() => formatDateCN(date.value))
|
||
const isToday = computed(() => date.value === todayStr())
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
const [plans, gs, prog] = await Promise.all([
|
||
listPlansByDate(date.value),
|
||
listGoals(),
|
||
getGoalProgress()
|
||
])
|
||
items.value = plans
|
||
goals.value = gs
|
||
const map: Record<string, { done: number; total: number }> = {}
|
||
for (const p of prog) map[String(p.goalId)] = { done: p.done, total: p.total }
|
||
goalProg.value = map
|
||
loading.value = false
|
||
}
|
||
watch(date, load)
|
||
watch(attendanceChanged, load)
|
||
onMounted(() => {
|
||
load()
|
||
tick = window.setInterval(() => { now.value = Date.now() }, 30000)
|
||
})
|
||
onUnmounted(() => { if (tick) window.clearInterval(tick) })
|
||
|
||
function prevDay() { date.value = addDays(date.value, -1) }
|
||
function nextDay() { date.value = addDays(date.value, 1) }
|
||
function goToday() { date.value = todayStr() }
|
||
|
||
// ---- 汇总 ----
|
||
const summary = computed(() => {
|
||
const total = items.value.length
|
||
const done = items.value.filter(p => p.status === 'done').length
|
||
const plannedMin = items.value.reduce((acc, p) => acc + spanMinutes(p), 0)
|
||
const actualMin = items.value.reduce((acc, p) => acc + (p.actual_minutes || 0), 0)
|
||
let liveExtra = 0
|
||
const runningId = running.value
|
||
if (runningId) {
|
||
const rp = items.value.find(p => p.id === runningId)
|
||
if (rp) liveExtra = liveElapsed(rp)
|
||
}
|
||
return { total, done, plannedMin, actualMin: actualMin + liveExtra }
|
||
})
|
||
function spanMinutes(p: PlanItem): number {
|
||
const [sh, sm] = p.start_time.split(':').map(Number)
|
||
const [eh, em] = p.end_time.split(':').map(Number)
|
||
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm))
|
||
}
|
||
function fmtMin(n: number): string {
|
||
if (n < 60) return n + ' 分钟'
|
||
return Math.floor(n / 60) + ' 小时 ' + (n % 60) + ' 分'
|
||
}
|
||
|
||
// ---- 计时时钟 ----
|
||
const running = computed(() => {
|
||
const r = items.value.find(p => !!p.timer_started_at)
|
||
return r ? (r.id as number) : null
|
||
})
|
||
function liveElapsed(p: PlanItem): number {
|
||
if (!p.timer_started_at) return p.actual_minutes || 0
|
||
const extra = Math.max(0, Math.round((now.value - new Date(p.timer_started_at).getTime()) / 60000))
|
||
return (p.actual_minutes || 0) + extra
|
||
}
|
||
async function toggleTimer(p: PlanItem) {
|
||
if (p.timer_started_at) {
|
||
await stopPlanTimer(p.id as number)
|
||
showToast('已停止计时:' + p.title)
|
||
} else {
|
||
await startPlanTimer(p.id as number)
|
||
showToast('开始计时:' + p.title)
|
||
}
|
||
await load()
|
||
}
|
||
|
||
// ---- 打卡 ----
|
||
async function mark(p: PlanItem, status: 'todo' | 'done' | 'skip') {
|
||
await setPlanStatus(p.id as number, status)
|
||
await load()
|
||
}
|
||
|
||
// ---- 新增 / 编辑 ----
|
||
const dialogOpen = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
const fTitle = ref('')
|
||
const fKind = ref('学习')
|
||
const fStart = ref('09:00')
|
||
const fEnd = ref('10:00')
|
||
const fNote = ref('')
|
||
const fError = ref('')
|
||
const KINDS = ['学习', '健身', '自律', '工作', '阅读', '其他']
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
fTitle.value = ''
|
||
fKind.value = '学习'
|
||
fStart.value = '09:00'
|
||
fEnd.value = '10:00'
|
||
fNote.value = ''
|
||
fError.value = ''
|
||
dialogOpen.value = true
|
||
}
|
||
function openEdit(p: PlanItem) {
|
||
editingId.value = p.id as number
|
||
fTitle.value = p.title
|
||
fKind.value = p.kind
|
||
fStart.value = p.start_time
|
||
fEnd.value = p.end_time
|
||
fNote.value = p.note || ''
|
||
fError.value = ''
|
||
dialogOpen.value = true
|
||
}
|
||
async function submitPlan() {
|
||
if (!fTitle.value.trim()) { fError.value = '请填写计划内容'; return }
|
||
if (fEnd.value <= fStart.value) { fError.value = '结束时间需晚于开始时间'; return }
|
||
if (editingId.value) {
|
||
await updatePlan(editingId.value, {
|
||
title: fTitle.value.trim(), kind: fKind.value, start_time: fStart.value,
|
||
end_time: fEnd.value, note: fNote.value
|
||
})
|
||
showToast('计划已更新')
|
||
} else {
|
||
await addPlan({ date: date.value, start_time: fStart.value, end_time: fEnd.value, title: fTitle.value, kind: fKind.value, note: fNote.value })
|
||
showToast('计划已添加')
|
||
}
|
||
dialogOpen.value = false
|
||
await load()
|
||
}
|
||
|
||
async function remove(p: PlanItem) {
|
||
await deletePlan(p.id as number)
|
||
showToast('已删除')
|
||
await load()
|
||
}
|
||
|
||
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
function weekday(s: string): string {
|
||
return wdNames[parseDateStr(s).getDay()]
|
||
}
|
||
</script>
|
||
<template>
|
||
<div class="plan-page">
|
||
<!-- 长期计划 -->
|
||
<div class="blk-head">
|
||
<div class="blk-head-text">
|
||
<div class="blk-title">长期计划</div>
|
||
<div class="muted blk-sub">跨越多天的目标,自动落实到每一天</div>
|
||
</div>
|
||
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
|
||
</div>
|
||
<div v-if="!goals.length" class="card empty muted">
|
||
暂无长期计划。创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。
|
||
</div>
|
||
<div v-for="g in goals" :key="g.id" class="card goal-card">
|
||
<div class="goal-main">
|
||
<div class="goal-line">
|
||
<span class="tag">{{ g.kind }}</span>
|
||
<span class="goal-name">{{ g.title }}</span>
|
||
<span class="muted num goal-range">{{ g.start_date.slice(5) }} ~ {{ g.end_date.slice(5) }}</span>
|
||
</div>
|
||
<div class="goal-bar">
|
||
<span
|
||
class="goal-bar-fill"
|
||
:style="{ width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%' }"
|
||
></span>
|
||
</div>
|
||
<div class="muted num goal-meta">
|
||
{{ goalProg[String(g.id)] ? goalProg[String(g.id)].done + '/' + goalProg[String(g.id)].total + ' 天已完成' : '0/' + (goalProg[String(g.id)] ? goalProg[String(g.id)].total : 0) + ' 天' }}
|
||
<template v-if="g.note"> · {{ g.note }}</template>
|
||
</div>
|
||
</div>
|
||
<div class="goal-ops">
|
||
<button class="icon-btn" title="编辑" @click="openGoalEdit(g)"><Icon name="edit" :size="14" /></button>
|
||
<button class="icon-btn" title="删除" @click="delGoal = g"><Icon name="close" :size="14" /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 每日安排 -->
|
||
<div class="blk-head day-head">
|
||
<div class="blk-head-text">
|
||
<div class="blk-title">每日安排</div>
|
||
<div class="muted blk-sub num">
|
||
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
|
||
<template v-if="running"> · <span class="timer-live">计时中</span></template>
|
||
</div>
|
||
</div>
|
||
<div class="day-ops">
|
||
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||
<span class="date-chip num" :class="{ today: isToday }">{{ date.slice(5) }} {{ weekday(date) }}</span>
|
||
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
|
||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
|
||
</div>
|
||
</div>
|
||
<div class="card list-card">
|
||
<div v-if="!loading && !items.length" class="empty muted">今天还没有计划,点「添加计划」开始规划。</div>
|
||
<div
|
||
v-for="p in items" :key="p.id"
|
||
class="plan-item"
|
||
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
|
||
>
|
||
<div class="pi-time num">
|
||
{{ p.start_time }}<span class="muted"> - {{ p.end_time }}</span>
|
||
</div>
|
||
<div class="pi-main">
|
||
<div class="pi-title">
|
||
<span class="tag">{{ p.kind }}</span>
|
||
<span v-if="p.goal_id" class="tag tag-goal">长期</span>
|
||
<span class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
|
||
<span v-if="p.status === 'done'" class="state-done">已完成</span>
|
||
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
|
||
</div>
|
||
<div v-if="p.note" class="muted pi-note">{{ p.note }}</div>
|
||
<div class="muted pi-meta num">
|
||
<template v-if="p.timer_started_at">
|
||
<span class="timer-live">计时中 · 已 {{ liveElapsed(p) }} 分钟</span>
|
||
</template>
|
||
<template v-else-if="p.actual_minutes > 0">实际投入 {{ p.actual_minutes }} 分钟</template>
|
||
</div>
|
||
</div>
|
||
<div class="pi-ops">
|
||
<button
|
||
v-if="p.status !== 'done'" class="btn btn-xs"
|
||
:class="{ 'timer-on': !!p.timer_started_at }"
|
||
@click="toggleTimer(p)"
|
||
>{{ p.timer_started_at ? '停止计时' : '计时' }}</button>
|
||
<button v-if="p.status !== 'done'" class="btn btn-xs" @click="mark(p, 'done')">完成</button>
|
||
<button v-if="p.status === 'todo'" class="btn btn-xs" @click="mark(p, 'skip')">跳过</button>
|
||
<button v-if="p.status !== 'todo'" class="btn btn-xs" @click="mark(p, 'todo')">恢复</button>
|
||
<button class="icon-btn" title="编辑" @click="openEdit(p)"><Icon name="edit" :size="14" /></button>
|
||
<button class="icon-btn" title="删除" @click="remove(p)"><Icon name="close" :size="14" /></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<teleport to="body">
|
||
<div v-if="dialogOpen" class="mask" @click.self="dialogOpen = false">
|
||
<div class="dialog" role="dialog" aria-label="计划">
|
||
<div class="dialog-head">
|
||
<span class="dialog-title">{{ editingId ? '编辑计划' : '添加计划' }}</span>
|
||
<button class="icon-btn" @click="dialogOpen = false"><Icon name="close" :size="16" /></button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-row">
|
||
<label>计划内容(学什么、做什么)</label>
|
||
<input class="input" v-model="fTitle" placeholder="如:产品文档第三章 / 晨跑 5 公里" @keydown.enter="submitPlan" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>类别</label>
|
||
<select class="select" v-model="fKind">
|
||
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row two">
|
||
<div>
|
||
<label>开始时间</label>
|
||
<input class="input" type="time" v-model="fStart" />
|
||
</div>
|
||
<div>
|
||
<label>结束时间</label>
|
||
<input class="input" type="time" v-model="fEnd" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>备注(选填)</label>
|
||
<input class="input" v-model="fNote" placeholder="目标、资料、提醒等" @keydown.enter="submitPlan" />
|
||
</div>
|
||
<div v-if="fError" class="form-error">{{ fError }}</div>
|
||
</div>
|
||
<div class="dialog-btns">
|
||
<button class="btn btn-sm" @click="dialogOpen = false">取消</button>
|
||
<button class="btn btn-primary btn-sm" @click="submitPlan">{{ editingId ? '保存' : '添加' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</teleport>
|
||
<teleport to="body">
|
||
<div v-if="goalOpen" class="mask" @click.self="goalOpen = false">
|
||
<div class="dialog" role="dialog" aria-label="长期计划">
|
||
<div class="dialog-head">
|
||
<span class="dialog-title">{{ goalEditing ? '编辑长期计划' : '新建长期计划' }}</span>
|
||
<button class="icon-btn" @click="goalOpen = false"><Icon name="close" :size="16" /></button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-row">
|
||
<label>计划名称(落实为每天一条任务)</label>
|
||
<input class="input" v-model="gTitle" placeholder="如:产品文档学习 / 健身月计划" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>类别</label>
|
||
<select class="select" v-model="gKind">
|
||
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row two">
|
||
<div>
|
||
<label>开始日期</label>
|
||
<input class="input" type="date" v-model="gStart" />
|
||
</div>
|
||
<div>
|
||
<label>结束日期</label>
|
||
<input class="input" type="date" v-model="gEnd" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>备注(选填)</label>
|
||
<input class="input" v-model="gNote" />
|
||
</div>
|
||
<div v-if="gError" class="form-error">{{ gError }}</div>
|
||
<div class="muted goal-hint">创建后区间内每一天都会生成一条任务,可在每日安排中逐日打卡与计时。</div>
|
||
</div>
|
||
<div class="dialog-btns">
|
||
<button class="btn btn-sm" @click="goalOpen = false">取消</button>
|
||
<button class="btn btn-primary btn-sm" @click="submitGoal">{{ goalEditing ? '保存' : '创建' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</teleport>
|
||
|
||
<ConfirmDialog
|
||
:open="!!delGoal"
|
||
title="删除长期计划"
|
||
:message="delGoal ? '将删除「' + delGoal.title + '」及其全部每日任务(含已完成记录),不可恢复。' : ''"
|
||
confirm-text="删除" danger
|
||
@cancel="delGoal = null"
|
||
@confirm="removeGoal"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<style scoped>
|
||
.plan-page { display: flex; flex-direction: column; }
|
||
|
||
.blk-head {
|
||
display: flex; align-items: flex-start; justify-content: space-between;
|
||
gap: 12px; flex-wrap: wrap;
|
||
margin: 4px 2px 10px;
|
||
}
|
||
.blk-head-text { min-width: 0; }
|
||
.blk-title { font-size: 14px; font-weight: 600; }
|
||
.blk-sub { font-size: 12px; margin-top: 1px; }
|
||
.day-head { margin-top: 18px; }
|
||
.day-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.date-chip { min-width: 74px; text-align: center; font-weight: 600; font-size: 13px; }
|
||
.date-chip.today { color: var(--accent); }
|
||
.timer-live { color: var(--accent); font-weight: 600; }
|
||
|
||
.goal-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; margin-bottom: 10px; }
|
||
.goal-main { flex: 1; min-width: 0; }
|
||
.goal-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.goal-name { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||
.goal-range { font-size: 12px; }
|
||
.goal-bar { height: 6px; border-radius: 3px; background: var(--bg-subtle); overflow: hidden; margin-top: 10px; }
|
||
.goal-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
||
.goal-meta { font-size: 12px; margin-top: 6px; }
|
||
.goal-ops { display: flex; gap: 4px; }
|
||
|
||
.list-card { overflow: hidden; }
|
||
.plan-item {
|
||
display: flex; align-items: flex-start; gap: 12px;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.plan-item:last-child { border-bottom: none; }
|
||
.plan-item.done { opacity: 0.6; }
|
||
.plan-item.skip { opacity: 0.48; }
|
||
.plan-item.running { box-shadow: inset 3px 0 0 var(--accent); }
|
||
.pi-time { font-size: 13px; font-weight: 600; min-width: 92px; padding-top: 2px; }
|
||
.pi-main { flex: 1; min-width: 0; }
|
||
.pi-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.pi-text { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||
.pi-text.strike { text-decoration: line-through; }
|
||
.state-done { color: var(--ok); font-size: 12px; }
|
||
.state-skip { color: var(--text-3); font-size: 12px; }
|
||
.pi-note { font-size: 12px; margin-top: 3px; }
|
||
.pi-meta { font-size: 12px; margin-top: 3px; }
|
||
.pi-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.timer-on { color: var(--accent); border-color: var(--accent); }
|
||
.empty { padding: 28px 16px; }
|
||
|
||
.dialog { width: 420px; }
|
||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||
.form-row.two { flex-direction: row; gap: 10px; }
|
||
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
||
.goal-hint { font-size: 12px; margin-top: 8px; }
|
||
|
||
@media (max-width: 720px) {
|
||
.plan-item { flex-wrap: wrap; }
|
||
.pi-ops { width: 100%; justify-content: flex-start; }
|
||
.day-ops { width: 100%; }
|
||
}
|
||
</style>
|