@@ -1,12 +1,16 @@
const state = {
tasks : [ ] , version : 1 , selectedId : null , selectedTask : null ,
daily : null , history : null , layout : null , csrf : "" , authEnabled : false ,
daily : null , history : null , summary : null , csrf : "" , authEnabled : false ,
filter : "" , editingId : null , dragId : null ,
} ;
const $ = ( selector , root = document ) => root . querySelector ( selector ) ;
const $$ = ( selector , root = document ) => [ ... root . querySelectorAll ( selector ) ] ;
const today = ( ) => new Intl . DateTimeFormat ( "en-CA" , { timeZone : "Asia/Shanghai" } ) . format ( new Date ( ) ) ;
const shanghaiDate = ( offsetDays ) => {
const now = new Date ( Date . now ( ) + offsetDays * 86400000 ) ;
return new Intl . DateTimeFormat ( "en-CA" , { timeZone : "Asia/Shanghai" } ) . format ( now ) ;
} ;
const escapeHtml = ( value ) => String ( value ? ? "" ) . replace ( /[&<>"']/g , ( char ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ char ] ) ) ;
const metricKindLabel = { checkin : "打卡" , quantity : "数量" , duration : "时长" } ;
const periodLabel = { day : "每日" , week : "每周" , month : "每月" , lifetime : "总计" } ;
@@ -31,32 +35,46 @@ function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute
function hideLogin ( ) { $ ( "#loginOverlay" ) . hidden = true ; $ ( "#app" ) . setAttribute ( "aria-busy" , "false" ) ; }
async function bootstrap ( ) {
const dateNode = $ ( "#logicalDate" ) ; if ( dateNode ) dateNode . textContent = today ( ) ;
try {
const auth = await api ( "/api/auth/status" ) ;
state . authEnabled = auth . enabled ; state . csrf = auth . csrfToken || "" ;
if ( auth . enabled && ! auth . authenticated ) { showLogin ( ) ; return ; }
hideLogin ( ) ;
await loadLayout ( ) ; await loadTasks ( ) ;
await loadTasks ( ) ;
} catch ( error ) { toast ( error . message ) ; }
}
async function loadLayout ( ) { state . layout = await api ( "/api/layouts/home" ) ; }
const layoutCache = { home : null , "task-detail" : null } ;
async function ensureLayout ( page ) { if ( ! layoutCache [ page ] ) layoutCache [ page ] = await api ( ` /api/layouts/ ${ page } ` ) ; return layoutCache [ page ] ; }
async function loadTasks ( ) {
const result = await api ( "/api/tasks" ) ; state . tasks = result . items ; state . version = result . version ;
if ( ! state . selectedId || ! state . tasks . some ( ( task ) => task . taskId === state . selectedId ) ) state . selectedId = state . tasks [ 0 ] ? . taskId || null ;
await render ( ) ;
}
function renderSidebar ( ) {
function renderTaskList ( ) {
const filtered = state . tasks . filter ( ( task ) => ! state . filter || task . name . toLowerCase ( ) . includes ( state . filter . toLowerCase ( ) ) ) ;
const items = filtered . map ( ( task ) => ` <div class="task-item ${ task . taskId === state . selectedId ? "selected" : "" } " draggable="true" data-task-id=" ${ task . taskId } ">
<span class="priority-bar priority- ${ task . priority } "></span><span class="task-copy"><strong> ${ escapeHtml ( task . name ) } </strong><small> ${ periodText ( task ) } · ${ task . metrics . length } 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div> ` ) . join ( "" ) ;
$ ( "#sidebar" ) . innerHTML = ` <div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span> ${ state . tasks . length } 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value=" ${ escapeHtml ( state . filter ) } "><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">× </button></div><div class="task-list"> ${ items || ` <div class="empty-list">还没有任务<br>从右上角新建一项计划</div> ` } </div> ` ;
$ ( "#taskFilter" ) . addEventListener ( "input" , ( event ) => { state . filter = event . target . value ; renderSidebar ( ) ; bindSidebar ( ) ; } ) ;
$ ( "#clearFilter" ) . addEventListener ( "click" , ( ) => { state . filter = "" ; renderSidebar ( ) ; bindSidebar ( ) ; } ) ; bindSidebar ( ) ;
return ` <div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span> ${ state . tasks . length } 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value=" ${ escapeHtml ( state . filter ) } "><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">× </button></div><div class="task-list"> ${ items || ` <div class="empty-list">还没有任务<br>从右上角新建一项计划</div> ` } </div> ` ;
}
function bindSidebar ( ) {
const filterInput = $ ( "#taskFilter" ) ;
if ( filterInput ) {
filterInput . addEventListener ( "input" , ( event ) => { state . filter = event . target . value ; rerenderSidebarList ( ) ; } ) ;
$ ( "#clearFilter" ) ? . addEventListener ( "click" , ( ) => { state . filter = "" ; rerenderSidebarList ( ) ; } ) ;
}
bindSidebarItems ( ) ;
}
function rerenderSidebarList ( ) {
$ ( "#sidebar" ) . innerHTML = renderTaskList ( ) ;
bindSidebar ( ) ;
}
function bindSidebarItems ( ) {
$$ ( ".task-item" ) . forEach ( ( node ) => {
node . addEventListener ( "click" , ( ) => { state . selectedId = node . dataset . taskId ; render ( ) ; } ) ;
node . addEventListener ( "dragstart" , ( ) => { state . dragId = node . dataset . taskId ; node . classList . add ( "dragging" ) ; } ) ;
@@ -74,57 +92,149 @@ async function reorder(fromId, toId) {
}
function periodText ( task ) { const metrics = task . metrics || [ ] ; return [ ... new Set ( metrics . map ( ( metric ) => periodLabel [ metric . period ] || metric . period ) ) ] . join ( " / " ) ; }
function percent ( value , target ) { return target ? Math . min ( 100 , Math . round ( value / target * 100 ) ) : value ? 100 : 0 ; }
function metricTotal ( metricId ) { return ( state . history ? . records || [ ] ) . reduce ( ( sum , record ) => sum + Number ( record . values ? . [ metricId ] || 0 ) , 0 ) ; }
function summaryFor ( taskId ) { return s tate . summary ? . tasks ? . [ taskId ] || null ; }
function metricSummary ( taskId , metricId ) { return summaryFor ( taskId ) ? . metrics ? . find ( ( metric ) => metric . metricId === metricId ) || null ; }
async function render ( ) {
renderSidebar ( ) ;
if ( ! state . selectedId ) { renderHome ( ) ; return ; }
state . selectedTask = state . tasks . find ( ( task ) => task . taskId === state . selectedId ) ;
try { [ state . daily , state . history ] = await Promise . all ( [ api ( ` /api/tasks/ ${ state . selectedId } /daily/ ${ today ( ) } ` ) , api ( ` /api/tasks/ ${ state . selectedId } /history ` ) ] ) ; }
catch ( error ) { toast ( error . message ) ; return ; }
renderDetail ( ) ;
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
const components = {
"task-list" : { render : renderTaskList , bind : bindSidebar } ,
"overview-summary" : { render : renderOverviewSummary } ,
"task-progress-grid" : { render : renderTaskProgressGrid , bind : bindOverview } ,
"task-header" : { render : renderTaskHeader , bind : bindTaskHeader } ,
"today-record" : { render : renderTodayRecord , bind : bindRecordForm } ,
"metric-progress" : { render : renderMetricProgress } ,
"activity-heatmap" : { render : renderActivityHeatmap } ,
"task-settings" : { render : renderTaskSettings , bind : bindSettings } ,
} ;
function componentHtml ( item ) { const entry = components [ item . component ] ; return entry ? entry . render ( item . options || { } ) : "" ; }
function bindComponents ( items ) {
for ( const item of items ) {
const entry = components [ item . component ] ;
if ( entry ? . bind ) entry . bind ( item . options || { } ) ;
}
}
function renderHome ( ) {
const active = state . tasks . filter ( ( task ) => ! task . deletedAt ) ; const completed = active . filter ( ( task ) => task . state === "active" ) . length ;
$ ( "#main" ) . innerHTML = ` <div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${ today ( ) } </p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong> ${ active . length } </strong><span>进行中的任务</span></div><div class="summary-cell"><strong> ${ completed } </strong><span>活跃计划</span></div><div class="summary-cell"><strong> ${ active . filter ( ( task ) => task . priority <= 2 ) . length } </strong><span>高优先级</span></div></div><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 ) } %"></div></div><span class="status-badge ${ task . state === "paused" ? "paused" : "" } "> ${ task . state === "paused" ? "已挂起" : ` ${ taskProgress ( task ) } % ` } </span></div> ` ) . join ( "" ) || ` <div class="empty-list">还没有计划,从右上角开始添加。</div> ` } </div></section> ` ;
async function renderLayout ( page ) {
const layout = await ensureLayout ( page ) ;
const sidebarItems = layout . regions . sidebar || [ ] ;
const mainItems = layout . regions . main || [ ] ;
$ ( "#sidebar" ) . innerHTML = sidebarItems . map ( componentHtml ) . join ( "" ) ;
$ ( "#main" ) . innerHTML = mainItems . map ( componentHtml ) . join ( "" ) ;
bindComponents ( sidebarItems ) ;
bindComponents ( mainItems ) ;
}
async function render ( ) {
if ( ! state . selectedId ) {
try { state . summary = await api ( "/api/summary" ) ; } catch ( error ) { toast ( error . message ) ; }
await renderLayout ( "home" ) ;
return ;
}
state . selectedTask = state . tasks . find ( ( task ) => task . taskId === state . selectedId ) ;
const layout = await ensureLayout ( "task-detail" ) ;
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 ) ) ;
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 } /history?start= ${ start } &end= ${ today ( ) } ` ) ,
] ) ;
} catch ( error ) { toast ( error . message ) ; return ; }
await renderLayout ( "task-detail" ) ;
}
function renderOverviewSummary ( ) {
const active = state . tasks . filter ( ( task ) => ! task . deletedAt ) ; const activePlans = active . filter ( ( task ) => task . state === "active" ) . length ;
return ` <div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${ today ( ) } </p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong> ${ active . length } </strong><span>进行中的任务</span></div><div class="summary-cell"><strong> ${ activePlans } </strong><span>活跃计划</span></div><div class="summary-cell"><strong> ${ active . filter ( ( task ) => task . priority <= 2 ) . length } </strong><span>高优先级</span></div></div> ` ;
}
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> ` ;
}
function bindOverview ( ) {
$$ ( ".overview-row" ) . forEach ( ( row ) => row . addEventListener ( "click" , ( ) => { state . selectedId = row . dataset . taskId ; render ( ) ; } ) ) ;
}
function taskProgress ( task ) {
const values = task . metrics . map ( ( metric ) => ( { value : metricTotal ( metric . metricId ) , target : metric . target } ) ) . filter ( ( item ) => item . target ) ;
if ( ! values . length ) return 0 ; return Math . round ( values . reduce ( ( sum , item ) => sum + percent ( item . value , item . target ) , 0 ) / values . length ) ;
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 } % ` ;
}
function renderDetail ( ) {
const task = state . selectedTask ; const completedToday = Object . values ( state . daily ? . values || { } ) . some ( ( value ) => value > 0 ) ;
$ ( "#main" ) . innerHTML = ` <div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${ task . priority } </p><h1> ${ escapeHtml ( task . name ) } </h1><p> ${ escapeHtml ( task . description || "还没有写下说明" ) } </p></div><div class="top-actions"><span class="status-badge ${ task . state === "paused" ? "paused" : "" } "> ${ task . state === "paused" ? "已挂起" : "进行中" } </span><button class="button" id="editTaskButton">编辑属性</button></div></div><div class="tabs"><button class="tab active" data-tab="record">今日记录</button><button class="tab" data-tab="insight">完成情况</button><button class="tab" data-tab="settings">任务设置</button></div><div class="tab-panel" data-panel="record"> ${ renderTodayRecord ( task , completedToday ) } </div><div class="tab-panel" data-panel="insight" hidden> ${ renderInsight ( task ) } </div><div class="tab-panel" data-panel="settings" hidden> ${ renderSettings ( task ) } </div> ` ;
$$ ( ".tab" ) . forEach ( ( tab ) => tab . addEventListener ( "click" , ( ) => { $$ ( ".tab" ) . forEach ( ( item ) => item . classList . toggle ( "active" , item === tab ) ) ; $$ ( ".tab-panel" ) . forEach ( ( panel ) => panel . hidden = panel . dataset . panel !== tab . dataset . tab ) ; } ) ) ;
$ ( "#editTaskButton" ) . addEventListener ( "click" , ( ) => openTaskDialog ( task ) ) ; bindRecordForm ( task ) ; bindSettings ( task ) ;
function renderTaskHeader ( ) {
const task = state . selectedTask ;
return ` <div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${ task . priority } </p><h1> ${ escapeHtml ( task . name ) } </h1><p> ${ escapeHtml ( task . description || "还没有写下说明" ) } </p></div><div class="top-actions"><span class="status-badge ${ task . state === "paused" ? "paused" : "" } "> ${ task . state === "paused" ? "已挂起" : "进行中" } </span><button class="button" id="editTaskButton">编辑属性</button></div></div> ` ;
}
function renderTodayRecord ( task , completedToday ) {
const fields = task . metrics . map ( ( 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> ` ; return ` <label class="record-field"><span><strong> ${ escapeHtml ( metric . name ) } </strong><small class="muted"> ${ metricKindLabel [ metric . kind ] } · ${ metric . unit || ( metric . kind === "duration" ? "秒" : "次" ) } · ${ periodLabel [ metric . period ] } </small></span><input type="number" min="0" step="1" data-metric-id=" ${ metric . metricId } " value=" ${ value } "></label> ` ; } ) . join ( "" ) ;
return ` <section class="section"><div class="section-heading"><div><h2>今天, ${ today ( ) } </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"><span class="muted">修改会保留任务历史版本</span><button class="button primary" id="saveDailyButton">保存今日记录</button></div></section> ` ;
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 fromDurationString ( 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 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> ` ;
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 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>今天, ${ today ( ) } </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> ` ;
}
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 ) ) ) ,
} ) ) ;
}
function saveTodayRecord ( values = collectTodayValues ( ) ) {
const task = state . selectedTask ;
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 } ) } ) ;
}
function toLocalDatetime ( value ) { return value ? value . slice ( 0 , 16 ) : "" ; }
function bindRecordForm ( task ) {
function bindRecordForm ( ) {
$ ( "#saveDailyButton" ) . addEventListener ( "click" , async ( ) => {
const values = $$ ( ` [data-metric-id] ` ) . map ( ( node ) => ( { metricId : no de. dataset . metricId , value : node . type === "checkbox" ? ( node . checked ? 1 : 0 ) : Math . max ( 0 , Number ( node . value || 0 ) ) } ) ) ;
const completedAt = $ ( "#completedAt" ) . value ; try { await api ( ` /api/tasks/ ${ task . taskId } /daily/ ${ today ( ) } ` , { method : "PUT" , body : JSON . stringify ( { expectedVersion : state . daily ? . version || 0 , note : $ ( "#dailyNote" ) . value , completedAt : completedAt ? new Date ( completedAt ) . toISOString ( ) : null , values } ) } ) ; toast ( "今日记录已保存" ) ; await render ( ) ; } catch ( error ) { toast ( error . message ) ; }
try { await saveTodayRecord ( ) ; toast ( "今日记录已保存" ) ; await ren der ( ) ; } catch ( error ) { toast ( error . message ) ; }
}) ;
$ ( "#quickCheckinButton" ) ? . addEventListener ( "click" , async ( ) => {
$$ ( ` #todayRecord [data-metric-id][type="checkbox"] ` ) . forEach ( ( node ) => { node . checked = true ; } ) ;
try { await saveTodayRecord ( ) ; toast ( "已打卡" ) ; await render ( ) ; } catch ( error ) { toast ( error . message ) ; }
} ) ;
}
function renderInsight ( task ) {
const cards = task . metrics . map ( ( metric ) => { const total = metricTotal ( metric . metricId ) ; const target = metric . target ; 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"> ${ total } ${ target ? ` <small>/ ${ target } </small> ` : "" } </div><div class="progress-track"><div class="progress-fill" style="width: ${ percent ( total , target ) } %"></div></div><small class="muted"> ${ target ? ` ${ percent ( total , target ) } % · ${ periodLabel [ metric . period ] } ` : ` 累计 ${ periodLabel [ metric . period ] } · 无上限 ` } </small></div> ` ; } ) . join ( "" ) ;
const heat = Array . from ( { length : 364 } , ( _ , index ) => { const day = new Date ( ) ; day . setDate ( day . g etDate ( ) - ( 363 - index ) ) ; const iso = day . toISOString ( ) . slice ( 0 , 10 ) ; const record = state . history ? . records ? . f ind ( ( item ) => item . date === iso ) ; const amount = record ? Object . values ( record . values || { } ) . reduce ( ( sum , value ) => sum + Number ( value ) , 0 ) : 0 ; return ` <span class="heat-cell level- ${ amou nt ? Math . min ( 4 , Math . ceil ( amount / 2 ) ) : 0 } " title=" ${ iso } · ${ amount } "> </span >` ; } ) . join ( "" ) ;
return ` <section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards"> ${ cards } </div></section><section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 12 个月</span></div><div class="heatmap-wrap"><div class="heatmap"> ${ heat } </div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section> ` ;
function renderMetricProgress ( ) {
const task = state . selectedTask ;
const cards = task . metrics . map ( ( metric ) => { const info = metricSummary ( task . taskId , metric . m etricId ) || { 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 . perce nt ? ? 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> ` ;
}
function renderSettings ( task ) { return ` <section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section> ` ; }
function bindSettings ( task ) { $ ( "#deleteTaskButton" ) . addEventListener ( "click" , async ( ) => { if ( ! confirm ( ` 确认将“ ${ task . name } ”移入回收站? ` ) ) return ; try { await api ( ` /api/tasks/ ${ task . taskId } ?expectedVersion= ${ task . version } ` , { method : "DELETE" } ) ; state . selectedId = null ; toast ( "任务已移入回收站" ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ; }
function renderActivityHeatmap ( options ) {
const days = Math . min ( options . historyDays ? ? 365 , 730 ) ;
const heat = Array . from ( { length : days } , ( _ , index ) => { const iso = shanghaiDate ( index - ( days - 1 ) ) ; const record = state . history ? . records ? . find ( ( item ) => item . date === iso ) ; const amount = record ? Object . values ( record . values || { } ) . reduce ( ( sum , value ) => sum + Number ( value ) , 0 ) : 0 ; return ` <span class="heat-cell level- ${ amount ? Math . min ( 4 , Math . ceil ( amount / 2 ) ) : 0 } " title=" ${ iso } · ${ amount } "></span> ` ; } ) . join ( "" ) ;
return ` <section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 ${ Math . round ( days / 30 ) } 个月</span></div><div class="heatmap-wrap"><div class="heatmap"> ${ heat } </div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section> ` ;
}
function renderTaskSettings ( ) { return ` <section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section> ` ; }
function bindSettings ( ) { const task = state . selectedTask ; $ ( "#deleteTaskButton" ) . addEventListener ( "click" , async ( ) => { if ( ! confirm ( ` 确认将“ ${ task . name } ”移入回收站? ` ) ) return ; try { await api ( ` /api/tasks/ ${ task . taskId } ?expectedVersion= ${ task . version } ` , { method : "DELETE" } ) ; state . selectedId = null ; toast ( "任务已移入回收站" ) ; await loadTasks ( ) ; } catch ( error ) { toast ( error . message ) ; } } ) ; }
function openTaskDialog ( task = null ) {
state . editingId = task ? . taskId || null ; const form = $ ( "#taskForm" ) ; form . reset ( ) ; $ ( "#taskDialogTitle" ) . textContent = task ? "编辑任务属性" : "新建任务" ;
@@ -139,5 +249,5 @@ $("#taskForm").addEventListener("submit", async (event) => { event.preventDefaul
$ ( "#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 ( ) ) ; $ ( "#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 ) ; } ) ; $ ( "#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 loadLayout ( ) ; await loadTasks ( ) ; } catch ( error ) { $ ( "#loginError" ) . textContent = error . message ; } } ) ;
$ ( "#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 ( ) ;