feat: productize decision center workspace
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
@@ -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<string, any[]>();
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user