feat: harden auth for public deploy + gantt, duration units, docker packaging

Security (fail-secure for production):
- Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless
  PPM_ALLOW_NO_AUTH=1 (local dev only)
- Add login rate limiting (5 failures / 60s per client), enforced inside
  SessionManager.login so it cannot be bypassed at the endpoint layer
- compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy
- Add .dockerignore to keep runtime data/out of the image

Features:
- Task timeline gantt on home page (week/month zoom, horizontal scroll,
  infinite-arrow for open-ended dates, CSS Grid layout)
- Duration metrics gain a second/minute/hour unit for input & display
  (canonical storage stays integer seconds)
- Per-metric progress bars on home page (no more merged average)
- Backfill historical records via date picker in the today-record card
- Admin panel: stats overview, JSON import/restore, full task list mgmt

Fixes:
- Nullable task startDate; summarize handles missing start date
- Task dialog cancel/close no longer submits the form
- Import validates whole backup first, rejects path traversal, rebuilds
  archives, and replaces stale files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MobKBK
2026-08-24 12:11:03 +08:00
parent bcc2abe623
commit 02b81f0113
16 changed files with 705 additions and 45 deletions

View File

@@ -69,6 +69,86 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
.overview-row { display: grid; grid-template-columns: minmax(160px, 1.4fr) 2fr auto; gap: 16px; align-items: center; padding: 13px 0; cursor: pointer; }
.overview-row:hover strong { color: var(--green); }
.overview-name strong, .overview-name small { display: block; }.overview-name small { color: var(--muted); margin-top: 4px; }
.overview-metrics { display: grid; gap: 8px; }
.mini-progress { display: grid; gap: 4px; }
.mini-progress-label { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
.mini-progress-label small { color: var(--muted); font-size: 11px; }
.gantt-scale-switch { display: inline-flex; gap: 4px; }
.gantt-scale-btn { border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 4px; padding: 3px 10px; font-size: 12px; }
.gantt-scale-btn.active { background: var(--green-soft); color: var(--green); border-color: #bad6c5; font-weight: 700; }
/* 一周和一月的视觉宽度一致:每个「周期」占 --gantt-period-px,每天列宽按周期天数均分。 */
.gantt-viewport { --gantt-period-px: 520px; overflow-x: auto; overflow-y: hidden; padding-bottom: 10px; border: 1px solid var(--line); border-radius: 6px; }
.gantt-viewport[data-scale="week"] { --gantt-day-size: calc(var(--gantt-period-px) / 7); }
.gantt-viewport[data-scale="month"] { --gantt-day-size: calc(var(--gantt-period-px) / 30); }
.gantt-layout {
--gantt-label-col: clamp(8rem, 18vw, 11rem);
--gantt-row-height: 24px;
display: grid;
grid-template-columns: var(--gantt-label-col) repeat(var(--days), var(--gantt-day-size));
grid-template-rows: 24px 22px repeat(var(--rows), var(--gantt-row-height));
width: max-content;
position: relative;
}
.gantt-month-band {
grid-row: 1;
grid-column: var(--col) / span var(--span);
align-self: center;
padding-left: 4px;
font-size: 11px;
font-weight: 700;
color: var(--muted);
white-space: nowrap;
border-left: 1px solid var(--line);
}
.gantt-day-mark {
grid-row: 2;
grid-column: var(--col);
font-size: 9px;
color: var(--muted);
text-align: center;
white-space: nowrap;
border-left: 1px solid #eef2ee;
padding-top: 4px;
}
.gantt-label {
grid-column: 1;
grid-row: var(--row);
position: sticky;
left: 0;
z-index: 3;
background: var(--surface);
padding: 0 8px 0 4px;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-bottom: 1px solid #f0f3f0;
}
.gantt-bar {
grid-column: var(--start) / var(--end);
grid-row: var(--row);
align-self: center;
height: 14px;
border-radius: 4px;
background: var(--blue);
min-width: 3px;
cursor: pointer;
position: relative;
}
.gantt-bar.priority-1 { background: var(--red); }.gantt-bar.priority-2 { background: var(--yellow); }.gantt-bar.priority-3 { background: var(--green); }.gantt-bar.priority-4 { background: var(--blue); }.gantt-bar.priority-5 { background: #9ba39d; }
.gantt-bar.open-left { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.gantt-bar.open-right { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.gantt-bar.open-left::before { content: "⟵"; font-size: 10px; line-height: 1; color: rgba(255,255,255,.9); position: absolute; left: 3px; top: 50%; transform: translateY(-50%); }
.gantt-bar.open-right::after { content: "⟶"; font-size: 10px; line-height: 1; color: rgba(255,255,255,.9); position: absolute; right: 3px; top: 50%; transform: translateY(-50%); }
.gantt-today {
grid-column: var(--col);
grid-row: 2 / -1;
width: 1px;
background: var(--red);
z-index: 2;
pointer-events: none;
}
.progress-track { height: 7px; background: #e5eae5; overflow: hidden; border-radius: 4px; }
.progress-fill { height: 100%; background: var(--green); border-radius: inherit; }
.muted { color: var(--muted); }.nowrap { white-space: nowrap; }
@@ -79,6 +159,8 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
.tab.active { color: var(--green); border-bottom-color: var(--green); font-weight: 700; }
.tab-panel[hidden] { display: none; }
.record-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
.record-date-row { margin-bottom: 13px; }
.record-date-row input { max-width: 200px; }
.record-field { padding: 15px; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
.record-field.check { display: flex; align-items: center; gap: 11px; }
.record-field.check input { width: 19px; height: 19px; }
@@ -106,7 +188,8 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
.metric-row { display: grid; grid-template-columns: 1.5fr 1fr 1fr .8fr .8fr 38px; gap: 7px; align-items: end; padding: 10px; border: 1px solid var(--line); border-radius: 6px; }
.metric-row label { font-size: 10px; }.remove-metric { margin-bottom: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
.admin-dialog { width: min(660px, calc(100vw - 28px)); }.admin-actions { margin-bottom: 30px; }
.admin-dialog { width: min(720px, calc(100vw - 28px)); }.admin-actions { margin-bottom: 18px; }
.admin-stats-strip { margin: 0 0 22px; }.admin-stats-strip .summary-cell { padding: 12px 14px; }.admin-stats-strip .summary-cell strong { font-size: 19px; }
.trash-list { display: grid; gap: 8px; }.trash-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--line); padding: 12px 0; }.trash-item-actions { display: flex; gap: 6px; }
.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; }
.login-overlay[hidden] { display: none; }.login-panel { width: min(380px, 100%); background: white; border: 1px solid var(--line); padding: 38px; border-radius: 8px; }.login-panel h1 { margin: 6px 0 28px; font-size: 25px; }.wide-button { width: 100%; margin-top: 16px; }.form-error { color: var(--red); min-height: 18px; font-size: 12px; }