diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index facd02ec..b7ab4f1f 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -7013,6 +7013,61 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } font-size: 13px; letter-spacing: 0.04em; } +.goal-breakdown { + display: grid; + gap: 8px; +} +.goal-breakdown-item { + display: grid; + gap: 9px; + padding: 10px; + border: 1px solid var(--line-soft); + background: rgba(0,0,0,0.14); +} +.goal-breakdown-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} +.goal-breakdown-head strong { + display: block; + margin-top: 4px; + overflow-wrap: anywhere; + font-size: 15px; +} +.goal-breakdown-body { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.goal-child-list { + display: grid; + gap: 6px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.12); +} +.goal-child-list button { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px; + align-items: center; + min-height: 30px; + padding: 5px 7px; + border: 1px solid var(--line-soft); + color: var(--text); + background: rgba(255,255,255,0.025); + text-align: left; +} +.goal-child-list button:hover, +.goal-child-list button:focus-visible { + border-color: rgba(78, 183, 168, 0.5); +} +.goal-child-list button span:last-child { + overflow-wrap: anywhere; +} .record-editor-form, .diary-editor-form { display: grid; @@ -7113,6 +7168,74 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: var(--muted); line-height: 1.45; } +.diary-calendar { + display: grid; + gap: 8px; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--line-soft); + background: rgba(0,0,0,0.12); +} +.diary-calendar-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.diary-calendar-head strong { + letter-spacing: 0.08em; +} +.diary-calendar-week, +.diary-calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; +} +.diary-calendar-week span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.12em; + text-align: center; +} +.diary-day { + display: grid; + align-content: start; + gap: 3px; + min-height: 52px; + padding: 5px; + border: 1px solid rgba(255,255,255,0.06); + color: var(--muted); + background: rgba(0,0,0,0.16); + text-align: left; +} +.diary-day.empty { + border-color: transparent; + background: transparent; +} +.diary-day.has-entry { + color: var(--text); + border-color: rgba(78, 183, 168, 0.34); + background: rgba(78, 183, 168, 0.08); +} +.diary-day.today { + border-color: rgba(215, 161, 58, 0.52); +} +.diary-day.selected { + box-shadow: inset 0 0 0 1px rgba(78, 183, 168, 0.8); +} +.diary-day span { + font-size: 12px; +} +.diary-day i { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 10px; + font-style: normal; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} .diary-markdown { max-height: calc(100vh - 250px); } @@ -7159,6 +7282,20 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } letter-spacing: 0.16em; text-transform: uppercase; } +.decision-record-links { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.decision-record-links span { + min-width: 0; + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.07); + color: var(--muted); + background: rgba(255,255,255,0.025); + font-size: 11px; + overflow-wrap: anywhere; +} .decision-summary { margin: 0; color: var(--muted); @@ -7207,6 +7344,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .decision-filter-bar, .decision-default-grid, .decision-form-grid, + .goal-breakdown-head, + .goal-breakdown-body, .diary-layout { grid-template-columns: 1fr; } diff --git a/src/components/frontend/src/decision-center.tsx b/src/components/frontend/src/decision-center.tsx index 13345a65..efe44197 100644 --- a/src/components/frontend/src/decision-center.tsx +++ b/src/components/frontend/src/decision-center.tsx @@ -2,7 +2,7 @@ import React from "react"; import { fmtClock, fmtDate } from "./time"; import { LoadingTitle } from "./loading-indicator"; import { MarkdownBody } from "./markdown"; -import { errorMessage, requestJson } from "./unidesk-error"; +import { errorMessage, isUniDeskRequestError, requestJson } from "./unidesk-error"; import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; @@ -14,6 +14,7 @@ const useState: any = React.useState; const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "experiment"]; const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]; const recordStatuses = ["all", "active", "blocked", "parked", "done"]; +const diaryWeekdays = ["一", "二", "三", "四", "五", "六", "日"]; const requirementViews = [ { id: "all", label: "全部需求" }, { id: "external-goal", label: "外部目标" }, @@ -109,6 +110,77 @@ function browserDateOnly(value = new Date()): string { return `${year}-${month}-${day}`; } +function monthFromDate(value: string): string { + return /^\d{4}-\d{2}-\d{2}$/u.test(value) ? value.slice(0, 7) : browserDateOnly().slice(0, 7); +} + +function monthStartDate(month: string): Date | null { + if (!/^\d{4}-\d{2}$/u.test(month)) return null; + const [year, monthIndex] = month.split("-").map(Number); + const date = new Date(year || 0, (monthIndex || 1) - 1, 1); + return Number.isNaN(date.getTime()) ? null : date; +} + +function monthDateGrid(month: string): string[] { + const start = monthStartDate(month); + if (start === null) return []; + const daysInMonth = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate(); + const mondayOffset = (start.getDay() + 6) % 7; + const cells: string[] = []; + for (let index = 0; index < mondayOffset; index += 1) cells.push(""); + for (let day = 1; day <= daysInMonth; day += 1) { + cells.push(`${month}-${String(day).padStart(2, "0")}`); + } + while (cells.length % 7 !== 0) cells.push(""); + return cells; +} + +function recordStringArray(record: any, keys: string[]): string[] { + for (const key of keys) { + const value = record?.[key]; + if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean); + if (typeof value === "string" && value.trim()) return parseCsv(value); + } + return []; +} + +function recordStringField(record: any, keys: string[]): string { + for (const key of keys) { + const value = record?.[key]; + if (value !== null && value !== undefined && String(value).trim()) return String(value).trim(); + } + return ""; +} + +function recordTags(record: any): string[] { + return Array.isArray(record?.tags) ? record.tags.map((tag: any) => String(tag || "").trim()).filter(Boolean) : parseCsv(record?.tags); +} + +function recordTagValues(record: any, prefix: string): string[] { + const marker = `${prefix}:`; + return recordTags(record) + .filter((tag) => tag.toLowerCase().startsWith(marker.toLowerCase())) + .map((tag) => tag.slice(marker.length).trim()) + .filter(Boolean); +} + +function firstRecordTagValue(record: any, prefix: string): string { + return recordTagValues(record, prefix)[0] || ""; +} + +function recordSource(record: any): string { + return recordStringField(record, ["source", "sourceLabel", "sourceFile"]) || firstRecordTagValue(record, "source") || recordStringField(record, ["sourceSession"]) || "--"; +} + +function recordIssueTaskLabel(record: any): string { + const issue = recordStringField(record, ["issueId", "issue", "githubIssue", "githubIssueId", "externalIssue"]) || firstRecordTagValue(record, "issue"); + const task = recordStringField(record, ["taskId", "task"]); + if (issue && task) return `issue:${issue} / task:${task}`; + if (issue) return `issue:${issue}`; + if (task) return `task:${task}`; + return ""; +} + function fmtRecordTime(value: any): string { return fmtDate(value) || "--"; } @@ -145,6 +217,10 @@ function recordFormFromCategory(categoryId: string): AnyRecord { title: "", body: "", linkedGoalId: "", + source: "", + issueId: "", + parentGoalId: "", + childGoalIds: "", tags: tagsText(category?.tags || []), evidenceLinks: "", sourceSession: "frontend", @@ -163,6 +239,10 @@ function recordFormFromRecord(record: any): AnyRecord { title: record?.title || "", body: record?.body || record?.summary || "", linkedGoalId: record?.linkedGoalId || "", + source: recordStringField(record, ["source", "sourceLabel", "sourceFile"]) || firstRecordTagValue(record, "source"), + issueId: recordStringField(record, ["issueId", "issue", "githubIssue", "githubIssueId", "externalIssue"]) || firstRecordTagValue(record, "issue"), + parentGoalId: recordStringField(record, ["parentGoalId", "parentId", "parent"]) || firstRecordTagValue(record, "parent"), + childGoalIds: tagsText([...recordStringArray(record, ["childGoalIds", "children", "childIds"]), ...recordTagValues(record, "child")]), tags: tagsText(record?.tags), evidenceLinks: tagsText(record?.evidenceLinks), sourceSession: record?.sourceSession || "frontend", @@ -172,6 +252,13 @@ function recordFormFromRecord(record: any): AnyRecord { } function recordPayloadFromForm(form: AnyRecord): AnyRecord { + const tags = parseCsv(form.tags); + const metadataTags = [ + form.source ? `source:${String(form.source).trim()}` : "", + form.issueId ? `issue:${String(form.issueId).trim()}` : "", + form.parentGoalId ? `parent:${String(form.parentGoalId).trim()}` : "", + ...parseCsv(form.childGoalIds).map((id) => `child:${id}`), + ].filter(Boolean); return { type: form.type, level: form.level, @@ -179,9 +266,11 @@ function recordPayloadFromForm(form: AnyRecord): AnyRecord { title: String(form.title || "").trim(), body: String(form.body || "").trim(), linkedGoalId: String(form.linkedGoalId || "").trim(), - tags: parseCsv(form.tags), + // TODO(decision-center API): replace metadata tags with first-class fields: + // source, issueId/taskId, parentGoalId and childGoalIds when the backend schema exposes them. + tags: [...new Set([...tags, ...metadataTags])], evidenceLinks: parseCsv(form.evidenceLinks), - sourceSession: String(form.sourceSession || "").trim(), + sourceSession: String(form.source || form.sourceSession || "frontend").trim(), taskId: String(form.taskId || "").trim(), commitId: String(form.commitId || "").trim(), }; @@ -197,6 +286,14 @@ function isInternalGoal(record: any): boolean { return record.type === "goal" && record.status !== "blocked" && record.status !== "parked" && !isExternalGoal(record) && (["G1", "G2", "G3"].includes(record.level) || tags.includes("internal-goal")); } +function goalParentId(record: any): string { + return recordStringField(record, ["parentGoalId", "parentId", "parent"]) || firstRecordTagValue(record, "parent") || record?.linkedGoalId || ""; +} + +function goalChildIds(record: any): string[] { + return [...new Set([...recordStringArray(record, ["childGoalIds", "children", "childIds"]), ...recordTagValues(record, "child")])]; +} + function recordMatchesRequirementView(record: any, view: string): boolean { if (view === "external-goal") return isExternalGoal(record); if (view === "internal-goal") return isInternalGoal(record); @@ -226,9 +323,32 @@ function diaryImportMarkdown(form: AnyRecord): string { return `# ${date}\n\n${body || `## ${title}\n`}`.trim(); } +function diaryPayloadFromForm(form: AnyRecord): AnyRecord { + const date = String(form.date || browserDateOnly()).trim(); + const body = String(form.body || "").trim() || `# ${date}\n\n`; + return { + title: String(form.title || `${date} 工作日记`).trim(), + body, + sourceFile: String(form.sourceFile || defaultDiarySourceFile).trim() || defaultDiarySourceFile, + tags: parseCsv(form.tags), + }; +} + +function unavailableMessage(error: unknown, fallback: string): string { + if (isUniDeskRequestError(error)) { + const status = error.meta.status ? `HTTP ${error.meta.status}` : error.meta.kind; + return `${fallback}(${status}):${error.message || "后端接口暂不可用"}`; + } + return errorMessage(error, fallback); +} + function RecordCard({ record, onRaw, compact, onEdit }: AnyRecord) { - const tags = Array.isArray(record.tags) ? record.tags : []; + const tags = recordTags(record); const evidence = Array.isArray(record.evidenceLinks) ? record.evidenceLinks : []; + const issueTask = recordIssueTaskLabel(record); + const source = recordSource(record); + const parentId = goalParentId(record); + const childIds = goalChildIds(record); return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${stableTestId(record.id)}` }, h("div", { className: "decision-record-head" }, h("div", null, @@ -241,9 +361,15 @@ function RecordCard({ record, onRaw, compact, onEdit }: AnyRecord) { ), h("div", { className: "inline-actions" }, onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `edit-record-${stableTestId(record.id)}` }, "编辑") : null, - h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }), + h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw, testId: `raw-record-${stableTestId(record.id)}` }), ), ), + h("div", { className: "decision-record-links", "data-testid": `decision-record-links-${stableTestId(record.id)}` }, + h("span", null, `source:${source}`), + issueTask ? h("span", null, issueTask) : null, + parentId ? h("span", null, `parent:${parentId}`) : null, + childIds.length > 0 ? h("span", null, `children:${childIds.slice(0, 4).join(",")}`) : null, + ), compact ? h("p", { className: "decision-summary" }, shortText(record.summary || record.body)) : h(MarkdownBody, { markdown: record.body || record.summary || "", className: "decision-markdown" }), @@ -276,13 +402,13 @@ function RecordTable({ records, onRaw, onEdit }: AnyRecord) { h("td", null, h(StatusBadge, { status: levelTone(record.level) }, record.level || "none")), h("td", null, h(StatusBadge, { status: statusTone(record.status) }, record.status || "--")), h("td", null, record.type || "--"), - h("td", null, h("strong", null, record.title || "--"), record.linkedGoalId ? h("code", null, record.linkedGoalId) : null), + h("td", null, h("strong", null, record.title || "--"), record.linkedGoalId ? h("code", null, record.linkedGoalId) : null, h("span", { className: "muted block" }, recordIssueTaskLabel(record) || recordSource(record))), h("td", null, shortText(record.summary || record.body, 180)), h("td", null, Array.isArray(record.evidenceLinks) ? record.evidenceLinks.length : 0), h("td", null, fmtRecordTime(record.updatedAt)), h("td", null, h("div", { className: "inline-actions" }, onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `table-edit-record-${stableTestId(record.id)}` }, "编辑") : null, - h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }), + h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw, testId: `table-raw-record-${stableTestId(record.id)}` }), )), ))), ), @@ -322,7 +448,102 @@ function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) { h("div", { className: "decision-record-foot" }, h("code", null, entry.markdownPath || "--"), h("span", null, fmtRecordTime(entry.updatedAt || entry.importedAt)), - h(RawButton, { title: `Diary ${entry.date || entry.id}`, data: entry, onOpen: onRaw }), + h(RawButton, { title: `Diary ${entry.date || entry.id}`, data: entry, onOpen: onRaw, testId: `raw-diary-entry-${stableTestId(entry.date || entry.id)}` }), + ), + ); +} + +function GoalBreakdown({ records, onEdit, onRaw }: AnyRecord) { + const goals = records.filter((record: any) => record.type === "goal"); + const internalGoals = goals.filter(isInternalGoal); + const childrenByParent = new Map(); + for (const goal of internalGoals) { + const parentId = goalParentId(goal); + if (!parentId) continue; + const bucket = childrenByParent.get(parentId) || []; + bucket.push(goal); + childrenByParent.set(parentId, bucket); + } + const externalGoals = goals.filter(isExternalGoal); + if (externalGoals.length === 0) return h(EmptyState, { title: "暂无拆解关系", text: "创建 G0 外部目标后,可用 Linked Goal 或 parent 标签把内部目标关联上去。" }); + return h("div", { className: "goal-breakdown", "data-testid": "goal-breakdown" }, + externalGoals.slice(0, 12).map((goal: any) => { + const explicitChildIds = goalChildIds(goal); + const linkedChildren = [ + ...(childrenByParent.get(goal.id) || []), + ...internalGoals.filter((child: any) => explicitChildIds.includes(child.id)), + ].filter((child: any, index: number, array: any[]) => array.findIndex((item: any) => item.id === child.id) === index); + const blockers = records.filter((record: any) => (record.type === "blocker" || record.status === "blocked") && (record.linkedGoalId === goal.id || goalChildIds(record).includes(goal.id))); + return h("article", { key: goal.id, className: "goal-breakdown-item", "data-testid": `goal-breakdown-${stableTestId(goal.id)}` }, + h("div", { className: "goal-breakdown-head" }, + h("div", null, + h("div", { className: "decision-record-meta" }, + h("span", null, "external"), + h(StatusBadge, { status: levelTone(goal.level) }, goal.level || "G0"), + h(StatusBadge, { status: statusTone(goal.status) }, goal.status || "--"), + ), + h("strong", null, goal.title || goal.id), + h("span", { className: "muted block" }, recordIssueTaskLabel(goal) || recordSource(goal)), + ), + h("div", { className: "inline-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(goal), "data-testid": `goal-breakdown-edit-${stableTestId(goal.id)}` }, "编辑"), + h(RawButton, { title: `Goal ${goal.id}`, data: goal, onOpen: onRaw, testId: `goal-breakdown-raw-${stableTestId(goal.id)}` }), + ), + ), + h("div", { className: "goal-breakdown-body" }, + h("div", { className: "goal-child-list" }, + h("span", { className: "panel-eyebrow" }, `内部目标 ${linkedChildren.length}`), + linkedChildren.length === 0 + ? h("span", { className: "muted" }, "尚未拆解") + : linkedChildren.slice(0, 6).map((child: any) => h("button", { key: child.id, type: "button", onClick: () => onEdit(child), "data-testid": `goal-child-${stableTestId(child.id)}` }, + h(StatusBadge, { status: levelTone(child.level) }, child.level), + h("span", null, child.title || child.id), + )), + ), + h("div", { className: "goal-child-list" }, + h("span", { className: "panel-eyebrow" }, `阻塞 ${blockers.length}`), + blockers.length === 0 + ? h("span", { className: "muted" }, "无阻塞") + : blockers.slice(0, 4).map((blocker: any) => h("button", { key: blocker.id, type: "button", onClick: () => onEdit(blocker), "data-testid": `goal-blocker-${stableTestId(blocker.id)}` }, + h(StatusBadge, { status: statusTone(blocker.status) }, blocker.status), + h("span", null, blocker.title || blocker.id), + )), + ), + ), + ); + }), + ); +} + +function DiaryCalendar({ month, entries, selectedDate, today, onPickDate, onOpenToday }: AnyRecord) { + const entriesByDate = new Map(entries.map((entry: any) => [entry.date, entry])); + return h("div", { className: "diary-calendar", "data-testid": "diary-calendar" }, + h("div", { className: "diary-calendar-head" }, + h("strong", null, month || "--"), + h("button", { type: "button", className: "ghost-btn compact", onClick: onOpenToday, "data-testid": "diary-calendar-today" }, "今天"), + ), + h("div", { className: "diary-calendar-week" }, diaryWeekdays.map((day) => h("span", { key: day }, day))), + h("div", { className: "diary-calendar-grid" }, + monthDateGrid(month).map((date, index) => { + if (!date) return h("span", { key: `empty-${index}`, className: "diary-day empty" }); + const entry: any = entriesByDate.get(date); + const classes = [ + "diary-day", + entry ? "has-entry" : "", + selectedDate === date ? "selected" : "", + today === date ? "today" : "", + ].filter(Boolean).join(" "); + return h("button", { + key: date, + type: "button", + className: classes, + onClick: () => onPickDate(date, entry), + "data-testid": `diary-calendar-day-${date}`, + }, + h("span", null, date.slice(8)), + entry ? h("i", null, shortText(entry.title || entry.summary, 24)) : null, + ); + }), ), ); } @@ -383,6 +604,10 @@ function RecordEditor({ form, saving, message, error, onChange, onCategory, onSu h("label", null, "状态", h("select", { value: form.status, onChange: (event: any) => onChange("status", event.target.value), "data-testid": "record-status-select" }, selectOptions(recordStatuses.filter((item) => item !== "all")))), h("label", null, "Linked Goal", h("input", { value: form.linkedGoalId, onChange: (event: any) => onChange("linkedGoalId", event.target.value), placeholder: "关联目标 id" })), h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "external-goal, requirement" })), + h("label", null, "来源", h("input", { value: form.source, onChange: (event: any) => onChange("source", event.target.value), placeholder: "issue / customer / commander", "data-testid": "record-source-input" })), + h("label", null, "Issue", h("input", { value: form.issueId, onChange: (event: any) => onChange("issueId", event.target.value), placeholder: "#22 或 URL", "data-testid": "record-issue-input" })), + h("label", null, "父目标", h("input", { value: form.parentGoalId, onChange: (event: any) => onChange("parentGoalId", event.target.value), placeholder: "外部目标 id", "data-testid": "record-parent-goal-input" })), + h("label", null, "子目标", h("input", { value: form.childGoalIds, onChange: (event: any) => onChange("childGoalIds", event.target.value), placeholder: "逗号分隔 id", "data-testid": "record-child-goals-input" })), h("label", { className: "wide" }, "正文 Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "- 背景\n- 验收标准\n- 下一步", "data-testid": "record-body-editor" })), h("label", null, "证据链接", h("input", { value: form.evidenceLinks, onChange: (event: any) => onChange("evidenceLinks", event.target.value), placeholder: "逗号分隔 URL" })), h("label", null, "Task ID", h("input", { value: form.taskId, onChange: (event: any) => onChange("taskId", event.target.value), placeholder: "可选" })), @@ -390,7 +615,7 @@ function RecordEditor({ form, saving, message, error, onChange, onCategory, onSu ), h("div", { className: "dispatch-actions" }, h("button", { type: "submit", disabled: saving || !String(form.title || "").trim(), "data-testid": "save-record-button" }, saving ? "保存中" : form.id ? "保存记录" : "创建记录"), - h("button", { type: "button", className: "ghost-btn", disabled: saving, onClick: onReset }, "新建"), + h("button", { type: "button", className: "ghost-btn", disabled: saving, onClick: onReset, "data-testid": "new-record-button" }, "新建"), form.id ? h("code", null, form.id) : null, ), message ? h("p", { className: "muted paragraph", "data-testid": "record-editor-message" }, message) : null, @@ -402,9 +627,9 @@ function DiaryEditor({ form, saving, message, error, onChange, onToday, onSubmit return h("form", { className: "diary-editor-form", onSubmit, "data-testid": "diary-editor" }, h("div", { className: "decision-form-grid diary-editor-grid" }, h("label", null, "日期", h("input", { type: "date", value: form.date, onChange: (event: any) => onChange("date", event.target.value), "data-testid": "diary-date-input" })), - h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "工作日记标题" })), - h("label", null, "Source File", h("input", { value: form.sourceFile, onChange: (event: any) => onChange("sourceFile", event.target.value), placeholder: defaultDiarySourceFile })), - h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "frontend, daily" })), + h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "工作日记标题", "data-testid": "diary-title-input" })), + h("label", null, "Source File", h("input", { value: form.sourceFile, onChange: (event: any) => onChange("sourceFile", event.target.value), placeholder: defaultDiarySourceFile, "data-testid": "diary-source-input" })), + h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "frontend, daily", "data-testid": "diary-tags-input" })), h("label", { className: "wide" }, "Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "## 今日进展\n\n## 阻塞\n\n## 下一步", "data-testid": "diary-body-editor" })), ), h("div", { className: "dispatch-actions" }, @@ -426,8 +651,9 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } const [recordForm, setRecordForm] = useState(recordFormFromCategory("external-goal")); const [recordSaveState, setRecordSaveState] = useState({ saving: false, error: "", message: "" }); const [diaryState, setDiaryState] = useState({ loading: false, error: "", entries: [], months: [], selected: null, refreshedAt: null }); - const [diaryFilters, setDiaryFilters] = useState({ month: "all", from: "", to: "" }); - const [diaryForm, setDiaryForm] = useState(diaryFormFromEntry({ date: browserDateOnly(), title: `${browserDateOnly()} 工作日记`, tags: ["frontend"] })); + const todayDate = browserDateOnly(); + const [diaryFilters, setDiaryFilters] = useState({ month: monthFromDate(todayDate), from: "", to: "" }); + const [diaryForm, setDiaryForm] = useState(diaryFormFromEntry({ date: todayDate, title: `${todayDate} 工作日记`, tags: ["frontend"] })); const [diarySaveState, setDiarySaveState] = useState({ saving: false, error: "", message: "" }); async function load(): Promise { @@ -447,7 +673,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } setState({ loading: false, error: healthError, health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() }); setDiaryState((prev: any) => ({ ...prev, months: Array.isArray(months.months) ? months.months : [] })); } catch (err) { - setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") })); + setState((prev: any) => ({ ...prev, loading: false, error: unavailableMessage(err, "Decision Center API 暂不可用") })); } } @@ -469,7 +695,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } })); if (nextSelected && !nextSelected.body) void selectDiaryEntry(nextSelected); } catch (err) { - setDiaryState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "日记加载失败") })); + setDiaryState((prev: any) => ({ ...prev, loading: false, error: unavailableMessage(err, "日记 API 暂不可用") })); } } @@ -503,6 +729,10 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } title: prev.title || "", body: prev.body || "", linkedGoalId: prev.linkedGoalId || "", + source: prev.source || "", + issueId: prev.issueId || "", + parentGoalId: prev.parentGoalId || "", + childGoalIds: prev.childGoalIds || "", evidenceLinks: prev.evidenceLinks || "", sourceSession: prev.sourceSession || "frontend", taskId: prev.taskId || "", @@ -541,11 +771,15 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } function changeDiaryForm(field: string, value: any): void { setDiaryForm((prev: any) => ({ ...prev, [field]: value })); + if (field === "date" && /^\d{4}-\d{2}-\d{2}$/u.test(String(value || ""))) { + setDiaryFilters((prev: any) => ({ ...prev, month: monthFromDate(String(value)) })); + } } async function openTodayDiary(): Promise { const today = browserDateOnly(); setActiveView("diary"); + setDiaryFilters((prev: any) => ({ ...prev, month: monthFromDate(today) })); setDiarySaveState({ saving: false, error: "", message: "" }); try { const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(today)}`)); @@ -559,6 +793,20 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } } } + async function pickDiaryDate(date: string, entry?: any): Promise { + setActiveView("diary"); + setDiaryFilters((prev: any) => ({ ...prev, month: monthFromDate(date) })); + setDiarySaveState({ saving: false, error: "", message: "" }); + if (entry) { + await selectDiaryEntry(entry); + return; + } + const next = diaryFormFromEntry({ date, title: `${date} 工作日记`, body: `## 今日进展\n\n## 阻塞\n\n## 下一步\n`, sourceFile: defaultDiarySourceFile, tags: ["frontend"] }); + setDiaryState((prev: any) => ({ ...prev, selected: null })); + setDiaryForm(next); + setDiarySaveState({ saving: false, error: "", message: `${date} 尚未存在,保存后自动创建` }); + } + async function saveDiary(event: any): Promise { event.preventDefault(); const date = String(diaryForm.date || "").trim(); @@ -568,21 +816,32 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } } setDiarySaveState({ saving: true, error: "", message: "" }); try { - const markdown = diaryImportMarkdown(diaryForm); - const response = await requestJson(decisionApi(apiBaseUrl, "/api/diary/import"), { - method: "POST", - body: { - markdown, - sourceFile: String(diaryForm.sourceFile || defaultDiarySourceFile).trim() || defaultDiarySourceFile, - tags: parseCsv(diaryForm.tags), - }, + const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(date)}`), { + method: "PUT", + body: diaryPayloadFromForm(diaryForm), }); - const entry = Array.isArray(response.entries) ? response.entries.find((item: any) => item.date === date) || response.entries[0] : null; - setDiarySaveState({ saving: false, error: "", message: `已保存 ${date} / ${response.pathPattern || "YYYY-MM/YYYY-MM-DD.md"}` }); + const entry = response.entry; + setDiarySaveState({ saving: false, error: "", message: `已保存 ${date} / ${entry?.markdownPath || "YYYY-MM/YYYY-MM-DD.md"}` }); await loadDiary(); if (entry) await selectDiaryEntry(entry); } catch (err) { - setDiarySaveState({ saving: false, error: errorMessage(err, "日记保存失败"), message: "" }); + try { + const markdown = diaryImportMarkdown(diaryForm); + const response = await requestJson(decisionApi(apiBaseUrl, "/api/diary/import"), { + method: "POST", + body: { + markdown, + sourceFile: String(diaryForm.sourceFile || defaultDiarySourceFile).trim() || defaultDiarySourceFile, + tags: parseCsv(diaryForm.tags), + }, + }); + const entry = Array.isArray(response.entries) ? response.entries.find((item: any) => item.date === date) || response.entries[0] : null; + setDiarySaveState({ saving: false, error: "", message: `已保存 ${date} / ${response.pathPattern || "YYYY-MM/YYYY-MM-DD.md"}` }); + await loadDiary(); + if (entry) await selectDiaryEntry(entry); + } catch (fallbackError) { + setDiarySaveState({ saving: false, error: unavailableMessage(fallbackError, "日记保存失败"), message: "" }); + } } } @@ -623,10 +882,11 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } const diaryMonths = Array.isArray(diaryState.months) ? diaryState.months : []; const selectedDiary = diaryState.selected; const diaryCount = Number(state.health?.diaryEntryCount ?? 0); + const currentDiaryMonth = diaryFilters.month === "all" ? monthFromDate(String(diaryForm.date || browserDateOnly())) : diaryFilters.month; return h("div", { className: "decision-center-page", "data-testid": "decision-center-page" }, h(Panel, { title: "Decision Center", eyebrow: "Authority Records", loading: state.loading, actions: h("div", { className: "inline-actions" }, - h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading }, state.loading ? "刷新中" : "刷新"), + h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading, "data-testid": "decision-refresh-button" }, state.loading ? "刷新中" : "刷新"), h(RawButton, { title: "Decision Center Health", data: state.health, onOpen: onRaw, testId: "raw-decision-center-health" }), ) }, h("div", { className: "decision-hero" }, @@ -664,18 +924,27 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } }), ), h(Panel, { title: "日记筛选", eyebrow: "Markdown by Month", loading: diaryState.loading, actions: h("div", { className: "inline-actions" }, - h("button", { type: "button", className: "ghost-btn", onClick: () => void loadDiary(), disabled: diaryState.loading }, diaryState.loading ? "刷新中" : "刷新"), + h("button", { type: "button", className: "ghost-btn", onClick: () => void loadDiary(), disabled: diaryState.loading, "data-testid": "diary-refresh-button" }, diaryState.loading ? "刷新中" : "刷新"), h(RawButton, { title: "Diary Months", data: diaryMonths, onOpen: onRaw, testId: "raw-decision-center-diary-months" }), ) }, - h("div", { className: "decision-filter-bar" }, - h("label", null, "月份", h("select", { value: diaryFilters.month, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, month: event.target.value })) }, + h("div", { className: "decision-filter-bar", "data-testid": "diary-filter-bar" }, + h("label", null, "月份", h("select", { value: diaryFilters.month, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, month: event.target.value })), "data-testid": "diary-month-select" }, h("option", { value: "all" }, "all"), + diaryMonths.some((month: any) => month.month === currentDiaryMonth) ? null : h("option", { value: currentDiaryMonth }, `${currentDiaryMonth} (0)`), diaryMonths.map((month: any) => h("option", { key: month.month, value: month.month }, `${month.month} (${month.count})`)), )), - h("label", null, "开始日期", h("input", { type: "date", value: diaryFilters.from, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, from: event.target.value })) })), - h("label", null, "结束日期", h("input", { type: "date", value: diaryFilters.to, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, to: event.target.value })) })), + h("label", null, "开始日期", h("input", { type: "date", value: diaryFilters.from, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, from: event.target.value })), "data-testid": "diary-from-input" })), + h("label", null, "结束日期", h("input", { type: "date", value: diaryFilters.to, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, to: event.target.value })), "data-testid": "diary-to-input" })), h("label", null, "存储", h("input", { value: "PostgreSQL / YYYY-MM/YYYY-MM-DD.md", readOnly: true })), ), + h(DiaryCalendar, { + month: currentDiaryMonth, + entries: diaryEntries, + selectedDate: String(diaryForm.date || selectedDiary?.date || ""), + today: browserDateOnly(), + onPickDate: (date: string, entry: any) => void pickDiaryDate(date, entry), + onOpenToday: () => void openTodayDiary(), + }), h(UniDeskErrorBanner, { error: diaryState.error, title: "日记请求失败" }), ), h("div", { className: "diary-layout" }, @@ -694,6 +963,9 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } h(Panel, { title: "需求管理工作区", eyebrow: `${requirementFilteredRecords.length} Filtered`, loading: state.loading }, h(RequirementBoard, { records, activeView: requirementView, onView: setRequirementView, onEdit: editRecord, onRaw }), ), + h(Panel, { title: "目标拆解", eyebrow: "External to Internal Goals" }, + h(GoalBreakdown, { records, onEdit: editRecord, onRaw }), + ), h(Panel, { title: recordForm.id ? "编辑需求记录" : "录入需求记录", eyebrow: "Record Editor", loading: recordSaveState.saving }, h(RecordEditor, { form: recordForm,