@@ -1,7 +1,7 @@
const state = {
tasks : [ ] , version : 1 , selectedId : null , selectedTask : null ,
daily : null , history : null , summary : null , csrf : "" , authEnabled : false ,
filter : "" , editingId : null , dragId : null ,
filter : "" , editingId : null , dragId : null , selectedDate : null , ganttScale : "month" ,
} ;
const $ = ( selector , root = document ) => root . querySelector ( selector ) ;
@@ -14,6 +14,8 @@ const shanghaiDate = (offsetDays) => {
const escapeHtml = ( value ) => String ( value ? ? "" ) . replace ( /[&<>"']/g , ( char ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ char ] ) ) ;
const metricKindLabel = { checkin : "打卡" , quantity : "数量" , duration : "时长" } ;
const periodLabel = { day : "每日" , week : "每周" , month : "每月" , lifetime : "总计" } ;
const durationUnitLabel = { second : "秒" , minute : "分" , hour : "时" } ;
const DURATION _SECONDS = { second : 1 , minute : 60 , hour : 3600 } ;
async function api ( path , options = { } ) {
const opts = { ... options , headers : { ... ( options . body ? { "Content-Type" : "application/json" } : { } ) , ... ( options . headers || { } ) } } ;
@@ -102,6 +104,7 @@ const components = {
"task-list" : { render : renderTaskList , bind : bindSidebar } ,
"overview-summary" : { render : renderOverviewSummary } ,
"task-progress-grid" : { render : renderTaskProgressGrid , bind : bindOverview } ,
"task-gantt" : { render : renderTaskGantt , bind : bindGantt } ,
"task-header" : { render : renderTaskHeader , bind : bindTaskHeader } ,
"today-record" : { render : renderTodayRecord , bind : bindRecordForm } ,
"metric-progress" : { render : renderMetricProgress } ,
@@ -139,10 +142,11 @@ async function render() {
const heatmap = ( layout . regions . main || [ ] ) . find ( ( item ) => item . component === "activity-heatmap" ) ;
const historyDays = Math . min ( heatmap ? . options ? . historyDays ? ? 365 , 730 ) ;
const start = shanghaiDate ( - ( historyDays - 1 ) ) ;
const recordDate = state . selectedDate || today ( ) ;
try {
[ state . summary , state . daily , state . history ] = await Promise . all ( [
api ( "/api/summary" ) ,
api ( ` /api/tasks/ ${ state . selectedId } /daily/ ${ today ( ) } ` ) ,
api ( ` /api/tasks/ ${ state . selectedId } /daily/ ${ recordDate } ` ) ,
api ( ` /api/tasks/ ${ state . selectedId } /history?start= ${ start } &end= ${ today ( ) } ` ) ,
] ) ;
} catch ( error ) { toast ( error . message ) ; return ; }
@@ -156,18 +160,118 @@ function renderOverviewSummary() {
function renderTaskProgressGrid ( ) {
const active = state . tasks . filter ( ( task ) => ! task . deletedAt ) ;
return ` <section class="section"><div class="section-heading"><h2>任务进度</h2><span>按各指标自身周期计算</span></div><div class="overview-list"> ${ active . map ( ( task ) => ` <div class="overview-row" data-task-id=" ${ task . taskId } "><div class="overview-name"><strong> ${ escapeHtml ( task . name ) } </strong><small> ${ periodText ( task ) } · ${ task . metrics . length } 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width: ${ taskProgress ( task ) ? ? 0 } %"></div></div><span class="status-badge ${ task . state === "paused" ? "paused" : "" } "> ${ progressBadge ( task ) } </span></div> ` ) . join ( "" ) || ` <div class="empty-list">还没有计划,从右上角开始添加。</div> ` } </div></section> ` ;
const rows = active . map ( ( task ) => {
const summary = summaryFor ( task . taskId ) ;
const metrics = ( task . metrics || [ ] ) . map ( ( metric ) => {
const info = ( summary ? . metrics || [ ] ) . find ( ( item ) => item . metricId === metric . metricId ) || { value : 0 , percent : null } ;
const pct = info . percent ? ? 0 ;
const valueText = metricDisplayValue ( metric , info . value ? ? 0 ) ;
const targetText = metric . target ? ` / ${ metricDisplayTarget ( metric ) } ` : "" ;
return ` <div class="mini-progress"><span class="mini-progress-label" title=" ${ escapeHtml ( metric . name ) } "> ${ escapeHtml ( metric . name ) } <small> ${ valueText } ${ targetText } ${ metric . target ? ` · ${ pct } % ` : "" } </small></span><div class="progress-track"><div class="progress-fill" style="width: ${ pct } %"></div></div></div> ` ;
} ) . join ( "" ) ;
return ` <div class="overview-row" data-task-id=" ${ task . taskId } "><div class="overview-name"><strong> ${ escapeHtml ( task . name ) } </strong><small> ${ periodText ( task ) } · ${ task . metrics . length } 个指标</small></div><div class="overview-metrics"> ${ metrics } </div><span class="status-badge ${ task . state === "paused" ? "paused" : "" } "> ${ task . state === "paused" ? "已挂起" : "" } </span></div> ` ;
} ) . join ( "" ) ;
return ` <section class="section"><div class="section-heading"><h2>任务进度</h2><span>每个指标独立展示</span></div><div class="overview-list"> ${ rows || ` <div class="empty-list">还没有计划,从右上角开始添加。</div> ` } </div></section> ` ;
}
function bindOverview ( ) {
$$ ( ".overview-row" ) . forEach ( ( row ) => row . addEventListener ( "click" , ( ) => { state . selectedId = row . dataset . taskId ; render ( ) ; } ) ) ;
}
function taskProgress ( task ) { const value = summaryFor ( task . taskId ) ? . progress ; return value === null || value === undefined ? null : value ; }
function progressBadge ( task ) {
if ( task . state === "paused" ) return "已挂起" ;
const value = taskProgress ( task ) ;
return value === null ? "无上限" : ` ${ value } % ` ;
// ---- 主页任务时间甘特图 ----
// 日期统一用 UTC epoch-day 整数。「按周/按月」是缩放级别:一周和一月的
// 视觉宽度一致(约一个屏幕宽),时间轴覆盖全部任务,超出用底部横向滚动条。
function _epochDay ( isoDate ) {
if ( ! isoDate ) return null ;
return Math . floor ( Date . parse ( ` ${ isoDate } T00:00:00Z ` ) / 86400000 ) ;
}
function _ganttFiniteBounds ( tasks ) {
let min = Infinity , max = - Infinity , any = false ;
for ( const task of tasks ) {
const s = _epochDay ( task . startDate ) ;
const e = _epochDay ( task . endDate ) ;
if ( s !== null ) { min = Math . min ( min , s ) ; max = Math . max ( max , s ) ; any = true ; }
if ( e !== null ) { min = Math . min ( min , e ) ; max = Math . max ( max , e ) ; any = true ; }
}
return any ? { min , max } : { min : null , max : null } ;
}
// 月份带:每个跨月的月一个带,标签 "2026-08"。
function _ganttMonthBands ( min , max ) {
const bands = [ ] ;
let cursor = min ;
while ( cursor <= max ) {
const d = new Date ( cursor * 86400000 ) ;
const y = d . getUTCFullYear ( ) , m = d . getUTCMonth ( ) ;
const monthStart = _epochDay ( ` ${ y } - ${ String ( m + 1 ) . padStart ( 2 , "0" ) } -01 ` ) ;
const nextMonth = Math . floor ( new Date ( Date . UTC ( y , m + 1 , 1 ) ) . getTime ( ) / 86400000 ) ;
const start = Math . max ( monthStart , min ) ;
const end = Math . min ( nextMonth - 1 , max ) ;
bands . push ( { start , span : end - start + 1 , label : ` ${ y } - ${ String ( m + 1 ) . padStart ( 2 , "0" ) } ` } ) ;
cursor = nextMonth ;
}
return bands ;
}
// 天刻度:周视图每天标、月视图每 5 天标一次避免拥挤。
function _ganttDayMarks ( min , max , scale ) {
const step = scale === "week" ? 1 : 5 ;
const marks = [ ] ;
for ( let d = min ; d <= max ; d ++ ) {
if ( ( d - min ) % step !== 0 ) continue ;
const dt = new Date ( d * 86400000 ) ;
marks . push ( { start : d , label : ` ${ dt . getUTCMonth ( ) + 1 } / ${ dt . getUTCDate ( ) } ` } ) ;
}
return marks ;
}
function renderTaskGantt ( options ) {
const scale = options . scale || state . ganttScale || "month" ;
const active = state . tasks . filter ( ( task ) => ! task . deletedAt ) ;
if ( ! active . length ) return ` <section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览</span></div><div class="empty-list">还没有任务,从右上角新建</div></section> ` ;
const finite = _ganttFiniteBounds ( active ) ;
const todayEpoch = _epochDay ( today ( ) ) ;
let min , max ;
if ( finite . min === null ) { min = todayEpoch - 15 ; max = todayEpoch + 15 ; }
else { min = finite . min ; max = finite . max ; }
if ( todayEpoch < min ) min = todayEpoch ;
if ( todayEpoch > max ) max = todayEpoch ;
min -= 2 ; max += 2 ; // 两端留余量,容纳「无限」任务的箭头
const days = max - min + 1 ;
const col = ( epoch ) => ( epoch - min ) + 2 ; // 第 1 列是任务名列
const header = ` <div class="gantt-scale-switch"><button class="gantt-scale-btn ${ scale === "week" ? "active" : "" } " data-gantt-scale="week">按周</button><button class="gantt-scale-btn ${ scale === "month" ? "active" : "" } " data-gantt-scale="month">按月</button></div> ` ;
const monthBands = _ganttMonthBands ( min , max ) . map ( ( b ) =>
` <div class="gantt-month-band" style="--col: ${ col ( b . start ) } ; --span: ${ b . span } "> ${ b . label } </div> `
) . join ( "" ) ;
const dayMarks = _ganttDayMarks ( min , max , scale ) . map ( ( m ) =>
` <div class="gantt-day-mark" style="--col: ${ col ( m . start ) } "> ${ m . label } </div> `
) . join ( "" ) ;
const rows = active . map ( ( task , i ) => {
const row = i + 3 ; // 第 1 行月份带、第 2 行天刻度
const s = _epochDay ( task . startDate ) ;
const e = _epochDay ( task . endDate ) ;
const openLeft = s === null || s < min ;
const openRight = e === null || e > max ;
const startCol = openLeft ? 2 : col ( s ) ;
const endCol = openRight ? days + 2 : col ( e ) + 1 ; // grid-column 结束线为开区间
return ` <div class="gantt-label" data-task-id=" ${ task . taskId } " style="--row: ${ row } " title=" ${ escapeHtml ( task . name ) } "> ${ escapeHtml ( task . name ) } </div><div class="gantt-bar priority- ${ task . priority } ${ openLeft ? "open-left" : "" } ${ openRight ? "open-right" : "" } " data-task-id=" ${ task . taskId } " style="--row: ${ row } ; --start: ${ startCol } ; --end: ${ endCol } "></div> ` ;
} ) . join ( "" ) ;
const todayMark = ( todayEpoch >= min && todayEpoch <= max )
? ` <div class="gantt-today" style="--col: ${ col ( todayEpoch ) } "></div> `
: "" ;
return ` <section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览 · 无日期视为无限</span> ${ header } </div><div class="gantt-viewport" data-scale=" ${ scale } "><div class="gantt-layout" style="--days: ${ days } ; --rows: ${ active . length } "> ${ monthBands } ${ dayMarks } ${ rows } ${ todayMark } </div></div></section> ` ;
}
function bindGantt ( ) {
$$ ( ".gantt-label, .gantt-bar" ) . forEach ( ( node ) => node . addEventListener ( "click" , ( ) => { state . selectedId = node . dataset . taskId ; render ( ) ; } ) ) ;
$$ ( ".gantt-scale-btn" ) . forEach ( ( btn ) => btn . addEventListener ( "click" , ( ) => { state . ganttScale = btn . dataset . ganttScale ; renderLayout ( "home" ) ; } ) ) ;
}
function renderTaskHeader ( ) {
@@ -179,41 +283,60 @@ function bindTaskHeader() {
$ ( "#editTaskButton" ) . addEventListener ( "click" , ( ) => openTaskDialog ( state . selectedTask ) ) ;
}
function toDurationString ( seconds ) { const total = Math . max ( 0 , Math . floor ( Number ( seconds ) || 0 ) ) ; return ` ${ Math . floor ( total / 3600 ) } : ${ String ( Math . floor ( total % 3600 / 60 ) ) . padStart ( 2 , "0" ) } : ${ String ( total % 60 ) . padStart ( 2 , "0" ) } ` ; }
function from DurationString ( value ) { const parts = String ( value || "0" ) . trim ( ) . split ( ":" ) . map ( ( part ) => Math . max ( 0 , Math . floor ( Number ( part ) || 0 ) ) ) ; const [ hours = 0 , minutes = 0 , secs = 0 ] = parts ; return hours * 3600 + minutes * 60 + secs ; }
// 按指标配置的时长单位把「整数秒」格式化为人类可读口径。
function format Duration( seconds , unit ) {
const total = Math . max ( 0 , Math . floor ( Number ( seconds ) || 0 ) ) ;
const label = durationUnitLabel [ unit ] || "分" ;
const factor = DURATION _SECONDS [ unit ] || 60 ;
const value = total / factor ;
if ( factor === 3600 && value % 1 !== 0 && value < 10 ) return ` ${ value . toFixed ( 1 ) } 时 ` ;
return ` ${ Number . isInteger ( value ) ? value : value . toFixed ( 1 ) } ${ label } ` ;
}
function metricField ( metric ) {
const value = state . daily ? . values ? . [ metric . metricId ] || 0 ;
if ( metric . kind === "checkin" ) return ` <label class="record-field check"><input type="checkbox" data-metric-id=" ${ metric . metricId } " value="1" ${ value ? "checked" : "" } ><span><strong> ${ escapeHtml ( metric . name ) } </strong><small class="muted"> ${ periodLabel [ metric . period ] } </small></span></label> ` ;
if ( metric . kind === "duration" ) return ` <label class="record-field"><span><strong> ${ escapeHtml ( metric . name ) } </strong><small class="muted">时长 · 时:分:秒 · ${ periodLabel [ metric . period ] } </small></span><input type="text" inputmode="numeric" placeholder="0:30:00" data-kind="duration" data-metric-id=" ${ metric . metricId } " value=" ${ toDurationString ( value ) } "></label> ` ;
if ( metric . kind === "duration" ) {
const unit = metric . durationUnit || "minute" ;
const unitLabel = durationUnitLabel [ unit ] || "分" ;
const scaled = value / ( DURATION _SECONDS [ unit ] || 60 ) ;
return ` <label class="record-field"><span><strong> ${ escapeHtml ( metric . name ) } </strong><small class="muted">时长 · ${ unitLabel } · ${ periodLabel [ metric . period ] } </small></span><input type="number" min="0" step="any" data-kind="duration" data-duration-unit=" ${ unit } " data-metric-id=" ${ metric . metricId } " value=" ${ scaled } "></label> ` ;
}
return ` <label class="record-field"><span><strong> ${ escapeHtml ( metric . name ) } </strong><small class="muted"> ${ metricKindLabel [ metric . kind ] } · ${ metric . unit || "次" } · ${ periodLabel [ metric . period ] } </small></span><input type="number" min="0" step="1" data-metric-id=" ${ metric . metricId } " value=" ${ value } "></label> ` ;
}
function renderTodayRecord ( ) {
const task = state . selectedTask ;
const recordDate = state . selectedDate || today ( ) ;
const isToday = recordDate === today ( ) ;
const completedToday = Object . values ( state . daily ? . values || { } ) . some ( ( value ) => value > 0 ) ;
const hasCheckin = task . metrics . some ( ( metric ) => metric . kind === "checkin" ) ;
const fields = task . metrics . map ( metricField ) . join ( "" ) ;
return ` <section class="section" id="todayRecord"><div class="section-heading"><div><h2>今天, ${ t oday( ) } </h2><span>只记录今天真实完成的部分 </span></div><span class="status-badge ${ completedToday ? "" : "paused" } "> ${ completedToday ? "已有记录" : "尚未记录" } </span></div><div class="record-grid"> ${ fields } <label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步"> ${ escapeHtml ( state . daily ? . note || "" ) } </textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value=" ${ toLocalDatetime ( state . daily ? . completedAt ) } "></label></div><div class="record-actions"> ${ hasCheckin ? ` <button class="button primary" id="quickCheckinButton" type="button">一键打卡</button> ` : ` <span class="muted">修改会保留任务历史版本</span> ` } <button class="button primary" id="saveDailyButton">保存今日 记录</button></div></section> ` ;
return ` <section class="section" id="todayRecord"><div class="section-heading"><div><h2> ${ isT oday ? "今天" : "补录" } , ${ recordDate } </h2><span> ${ isToday ? "只记录今天真实完成的部分" : "补充过去某天的记录,历史事实不会被删除" } </span></div><span class="status-badge ${ completedToday ? "" : "paused" } "> ${ completedToday ? "已有记录" : "尚未记录" } </span></div><div class="record-date-row"><input id="recordDate" type="date" value=" ${ recordDate } " max=" ${ today ( ) } "></div><div class="record-grid"> ${ fields } <label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步"> ${ escapeHtml ( state . daily ? . note || "" ) } </textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value=" ${ toLocalDatetime ( state . daily ? . completedAt ) } "></label></div><div class="record-actions"> ${ hasCheckin ? ` <button class="button primary" id="quickCheckinButton" type="button">一键打卡</button> ` : ` <span class="muted">修改会保留任务历史版本</span> ` } <button class="button primary" id="saveDailyButton">保存${ isToday ? "今日" : "补录" } 记录</button></div></section> ` ;
}
function collectTodayValues ( ) {
return $$ ( ` #todayRecord [data-metric-id] ` ) . map ( ( node ) => ( {
metricId : node . dataset . metricId ,
value : node . type === "checkbox" ? ( node . checked ? 1 : 0 ) : ( node . dataset . kind === "duration" ? fromDurationString ( node . value ) : Math . max ( 0 , Number ( node . value || 0 ) ) ) ,
} ) ) ;
return $$ ( ` #todayRecord [data-metric-id] ` ) . map ( ( node ) => {
let value ;
if ( node . type === "checkbox" ) value = node . checked ? 1 : 0 ;
else if ( node . dataset . kind === "duration" ) value = Math . round ( Math . max ( 0 , Number ( node . value || 0 ) ) * ( DURATION _SECONDS [ node . dataset . durationUnit ] || 60 ) ) ;
else value = Math . max ( 0 , Number ( node . value || 0 ) ) ;
return { metricId : node . dataset . metricId , value } ;
} ) ;
}
function saveTodayRecord ( values = collectTodayValues ( ) ) {
const task = state . selectedTask ;
const recordDate = state . selectedDate || today ( ) ;
const completedAt = $ ( "#todayRecord #completedAt" ) ? . value || "" ;
return api ( ` /api/tasks/ ${ task . taskId } /daily/ ${ today ( ) } ` , { method : "PUT" , body : JSON . stringify ( { expectedVersion : state . daily ? . version || 0 , note : $ ( "#todayRecord #dailyNote" ) ? . value || "" , completedAt : completedAt ? ` ${ completedAt } :00 ` : null , values } ) } ) ;
return api ( ` /api/tasks/ ${ task . taskId } /daily/ ${ recordDate } ` , { method : "PUT" , body : JSON . stringify ( { expectedVersion : state . daily ? . version || 0 , note : $ ( "#todayRecord #dailyNote" ) ? . value || "" , completedAt : completedAt ? ` ${ completedAt } :00 ` : null , values } ) } ) ;
}
function toLocalDatetime ( value ) { return value ? value . slice ( 0 , 16 ) : "" ; }
function bindRecordForm ( ) {
$ ( "#recordDate" ) ? . addEventListener ( "change" , ( event ) => { state . selectedDate = event . target . value || null ; render ( ) ; } ) ;
$ ( "#saveDailyButton" ) . addEventListener ( "click" , async ( ) => {
try { await saveTodayRecord ( ) ; toast ( "今日记录已保存" ) ; await render ( ) ; } catch ( error ) { toast ( error . message ) ; }
try { await saveTodayRecord ( ) ; toast ( state . selectedDate ? "补录已保存" : "今日记录已保存" ) ; await render ( ) ; } catch ( error ) { toast ( error . message ) ; }
} ) ;
$ ( "#quickCheckinButton" ) ? . addEventListener ( "click" , async ( ) => {
$$ ( ` #todayRecord [data-metric-id][type="checkbox"] ` ) . forEach ( ( node ) => { node . checked = true ; } ) ;
@@ -221,9 +344,25 @@ function bindRecordForm() {
} ) ;
}
function metricDisplayValue ( metric , value ) {
if ( metric . kind === "duration" ) return formatDuration ( value , metric . durationUnit || "minute" ) ;
return ` ${ value } ${ metric . unit ? ` ${ metric . unit } ` : "" } ` ;
}
function metricDisplayTarget ( metric ) {
if ( metric . kind === "duration" ) return formatDuration ( metric . target , metric . durationUnit || "minute" ) ;
return metric . target ;
}
function renderMetricProgress ( ) {
const task = state . selectedTask ;
const cards = task . metrics . map ( ( metric ) => { const info = metricSummary ( task . taskId , metric . metricId ) || { value : 0 , target : metric . target , percent : null , windowStart : "" , windowEnd : "" } ; const scope = metric . period === "lifetime" ? ` 自 ${ info . windowStart } ` : ` ${ info . windowStart } ~ ${ info . windowEnd } ` ; return ` <div class="metric-card"><div class="metric-card-head"><strong> ${ escapeHtml ( metric . name ) } </strong><small> ${ metricKindLabel [ metric . kind ] } </small></div><div class="metric-value"> ${ info . value } ${ info . target ? ` <small>/ ${ info . target } </small> ` : "" } </div><div class="progress-track"><div class="progress-fill" style="width: ${ info . percent ? ? 0 } %"></div></div><small class="muted"> ${ periodLabel [ metric . period ] } · ${ info . percent === null ? "无上限" : ` ${ info . percent } % ` } · ${ scope } </small></div> ` ; } ) . join ( "" ) ;
const cards = task . metrics . map ( ( metric ) => {
const info = metricSummary ( task . taskId , metric . metricId ) || { value : 0 , target : metric . target , percent : null , windowStart : "" , windowEnd : "" } ;
const scope = metric . period === "lifetime" ? ` 自 ${ info . windowStart } ` : ` ${ info . windowStart } ~ ${ info . windowEnd } ` ;
const valueText = metricDisplayValue ( metric , info . value ) ;
const targetText = info . target ? ` <small>/ ${ metricDisplayTarget ( metric ) } </small> ` : "" ;
return ` <div class="metric-card"><div class="metric-card-head"><strong> ${ escapeHtml ( metric . name ) } </strong><small> ${ metricKindLabel [ metric . kind ] } </small></div><div class="metric-value"> ${ valueText } ${ targetText } </div><div class="progress-track"><div class="progress-fill" style="width: ${ info . percent ? ? 0 } %"></div></div><small class="muted"> ${ periodLabel [ metric . period ] } · ${ info . percent === null ? "无上限" : ` ${ info . percent } % ` } · ${ scope } </small></div> ` ;
} ) . join ( "" ) ;
return ` <section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards"> ${ cards } </div></section> ` ;
}
@@ -238,16 +377,102 @@ function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton"
function openTaskDialog ( task = null ) {
state . editingId = task ? . taskId || null ; const form = $ ( "#taskForm" ) ; form . reset ( ) ; $ ( "#taskDialogTitle" ) . textContent = task ? "编辑任务属性" : "新建任务" ;
form . startDate . value = task ? . startDate || today ( ) ; form . endDate . value = task ? . endDate || "" ; form . name . value = task ? . name || "" ; form . description . value = task ? . description || "" ; form . priority . value = task ? . priority || 3 ; form . state . value = task ? . state || "active" ;
form . startDate . value = task ? . startDate || "" ; form . endDate . value = task ? . endDate || "" ; form . name . value = task ? . name || "" ; form . description . value = task ? . description || "" ; form . priority . value = task ? . priority || 3 ; form . state . value = task ? . state || "active" ;
renderMetricEditor ( task ? . metrics || [ { name : "完成" , kind : "checkin" , period : "day" , target : 1 , unit : "次" } ] ) ; $ ( "#taskDialog" ) . showModal ( ) ;
}
function renderMetricEditor ( metrics ) { $ ( "#metricEditor" ) . innerHTML = metrics . map ( ( metric , index ) => ` <div class="metric-row" data-index=" ${ index } " data-metric-id=" ${ escapeHtml ( metric . metricId || "" ) } "><label>名称<input data-field="name" value=" ${ escapeHtml ( metric . name ) } " maxlength="60" required></label><label>类型<select data-field="kind"><option value="checkin" ${ metric . kind === "checkin" ? "selected" : "" } >打卡</option><option value="quantity" ${ metric . kind === "quantity" ? "selected" : "" } >数量</option><option value="duration" ${ metric . kind === "duration" ? "selected" : "" } >时长(秒)</option></select></label><label>周期<select data-field="period"><option value="day" ${ metric . period === "day" ? "selected" : "" } >每日</option><option value="week" ${ metric . period === "week" ? "selected" : "" } >每周</option><option value="month" ${ metric . period === "month" ? "selected" : "" } >每月</option><option value="lifetime" ${ metric . period === "lifetime" ? "selected" : "" } >总计</option></select></label><label>目标<input data-field="target" type="number" min="1" placeholder="无上限" value=" ${ metric . target ? ? "" } "></label><label>单位<input data-field="unit" maxlength="20" value=" ${ escapeHtml ( metric . unit || "" ) } " placeholder="次"></label><button type="button" class="icon-button remove-metric" data-remove=" ${ index } " title="删除指标" aria-label="删除指标">× </button></div> ` ) . join ( "" ) ; $$ ( "[data-remove]" ) . forEach ( ( button ) => button . addEventListener ( "click" , ( ) => { const rows = $$ ( ".metric-row" ) ; if ( rows . length <= 1 ) return toast ( "至少保留一个指标" ) ; button . closest ( ".metric-row" ) . remove ( ) ; } ) ) ; }
function metricTargetToEditor ( metric ) {
if ( metric . target == null ) return "" ;
if ( metric . kind === "duration" ) return metric . target / ( DURATION _SECONDS [ metric . durationUnit ] || 60 ) ;
return metric . target ;
}
function renderMetricEditor ( metrics ) {
$ ( "#metricEditor" ) . innerHTML = metrics . map ( ( metric , index ) => {
const isDuration = metric . kind === "duration" ;
const du = metric . durationUnit || "minute" ;
const unitField = ` <label data-role="unit" ${ isDuration ? "hidden" : "" } >单位<input data-field="unit" maxlength="20" value=" ${ escapeHtml ( metric . unit || "" ) } " placeholder="次"></label> ` ;
const durationUnitField = ` <label data-role="durationUnit" ${ isDuration ? "" : "hidden" } >时长单位<select data-field="durationUnit" data-prev-unit=" ${ du } "><option value="second" ${ du === "second" ? "selected" : "" } >秒</option><option value="minute" ${ du === "minute" ? "selected" : "" } >分</option><option value="hour" ${ du === "hour" ? "selected" : "" } >时</option></select></label> ` ;
const targetValue = metricTargetToEditor ( metric ) ;
return ` <div class="metric-row" data-index=" ${ index } " data-metric-id=" ${ escapeHtml ( metric . metricId || "" ) } "><label>名称<input data-field="name" value=" ${ escapeHtml ( metric . name ) } " maxlength="60" required></label><label>类型<select data-field="kind"><option value="checkin" ${ metric . kind === "checkin" ? "selected" : "" } >打卡</option><option value="quantity" ${ metric . kind === "quantity" ? "selected" : "" } >数量</option><option value="duration" ${ metric . kind === "duration" ? "selected" : "" } >时长</option></select></label><label>周期<select data-field="period"><option value="day" ${ metric . period === "day" ? "selected" : "" } >每日</option><option value="week" ${ metric . period === "week" ? "selected" : "" } >每周</option><option value="month" ${ metric . period === "month" ? "selected" : "" } >每月</option><option value="lifetime" ${ metric . period === "lifetime" ? "selected" : "" } >总计</option></select></label><label>目标<input data-field="target" type="number" min="1" placeholder="无上限" value=" ${ targetValue } "></label> ${ unitField } ${ durationUnitField } <button type="button" class="icon-button remove-metric" data-remove=" ${ index } " title="删除指标" aria-label="删除指标">× </button></div> ` ;
} ) . join ( "" ) ;
$$ ( "[data-remove]" ) . forEach ( ( button ) => button . addEventListener ( "click" , ( ) => { const rows = $$ ( ".metric-row" ) ; if ( rows . length <= 1 ) return toast ( "至少保留一个指标" ) ; button . closest ( ".metric-row" ) . remove ( ) ; } ) ) ;
$$ ( ".metric-row [data-field='kind']" ) . forEach ( ( select ) => select . addEventListener ( "change" , ( ) => {
const row = select . closest ( ".metric-row" ) ;
const isDuration = select . value === "duration" ;
row . querySelector ( "[data-role='unit']" ) . hidden = isDuration ;
row . querySelector ( "[data-role='durationUnit']" ) . hidden = ! isDuration ;
} ) ) ;
// 切换时长单位时,把目标数值按比例换算,保持“秒”语义不变。
$$ ( ".metric-row [data-field='durationUnit']" ) . forEach ( ( select ) => select . addEventListener ( "change" , ( ) => {
const row = select . closest ( ".metric-row" ) ;
const targetInput = row . querySelector ( "[data-field='target']" ) ;
if ( ! targetInput . value ) return ;
const fromFactor = DURATION _SECONDS [ select . dataset . prevUnit ] || 60 ;
const toFactor = DURATION _SECONDS [ select . value ] || 60 ;
const seconds = Number ( targetInput . value ) * fromFactor ;
targetInput . value = seconds / toFactor ;
select . dataset . prevUnit = select . value ;
} ) ) ;
}
$ ( "#addMetricButton" ) . addEventListener ( "click" , ( ) => { const metrics = $$ ( ".metric-row" ) . map ( ( row ) => Object . fromEntries ( $$ ( "[data-field]" , row ) . map ( ( input ) => [ input . dataset . field , input . value ] ) ) ) ; metrics . push ( { name : "新指标" , kind : "quantity" , period : "day" , target : "" , unit : "次" } ) ; renderMetricEditor ( metrics ) ; } ) ;
$ ( "#taskForm" ) . addEventListener ( "submit" , async ( event ) => { event . preventDefault ( ) ; const form = event . currentTarget ; const metrics = $$ ( ".metric-row" ) . map ( ( row ) => { const result = Object . fromEntries ( $$ ( "[data-field]" , row ) . map ( ( input ) => [ input . dataset . field , input . value ] ) ) ; result . metricId = row . dataset . metricId || null ; result . target = result . target ? Number ( result . target ) : null ; return result ; } ) ; const payload = { name : form . name . value , description : form . description . value , startDate : form . startDate . value , endDate : form . endDate . value || null , priority : Number ( form . priority . value ) , state : form . state . value , metrics } ; try { const endpoint = state . editingId ? ` /api/tasks/ ${ state . editingId } ` : "/api/tasks" ; const method = state . editingId ? "PUT" : "POST" ; if ( state . editingId ) payload . expectedVersion = state . selectedTask . version ; const task = await api ( endpoint , { method , body : JSON . stringify ( payload ) } ) ; $ ( "#taskDialog" ) . close ( ) ; state . selectedId = task . taskId ; toast ( state . editingId ? "任务属性已更新" : "任务已创建" ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ;
$ ( "#taskForm" ) . addEventListener ( "submit" , async ( event ) => { event . preventDefault ( ) ; const form = event . currentTarget ; const metrics = $$ ( ".metric-row" ) . map ( ( row ) => { const result = Object . fromEntries ( $$ ( "[data-field]" , row ) . map ( ( input ) => [ input . dataset . field , input . value ] ) ) ; result . metricId = row . dataset . metricId || null ; result . target = result . target ? Number ( result . target ) : null ; if ( result . kind === "duration" ) { result . unit = "" ; result . durationUnit = result . durationUnit || "minute" ; if ( result . target != null ) result . target = Math . round ( result . target * ( DURATION _SECONDS [ result . durationUnit ] || 60 ) ) ; } else { delete result . durationUnit ; } return result ; } ) ; const payload = { name : form . name . value , description : form . description . value , startDate : form . startDate . value || null , endDate : form . endDate . value || null , priority : Number ( form . priority . value ) , state : form . state . value , metrics } ; try { const endpoint = state . editingId ? ` /api/tasks/ ${ state . editingId } ` : "/api/tasks" ; const method = state . editingId ? "PUT" : "POST" ; if ( state . editingId ) payload . expectedVersion = state . selectedTask . version ; const task = await api ( endpoint , { method , body : JSON . stringify ( payload ) } ) ; $ ( "#taskDialog" ) . close ( ) ; state . selectedId = task . taskId ; toast ( state . editingId ? "任务属性已更新" : "任务已创建" ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ;
$ ( "#addTaskButton" ) . addEventListener ( "click" , ( ) => openTaskDialog ( ) ) ; $ ( "#homeButton" ) . addEventListener ( "click" , ( ) => { state . selectedId = null ; render ( ) ; } ) ;
$ ( "#adminButton" ) . addEventListener ( "click" , async ( ) => { const result = await api ( "/api/tasks?includeDeleted=true" ) ; const deleted = result . items . filter ( ( task ) => task . deletedAt ) ; $ ( "#trashList" ) . innerHTML = deleted . length ? deleted . map ( ( task ) => ` <div class="trash-item"><span><strong> ${ escapeHtml ( task . name ) } </strong><small class="muted"> ${ task . deletedAt } </small></span><span class="trash-item-actions"><button class="button" data-restore=" ${ task . taskId } ">恢复</button><button class="button danger" data-purge=" ${ task . taskId } ">永久删除</button></span></div> ` ) . join ( "" ) : ` <p class="muted">回收站为空</p> ` ; $ ( "#adminDialog" ) . showModal ( ) ; $$ ( "[data-restore]" ) . forEach ( ( button ) => button . addEventListener ( "click" , async ( ) => { try { await api ( ` /api/tasks/ ${ button . dataset . restore } /restore?expectedVersion= ${ deleted . find ( ( task ) => task . taskId === button . dataset . restore ) . version } ` , { method : "POST" } ) ; toast ( "任务已恢复" ) ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ) ; $$ ( "[data-purge]" ) . forEach ( ( button ) => button . addEventListener ( "click" , async ( ) => { const task = deleted . find ( ( item ) => item . taskId === button . dataset . purge ) ; if ( ! confirm ( ` 永久删除“ ${ task . name } ”?此操作不可恢复。 ` ) ) return ; try { await api ( ` /api/tasks/ ${ task . taskId } /purge ` , { method : "POST" , body : JSON . stringify ( { confirmation : task . taskId } ) } ) ; toast ( "任务及其历史已永久删除" ) ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ) ; } ) ;
$ ( "#closeAdminButton" ) . addEventListener ( "click" , ( ) => $ ( "#adminDialog" ) . close ( ) ) ; $ ( "#export Button" ) . addEventListener ( "click" , async ( ) => { const response = await fetch ( "/api/export" ) ; const blob = await response . blob ( ) ; const link = document . createElement ( "a" ) ; link . href = URL . createObjectURL ( blob ) ; link . download = "progress-export.json" ; link . click ( ) ; URL . revokeObjectURL ( link . href ) ; } ) ; $ ( "#archiveButton" ) . addEventListener ( "click" , async ( ) => { try { const result = await api ( "/api/admin/archive" , { method : "POST" , body : "{}" } ) ; toast ( ` 补归档完成,处理 ${ result . closedDays } 天 ` ) ; } catch ( error ) { toast ( error . message ) ; } } ) ; $ ( "#logoutButton" ) . addEventListener ( "click" , async ( ) => { await api ( "/api/auth/logout" , { method : "POST" , body : "{}" } ) ; showLogin ( ) ; } ) ;
$ ( "#cancelTaskButton" ) . addEventListener ( "click" , ( ) => $ ( "#taskDialog" ) . close ( ) ) ;
$ ( "#closeTask Button" ) . addEventListener ( "click" , ( ) => $ ( "#taskDialog" ) . close ( ) ) ;
function renderAdminStats ( stats ) {
const cells = [
[ "任务" , stats . taskCount , "含回收站" ] ,
[ "活跃" , stats . activeTaskCount , "未删除" ] ,
[ "每日事实" , stats . dailyFactCount , "条记录" ] ,
[ "归档" , stats . archiveCount , "月" ] ,
[ "修订" , stats . revisionCount , "次" ] ,
[ "占用" , formatBytes ( stats . storageBytes ) , "存储" ] ,
] ;
return cells . map ( ( [ label , value , hint ] ) => ` <div class="summary-cell"><strong> ${ escapeHtml ( value ) } </strong><span> ${ label } · ${ hint } </span></div> ` ) . join ( "" ) ;
}
function formatBytes ( bytes ) { const n = Number ( bytes ) || 0 ; if ( n < 1024 ) return ` ${ n } B ` ; if ( n < 1024 * 1024 ) return ` ${ ( n / 1024 ) . toFixed ( 1 ) } KB ` ; return ` ${ ( n / 1024 / 1024 ) . toFixed ( 1 ) } MB ` ; }
$ ( "#adminButton" ) . addEventListener ( "click" , async ( ) => {
try {
const [ result , stats ] = await Promise . all ( [ api ( "/api/tasks?includeDeleted=true" ) , api ( "/api/admin/stats" ) ] ) ;
const all = result . items ;
const deleted = all . filter ( ( task ) => task . deletedAt ) ;
$ ( "#adminStats" ) . innerHTML = ` <div class="summary-strip admin-stats-strip"> ${ renderAdminStats ( stats ) } </div> ` ;
$ ( "#taskAdminList" ) . innerHTML = all . map ( ( task ) => {
const badge = task . deletedAt ? ` <span class="status-badge paused">已删除</span> ` : ` <span class="status-badge"> ${ task . state === "paused" ? "挂起" : "进行中" } </span> ` ;
const actions = task . deletedAt
? ` <button class="button" data-restore=" ${ task . taskId } ">恢复</button><button class="button danger" data-purge=" ${ task . taskId } ">永久删除</button> `
: ` <button class="button" data-admin-delete=" ${ task . taskId } ">移入回收站</button> ` ;
return ` <div class="trash-item"><span><strong> ${ escapeHtml ( task . name ) } </strong><small class="muted"> ${ escapeHtml ( task . startDate ) } · 优先级 ${ task . priority } · ${ task . metrics . length } 指标</small> ${ badge } </span><span class="trash-item-actions"> ${ actions } </span></div> ` ;
} ) . join ( "" ) || ` <p class="muted">暂无任务</p> ` ;
$ ( "#trashList" ) . innerHTML = deleted . length ? deleted . map ( ( task ) => ` <div class="trash-item"><span><strong> ${ escapeHtml ( task . name ) } </strong><small class="muted"> ${ task . deletedAt } </small></span><span class="trash-item-actions"><button class="button" data-restore=" ${ task . taskId } ">恢复</button><button class="button danger" data-purge=" ${ task . taskId } ">永久删除</button></span></div> ` ) . join ( "" ) : ` <p class="muted">回收站为空</p> ` ;
$ ( "#adminDialog" ) . showModal ( ) ;
$$ ( "[data-admin-delete]" ) . forEach ( ( button ) => button . addEventListener ( "click" , async ( ) => {
const task = all . find ( ( item ) => item . taskId === button . dataset . adminDelete ) ;
if ( ! confirm ( ` 将“ ${ task . name } ”移入回收站? ` ) ) return ;
try { await api ( ` /api/tasks/ ${ task . taskId } ?expectedVersion= ${ task . version } ` , { method : "DELETE" } ) ; toast ( "已移入回收站" ) ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; }
} ) ) ;
$$ ( "[data-restore]" ) . forEach ( ( button ) => button . addEventListener ( "click" , async ( ) => { try { await api ( ` /api/tasks/ ${ button . dataset . restore } /restore?expectedVersion= ${ all . find ( ( task ) => task . taskId === button . dataset . restore ) . version } ` , { method : "POST" } ) ; toast ( "任务已恢复" ) ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ) ;
$$ ( "[data-purge]" ) . forEach ( ( button ) => button . addEventListener ( "click" , async ( ) => { const task = all . find ( ( item ) => item . taskId === button . dataset . purge ) ; if ( ! confirm ( ` 永久删除“ ${ task . name } ”?此操作不可恢复。 ` ) ) return ; try { await api ( ` /api/tasks/ ${ task . taskId } /purge ` , { method : "POST" , body : JSON . stringify ( { confirmation : task . taskId } ) } ) ; toast ( "任务及其历史已永久删除" ) ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ) ;
} catch ( error ) { toast ( error . message ) ; }
} ) ;
$ ( "#closeAdminButton" ) . addEventListener ( "click" , ( ) => $ ( "#adminDialog" ) . close ( ) ) ; $ ( "#exportButton" ) . addEventListener ( "click" , async ( ) => { const response = await fetch ( "/api/export" ) ; const blob = await response . blob ( ) ; const link = document . createElement ( "a" ) ; link . href = URL . createObjectURL ( blob ) ; link . download = "progress-export.json" ; link . click ( ) ; URL . revokeObjectURL ( link . href ) ; } ) ;
$ ( "#importButton" ) . addEventListener ( "click" , ( ) => $ ( "#importFile" ) . click ( ) ) ;
$ ( "#importFile" ) . addEventListener ( "change" , async ( event ) => {
const file = event . target . files [ 0 ] ; if ( ! file ) return ;
if ( ! confirm ( "导入将【整体替换】当前全部数据,且不可撤销。确认继续?" ) ) { event . target . value = "" ; return ; }
try {
const text = await file . text ( ) ; const payload = JSON . parse ( text ) ;
const result = await api ( "/api/admin/import" , { method : "POST" , body : JSON . stringify ( payload ) } ) ;
toast ( ` 已还原 ${ result . imported } 个任务 ` ) ; event . target . value = "" ; $ ( "#adminDialog" ) . close ( ) ; await loadTasks ( ) ;
} catch ( error ) { toast ( error . message ) ; event . target . value = "" ; }
} ) ;
$ ( "#archiveButton" ) . addEventListener ( "click" , async ( ) => { try { const result = await api ( "/api/admin/archive" , { method : "POST" , body : "{}" } ) ; toast ( ` 补归档完成,处理 ${ result . closedDays } 天 ` ) ; } catch ( error ) { toast ( error . message ) ; } } ) ; $ ( "#logoutButton" ) . addEventListener ( "click" , async ( ) => { await api ( "/api/auth/logout" , { method : "POST" , body : "{}" } ) ; showLogin ( ) ; } ) ;
$ ( "#loginForm" ) . addEventListener ( "submit" , async ( event ) => { event . preventDefault ( ) ; try { const response = await fetch ( "/api/auth/login" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( { password : event . currentTarget . password . value } ) } ) ; const body = await response . json ( ) ; if ( ! response . ok ) throw new Error ( body . detail || "登录失败" ) ; state . csrf = body . csrfToken ; hideLogin ( ) ; await loadTasks ( ) ; } catch ( error ) { $ ( "#loginError" ) . textContent = error . message ; } } ) ;
bootstrap ( ) ;