From d480239c64d57b580a7b5a5bf2a221251784c2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=8F=E7=92=9E=E7=91=84?= Date: Mon, 28 Sep 2026 23:09:14 +0800 Subject: [PATCH 1/2] feat(web): add shared team projects workbench --- apps/web/package.json | 6 +- apps/web/src/components/flaremo-explorer.tsx | 13 + .../components/workspace/workspace-layout.tsx | 4 +- apps/web/src/features/team-projects/copy.js | 302 ++ .../web/src/features/team-projects/flaremo.js | 214 ++ apps/web/src/features/team-projects/model.js | 348 ++ .../src/features/team-projects/navigation.js | 98 + .../features/team-projects/navigation.test.ts | 31 + .../features/team-projects/select-menu.jsx | 202 ++ .../team-projects/team-projects-app.jsx | 2857 +++++++++++++++++ .../features/team-projects/team-projects.css | 1481 +++++++++ apps/web/src/i18n/messages/ar.ts | 1 + apps/web/src/i18n/messages/en-US.ts | 1 + apps/web/src/i18n/messages/es.ts | 1 + apps/web/src/i18n/messages/fr.ts | 1 + apps/web/src/i18n/messages/ja.ts | 1 + apps/web/src/i18n/messages/ko.ts | 1 + apps/web/src/i18n/messages/ru.ts | 1 + apps/web/src/i18n/messages/zh-CN.ts | 1 + apps/web/src/pages/team-projects-page.tsx | 57 + apps/web/src/router-tree.tsx | 55 + apps/web/tsconfig.team-projects.json | 14 + biome.json | 3 + docs/team-projects-workbench.md | 21 + playwright.config.ts | 2 +- tests/e2e/team-projects.spec.ts | 64 + tests/team-projects/workbench.test.ts | 273 ++ 27 files changed, 6048 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/features/team-projects/copy.js create mode 100644 apps/web/src/features/team-projects/flaremo.js create mode 100644 apps/web/src/features/team-projects/model.js create mode 100644 apps/web/src/features/team-projects/navigation.js create mode 100644 apps/web/src/features/team-projects/navigation.test.ts create mode 100644 apps/web/src/features/team-projects/select-menu.jsx create mode 100644 apps/web/src/features/team-projects/team-projects-app.jsx create mode 100644 apps/web/src/features/team-projects/team-projects.css create mode 100644 apps/web/src/pages/team-projects-page.tsx create mode 100644 apps/web/tsconfig.team-projects.json create mode 100644 docs/team-projects-workbench.md create mode 100644 tests/e2e/team-projects.spec.ts create mode 100644 tests/team-projects/workbench.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index a6703d05..3a176e9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,9 +6,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "check": "tsc --noEmit -p tsconfig.app.json --pretty false && tsc --noEmit -p tsconfig.node.json --pretty false", - "typecheck": "tsc -b --pretty false", + "build": "tsc -b && tsc --noEmit -p tsconfig.team-projects.json && vite build", + "check": "tsc --noEmit -p tsconfig.app.json --pretty false && tsc --noEmit -p tsconfig.node.json --pretty false && tsc --noEmit -p tsconfig.team-projects.json --pretty false", + "typecheck": "tsc -b --pretty false && tsc --noEmit -p tsconfig.team-projects.json --pretty false", "test": "vitest run --config ../../vitest.config.ts --passWithNoTests", "preview": "vite preview" }, diff --git a/apps/web/src/components/flaremo-explorer.tsx b/apps/web/src/components/flaremo-explorer.tsx index e2aa3ea7..f6ef7dda 100644 --- a/apps/web/src/components/flaremo-explorer.tsx +++ b/apps/web/src/components/flaremo-explorer.tsx @@ -208,6 +208,19 @@ export const FlareMoExplorer = memo(function FlareMoExplorer({ {t("nav.memory")} + + + + {t("nav.teamProjects")} + + ; onScroll?: (event: UIEvent) => void; maxWidthClass?: string; + outerMaxWidthClass?: string; }; export function WorkspaceLayout({ @@ -64,6 +65,7 @@ export function WorkspaceLayout({ mainRef, onScroll, maxWidthClass = "max-w-[640px]", + outerMaxWidthClass = "max-w-[950px]", }: WorkspaceLayoutProps) { const navigate = useNavigate(); const [mobileSheetOpen, setMobileSheetOpen] = useState(false); @@ -176,7 +178,7 @@ export function WorkspaceLayout({ return (
-
+
{ + const separator = line.indexOf("|"); + return [ + line.slice(0, separator), + line.slice(separator + 1).replace(/\\s/g, " "), + ]; + }), +); +let activeLocale = "zh-CN"; +export function setTeamProjectsLocale(locale) { + activeLocale = locale; +} +export function teamProjectsDateLocale() { + return activeLocale === "zh-CN" ? "zh-CN" : "en-US"; +} +export function tr(message) { + if (typeof message !== "string") return ""; + if (activeLocale === "zh-CN") return message; + const normalized = message.trim().replace(/\s+/g, " "); + const translation = en[normalized]; + if (!translation) return message; + const start = message.search(/\S/); + return ( + message.slice(0, start) + + translation + + message.slice(start + message.trim().length) + ); +} diff --git a/apps/web/src/features/team-projects/flaremo.js b/apps/web/src/features/team-projects/flaremo.js new file mode 100644 index 00000000..f5afce36 --- /dev/null +++ b/apps/web/src/features/team-projects/flaremo.js @@ -0,0 +1,214 @@ +const API = "/api/app"; +/** @typedef {{ id: string, role?: "owner" | "admin" | "member" | "reader", team: null | { id: string, name: string } }} TeamIdentity */ +/** @typedef {{ id: string, name: string }} DirectoryMember */ +/** @typedef {{ name: string, content: string, visibility: string, state: string, create_time?: string, update_time?: string, creator?: string, creator_name?: string, can_manage?: boolean, can_govern?: boolean, payload?: { client_id?: string } }} TeamMemo */ +/** @typedef {{ name: string, filename?: string, state?: string, preview_url?: string }} TeamAttachment */ +/** @typedef {{ memo: TeamMemo, attachments: TeamAttachment[], can_manage: boolean, can_govern?: boolean }} TeamMemoContext */ +export class ApiFailure extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} +/** @param {string} path + * @param {{ signal?: AbortSignal, method?: string, body?: unknown, form?: boolean, legacyWire?: boolean }} options + */ +export async function request( + path, + { signal, method = "GET", body, form = false, legacyWire = false } = {}, +) { + const response = await fetch(path, { + method, + body: + body === undefined + ? undefined + : form && body instanceof FormData + ? body + : JSON.stringify(body), + credentials: "same-origin", + cache: "no-store", + signal, + headers: { + ...(legacyWire ? { "x-flaremo-wire": "legacy" } : {}), + ...(!form && body !== undefined + ? { "content-type": "application/json" } + : {}), + }, + }); + const data = await response.json().catch(() => null); + if (!response.ok) + throw new ApiFailure( + data?.error?.message || data?.message || `连接失败(${response.status})`, + response.status, + ); + if (!data || typeof data !== "object" || Array.isArray(data)) + throw new ApiFailure("服务器返回的数据格式不完整,结果待确认"); + return data; +} +export const unknownWriteResult = (error) => + error?.status === undefined || error.status >= 500; +/** @param {AbortSignal=} signal @returns {Promise} */ +export async function currentUser(signal) { + const user = await request(`${API}/me`, { signal }); + if ( + typeof user.id !== "string" || + !( + user.role === undefined || + ["owner", "admin", "member", "reader"].includes(user.role) + ) || + !("team" in user) + ) + throw new ApiFailure("身份响应缺少必要字段"); + return user; +} +/** @param {AbortSignal=} signal @returns {Promise} */ +export async function activeUsers(signal) { + const data = await request("/api/v1/users", { signal }); + if ( + !Array.isArray(data.users) || + data.users.some( + (user) => + typeof user.name !== "string" || + !user.name.startsWith("users/") || + typeof user.displayName !== "string" || + user.state !== "NORMAL", + ) + ) + throw new ApiFailure("成员名册响应不完整"); + return data.users.map((user) => ({ id: user.name, name: user.displayName })); +} +/** @param {string} name @param {AbortSignal=} signal @returns {Promise} */ +export async function getContext(name, signal) { + const data = await request( + `${API}/memos/${encodeURIComponent(name.replace(/^memos\//, ""))}`, + { signal }, + ); + if ( + typeof data.memo?.name !== "string" || + typeof data.memo.content !== "string" || + !Array.isArray(data.attachments) + ) + throw new ApiFailure("详情响应缺少必要字段"); + return data; +} +/** @param {string} name @param {AbortSignal=} signal @returns {Promise<{ revisions: TeamMemo[] }>} */ +export async function revisions(name, signal) { + const data = await request( + `/api/v1/memos/${encodeURIComponent(name.replace(/^memos\//, ""))}/revisions`, + { signal, legacyWire: true }, + ); + if (!Array.isArray(data.revisions)) throw new ApiFailure("修订响应缺少列表"); + return data; +} +/** @param {string} content @param {string} clientId @param {AbortSignal=} signal @returns {Promise} */ +export async function createMemo(content, clientId, signal) { + const data = await request(`${API}/memos`, { + method: "POST", + body: { + content, + visibility: "protected", + source: "kosx-pm", + payload: { client_id: clientId }, + }, + signal, + }); + if (typeof data.name !== "string" || !data.name.startsWith("memos/")) + throw new ApiFailure("写入回执缺少记录 ID,结果待确认"); + return data; +} +/** @param {string} name @param {string} content @param {AbortSignal=} signal @returns {Promise} */ +export async function updateMemo(name, content, signal) { + const data = await request( + `${API}/memos/${encodeURIComponent(name.replace(/^memos\//, ""))}`, + { + method: "PATCH", + body: { content }, + signal, + }, + ); + if (data.name !== name || typeof data.content !== "string") + throw new ApiFailure("修改回执不完整,结果待确认"); + return data; +} +/** @param {string} name @param {File} file @param {string} clientId @param {AbortSignal=} signal @returns {Promise} */ +export async function uploadAttachment(name, file, clientId, signal) { + const f = new FormData(); + f.set("memo", name); + f.set("file", file); + f.set("client_id", clientId); + const data = await request("/api/v1/attachments", { + method: "POST", + body: f, + form: true, + legacyWire: true, + signal, + }); + if (typeof data.name !== "string" || !data.name.startsWith("attachments/")) + throw new ApiFailure("附件回执不完整,结果待确认"); + return data; +} +/** @param {AbortSignal=} signal @returns {Promise} */ +export async function listTeamMemos(signal) { + const result = [], + seen = new Set(), + seenPages = new Set(); + let token; + do { + const q = new URLSearchParams({ + space: "team", + state: "normal", + page_size: "50", + order_by: "created_at desc", + }); + if (token) q.set("page_token", token); + const page = await request(`${API}/memos?${q}`, { signal }); + if ( + !Array.isArray(page.memos) || + (page.next_page_token !== undefined && + typeof page.next_page_token !== "string") + ) + throw new ApiFailure("列表响应缺少记录或分页字段"); + for (const memo of page.memos) + if (typeof memo?.name !== "string" || typeof memo.content !== "string") + throw new ApiFailure("团队记录响应缺少必要字段"); + for (const memo of page.memos) + if (!seen.has(memo.name)) { + seen.add(memo.name); + result.push(memo); + } + token = page.next_page_token; + if (token) { + if (seenPages.has(token)) + throw new ApiFailure("分页标识重复,列表无法确认完整"); + seenPages.add(token); + } + } while (token); + return result; +} +/** @param {{ attachment: TeamAttachment, memoName: string, signal?: AbortSignal }} input @returns {Promise<{ blob: Blob, cacheControl: string | null }>} */ +export async function previewAttachment({ attachment, memoName, signal }) { + const context = await getContext(memoName, signal); + if ( + context.memo.visibility !== "protected" || + context.memo.state !== "normal" || + !context.attachments.some( + (a) => a.name === attachment.name && a.state === "ready", + ) + ) + throw new ApiFailure("附件访问权限待验证", 403); + const url = attachment.preview_url; + if (typeof url !== "string" || !url.startsWith("/api/v1/")) + throw new ApiFailure("附件地址无效", 400); + const response = await fetch(url, { + credentials: "same-origin", + cache: "no-store", + signal, + headers: { "cache-control": "no-store", pragma: "no-cache" }, + }); + if (!response.ok) + throw new ApiFailure(`附件不可访问(${response.status})`, response.status); + return { + blob: await response.blob(), + cacheControl: response.headers.get("cache-control"), + }; +} diff --git a/apps/web/src/features/team-projects/model.js b/apps/web/src/features/team-projects/model.js new file mode 100644 index 00000000..89e54b67 --- /dev/null +++ b/apps/web/src/features/team-projects/model.js @@ -0,0 +1,348 @@ +export const PHASES = [ + "idea", + "planned", + "active", + "paused", + "completed", + "cancelled", +]; +export const PHASE_LABELS = { + idea: "想法", + planned: "待启动", + active: "进行中", + paused: "暂停", + completed: "已完成", + cancelled: "已取消", +}; +export const CATEGORIES = { + progress: "进展", + meeting: "会议结论", + material: "资料", +}; +function blocksIn(content) { + const lines = content.match(/.*(?:\r?\n|$)/g) || []; + let offset = 0, + outer = null, + target = null; + const blocks = []; + let openings = 0; + for (const line of lines) { + if (!line) continue; + const plain = line.replace(/\r?\n$/, ""); + const fence = plain.match(/^([`~]{3,})(?:([\w-]+))?[ \t]*$/); + if (fence && new Set(fence[1]).size === 1) { + const marker = fence[1], + language = fence[2] || ""; + if (target) { + if ( + marker[0] === target.marker[0] && + marker.length >= target.marker.length && + !language + ) { + blocks.push({ + start: target.start, + end: offset + line.length, + json: content.slice(target.bodyStart, offset).trim(), + }); + target = null; + } + } else if (outer) { + if ( + marker[0] === outer[0] && + marker.length >= outer.length && + !language + ) + outer = null; + } else if (language === "kosx-pm" && marker[0] === "`") { + openings++; + target = { marker, start: offset, bodyStart: offset + line.length }; + } else outer = marker; + } + offset += line.length; + } + return { blocks, openings }; +} +const ID = /^memos\/[A-Za-z0-9_-]+$/; +export const todayShanghai = (now = new Date()) => + new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +// A follow-up date is a calendar date in the viewer's local zone in FlareMo. +// Keep todayShanghai for existing protocol fixtures and historical exports. +export const todayLocal = (now = new Date()) => + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +export function validDate(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) + return false; + const [y, m, d] = value.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() + 1 === m && + dt.getUTCDate() === d + ); +} +export function validIso(value) { + if ( + typeof value !== "string" || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value) + ) + return false; + const day = value.slice(0, 10), + hh = Number(value.slice(11, 13)), + mm = Number(value.slice(14, 16)), + ss = Number(value.slice(17, 19)); + const zone = value.endsWith("Z") ? "Z" : value.slice(-6); + return ( + validDate(day) && + hh <= 23 && + mm <= 59 && + ss <= 59 && + (zone === "Z" || + (Number(zone.slice(1, 3)) <= 23 && Number(zone.slice(4, 6)) <= 59)) && + !Number.isNaN(Date.parse(value)) + ); +} +export function validUrl(value) { + if (typeof value !== "string") return false; + try { + const u = new URL(value); + return ["http:", "https:"].includes(u.protocol) && !!u.hostname; + } catch { + return false; + } +} +function issue(issues, field, value, check, required = false) { + if (value === undefined || value === null || value === "") { + if (required) issues.push({ field, kind: "missing" }); + return; + } + if (!check(value)) issues.push({ field, kind: "invalid" }); +} +export function parseMemo(memo) { + if ( + !memo || + !ID.test(memo.name) || + memo.visibility !== "protected" || + memo.state !== "normal" + ) + return { type: "ignored" }; + const content = typeof memo.content === "string" ? memo.content : ""; + const { openings, blocks } = blocksIn(content); + if (!openings) return { type: "ignored" }; + if (openings !== 1) return { type: "error", memo, reason: "重复元数据块" }; + if (blocks.length !== 1) + return { type: "error", memo, reason: "元数据块未闭合" }; + let data; + try { + data = JSON.parse(blocks[0].json); + } catch { + return { type: "error", memo, reason: "JSON 格式错误" }; + } + if ( + !data || + typeof data !== "object" || + Array.isArray(data) || + data.schema !== "kosx.pm/1" || + !["project", "update"].includes(data.kind) + ) + return { type: "error", memo, reason: "未知协议或记录类型" }; + if (data.kind === "project" && !PHASES.includes(data.phase)) + return { type: "error", memo, reason: "项目阶段缺失或非法" }; + const issues = []; + issue( + issues, + "client_submission_id", + data.client_submission_id, + (v) => typeof v === "string" && v.trim().length > 0, + ); + if (data.kind === "project") { + issue( + issues, + "name", + data.name, + (v) => typeof v === "string" && !!v.trim(), + true, + ); + const active = data.phase === "active"; + for (const field of ["goal", "current_summary", "next_action"]) + issue( + issues, + field, + data[field], + (v) => typeof v === "string" && !!v.trim(), + active, + ); + issue( + issues, + "owner", + data.owner, + (v) => + v && + typeof v === "object" && + !Array.isArray(v) && + typeof v.name === "string" && + !!v.name.trim() && + (v.user_id === undefined || + v.user_id === null || + (typeof v.user_id === "string" && + /^users\/[A-Za-z0-9_-]+$/.test(v.user_id))), + active, + ); + issue(issues, "next_check_on", data.next_check_on, validDate, active); + for (const field of ["target_due_on", "idea_review_on"]) + issue(issues, field, data[field], validDate); + issue(issues, "blocker", data.blocker, (v) => typeof v === "string"); + issue(issues, "confirmed_at", data.confirmed_at, validIso); + issue(issues, "phase_history", data.phase_history, (v) => Array.isArray(v)); + if (Array.isArray(data.phase_history)) { + let previous = null; + data.phase_history.forEach((h, index) => { + const valid = + h && + typeof h === "object" && + !Array.isArray(h) && + (h.from === null || PHASES.includes(h.from)) && + PHASES.includes(h.to) && + typeof h.reason === "string" && + validIso(h.recorded_at) && + (!["paused", "completed", "cancelled"].includes(h.to) || + !!h.reason.trim()) && + !(previous && h.from !== previous) && + (!["completed", "cancelled"].includes(h.from) || !!h.reason.trim()); + if (!valid) + issues.push({ field: `phase_history[${index}]`, kind: "invalid" }); + if (h && PHASES.includes(h.to)) previous = h.to; + }); + if ( + ["paused", "completed", "cancelled"].includes(data.phase) && + data.phase_history.at(-1)?.to !== data.phase + ) + issues.push({ field: "phase_history", kind: "missing" }); + } + if ( + ["paused", "completed", "cancelled"].includes(data.phase) && + !Array.isArray(data.phase_history) + ) + issues.push({ field: "phase_history", kind: "missing" }); + } else { + issue( + issues, + "project_ref", + data.project_ref, + (v) => typeof v === "string" && ID.test(v), + true, + ); + issue(issues, "category", data.category, (v) => + Object.hasOwn(CATEGORIES, v), + ); + issue(issues, "occurred_on", data.occurred_on, validDate); + issue( + issues, + "references", + data.references, + (v) => Array.isArray(v) && v.every(validUrl), + ); + } + return { + type: data.kind, + memo, + data, + body: ( + content.slice(0, blocks[0].start) + content.slice(blocks[0].end) + ).trim(), + issues, + }; +} +export function readCorpus(memos) { + const records = memos.map(parseMemo), + projects = records.filter((r) => r.type === "project"), + projectById = new Map(projects.map((p) => [p.memo.name, p])); + const updates = records.filter((r) => r.type === "update"); + return { + projects, + updates, + errors: records.filter((r) => r.type === "error"), + orphans: updates.filter((u) => !projectById.has(u.data.project_ref)), + projectById, + }; +} +export function followupStatus(project, today = todayLocal()) { + if (project.data.phase !== "active" || !validDate(project.data.next_check_on)) + return null; + if (project.data.next_check_on > today) return null; + return project.data.next_check_on === today ? "今天跟进" : "跟进日期已过"; +} +export function displayName(project) { + return typeof project.data.name === "string" && project.data.name.trim() + ? project.data.name.trim() + : `未命名项目 ${project.memo.name}`; +} +export function metadataText(data, body = "") { + return `${body.trim()}${body.trim() ? "\n\n" : ""}\`\`\`kosx-pm\n${JSON.stringify(data, null, 2)}\n\`\`\``; +} +export function replaceMetadata(original, data) { + const blocks = blocksIn(original).blocks; + if (blocks.length !== 1) + throw new Error("原记录格式已变化,请在原记录修复后再保存"); + return ( + original.slice(0, blocks[0].start) + + `\`\`\`kosx-pm\n${JSON.stringify(data, null, 2)}\n\`\`\`` + + original.slice(blocks[0].end) + ); +} +export function replaceBody(original, body) { + const blocks = blocksIn(original).blocks; + if (blocks.length !== 1) throw new Error("原记录格式已变化"); + return `${body.trim()}\n\n${original.slice(blocks[0].start)}`; +} +export function newProject(input) { + const base = { + schema: "kosx.pm/1", + kind: "project", + name: input.name.trim(), + phase: input.phase, + phase_history: [], + client_submission_id: crypto.randomUUID(), + }; + if (input.phase === "active") + Object.assign(base, { + goal: input.goal.trim(), + owner: input.owner, + current_summary: input.current_summary.trim(), + next_action: input.next_action.trim(), + next_check_on: input.next_check_on, + }); + if (input.phase === "idea" && input.idea_review_on) + base.idea_review_on = input.idea_review_on; + for (const field of ["goal", "current_summary", "next_action", "blocker"]) + if (typeof input[field] === "string" && input[field].trim()) + base[field] = input[field].trim(); + if (input.owner?.name) base.owner = input.owner; + for (const field of ["next_check_on", "target_due_on", "idea_review_on"]) + if (validDate(input[field])) base[field] = input[field]; + return base; +} +export function validateActive(data) { + const p = parseMemo({ + name: "memos/validation", + content: metadataText(data), + visibility: "protected", + state: "normal", + }); + return p.type === "project" + ? p.issues.filter((x) => + [ + "name", + "goal", + "owner", + "current_summary", + "next_action", + "next_check_on", + ].includes(x.field), + ) + : [{ field: "phase", kind: "invalid" }]; +} diff --git a/apps/web/src/features/team-projects/navigation.js b/apps/web/src/features/team-projects/navigation.js new file mode 100644 index 00000000..65d483e4 --- /dev/null +++ b/apps/web/src/features/team-projects/navigation.js @@ -0,0 +1,98 @@ +const MEMO_ID = /^[a-zA-Z0-9_-]+$/; +export const APP_BASE = "/team-projects"; +export const lastListKey = "flaremo.team-projects.last-list"; +export const appPath = (path = "/") => { + if (path === "/ideas/new") return `${APP_BASE}?new=idea`; + if (path === "/projects/new") return `${APP_BASE}?new=active`; + return APP_BASE; +}; +// TanStack serializes JSON-shaped search values (for example the string "123") +// with quotes. Read both those URLs and older plain query-string links. +function value(params, key) { + const raw = params.get(key); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw); + if ( + typeof parsed === "string" || + typeof parsed === "number" || + typeof parsed === "boolean" + ) + return String(parsed); + } catch { + /* unquoted text */ + } + return raw; +} +export function readRoute( + _path = window.location.pathname, + search = window.location.search, +) { + const params = new URLSearchParams(search); + const id = value(params, "project") || ""; + if (id && MEMO_ID.test(id)) { + return { + kind: ["1", "true"].includes(value(params, "edit")) ? "edit" : "detail", + id: `memos/${id}`, + }; + } + const phase = value(params, "new"); + if (phase === "active" || phase === "idea") return { kind: "create", phase }; + return { kind: "list" }; +} +export function projectUrl(name) { + return `${APP_BASE}?project=${encodeURIComponent(name.replace(/^memos\//, ""))}`; +} +export function projectEditUrl(name) { + return `${projectUrl(name)}&edit=1`; +} +export function filterUrl({ view, member, phase, query, owner, group }) { + const p = new URLSearchParams(); + if (view !== "current") p.set("view", view); + if (member !== "all") p.set("member", member); + if (phase) p.set("phase", phase); + if (query) p.set("q", query); + if (owner) p.set("owner", owner); + if (group) p.set("group", "owner"); + return `${APP_BASE}${p.size ? `?${p}` : ""}`; +} +export function validListUrl(value) { + if (typeof value !== "string" || !value.startsWith("/")) return null; + try { + const url = new URL(value, "https://app.invalid"); + return url.origin === "https://app.invalid" && + url.pathname === APP_BASE && + !url.hash && + !url.searchParams.has("project") && + !url.searchParams.has("new") + ? `${url.pathname}${url.search}` + : null; + } catch { + return null; + } +} +export function readFilters(search = window.location.search) { + const query = new URLSearchParams(search); + const view = value(query, "view") || "current"; + return { + view: [ + "current", + "all", + "followup", + "ideas", + "revisit", + "maintain", + "issues", + "unlinked", + "paused", + "ended", + ].includes(view) + ? view + : "current", + member: value(query, "member") || "all", + phase: value(query, "phase") || "", + query: value(query, "q") || "", + owner: value(query, "owner") || "", + group: value(query, "group") === "owner", + }; +} diff --git a/apps/web/src/features/team-projects/navigation.test.ts b/apps/web/src/features/team-projects/navigation.test.ts new file mode 100644 index 00000000..46814e52 --- /dev/null +++ b/apps/web/src/features/team-projects/navigation.test.ts @@ -0,0 +1,31 @@ +import { + defaultParseSearch, + defaultStringifySearch, +} from "@tanstack/react-router"; +import { describe, expect, it } from "vitest"; +// @ts-expect-error The host TS config does not allow JS imports; the feature JS is checked separately. +import { readFilters, readRoute } from "./navigation.js"; + +describe("team-project URL with TanStack's actual search serializer", () => { + it("round-trips edit=true and a numeric-looking search query", () => { + const serialized = defaultStringifySearch({ + project: "memo_123", + edit: true, + q: "123", + }); + const parsed = defaultParseSearch(serialized); + expect(parsed).toMatchObject({ project: "memo_123", edit: true, q: "123" }); + expect(readRoute("/team-projects", serialized)).toEqual({ + kind: "edit", + id: "memos/memo_123", + }); + expect(readFilters(serialized).query).toBe("123"); + }); + + it("keeps older direct edit=1 links usable", () => { + expect(readRoute("/team-projects", "?project=older_id&edit=1")).toEqual({ + kind: "edit", + id: "memos/older_id", + }); + }); +}); diff --git a/apps/web/src/features/team-projects/select-menu.jsx b/apps/web/src/features/team-projects/select-menu.jsx new file mode 100644 index 00000000..4cb4ff81 --- /dev/null +++ b/apps/web/src/features/team-projects/select-menu.jsx @@ -0,0 +1,202 @@ +import { ChevronDownIcon } from "lucide-react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +// Focus stays on the combobox. The popup is portaled so narrow cards and +// disclosure panels cannot clip the options. +export function SelectMenu({ + value, + options, + onChange, + ariaLabel, + required = false, + invalid = false, + describedBy = undefined, + disabled = false, + className = "", +}) { + const id = useId(); + const trigger = useRef(null); + const popup = useRef(null); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const [position, setPosition] = useState(null); + const chosenIndex = options.findIndex((option) => option.value === value); + const chosen = chosenIndex >= 0 ? options[chosenIndex] : null; + const enabled = options + .map((option, index) => (option.disabled ? null : index)) + .filter((index) => index !== null); + function move(index, delta) { + if (!enabled.length) return 0; + const current = enabled.indexOf(index); + if (current < 0) return delta > 0 ? enabled[0] : enabled.at(-1); + const next = (current + delta + enabled.length) % enabled.length; + return enabled[next]; + } + const place = useCallback(() => { + if (!trigger.current) return; + const rect = trigger.current.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 210), window.innerWidth - 24); + const left = Math.max( + 12, + Math.min(rect.left, window.innerWidth - width - 12), + ); + const below = window.innerHeight - rect.bottom - 12; + const above = rect.top - 12; + const upward = below < 220 && above > below; + const height = Math.max(0, Math.min(260, upward ? above - 6 : below - 6)); + setPosition({ + left, + width, + top: upward ? undefined : rect.bottom + 6, + bottom: upward ? window.innerHeight - rect.top + 6 : undefined, + maxHeight: height, + }); + }, []); + function show(index = chosenIndex) { + if (disabled || !enabled.length) return; + place(); + setActive(options[index]?.disabled || index < 0 ? enabled[0] : index); + setOpen(true); + } + function close(restore = false) { + setOpen(false); + if (restore) requestAnimationFrame(() => trigger.current?.focus()); + } + function pick(index) { + if (options[index]?.disabled) return; + if (options[index].value !== value) onChange(options[index].value); + close(true); + } + function onKeyDown(event) { + if (disabled) return; + if (event.key === "Tab") { + close(); + return; + } + if (event.key === "Escape") { + if (open) { + event.preventDefault(); + close(true); + } + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : -1; + if (open) setActive((old) => move(old, delta)); + else show(move(chosenIndex, delta)); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + const index = event.key === "Home" ? enabled[0] : enabled.at(-1); + if (open) setActive(index); + else show(index); + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (open) pick(active); + else show(); + } + } + useEffect(() => { + if (!open) return; + const outside = (event) => { + if ( + !trigger.current?.contains(event.target) && + !popup.current?.contains(event.target) + ) + setOpen(false); + }; + const reposition = () => place(); + document.addEventListener("pointerdown", outside); + window.addEventListener("resize", reposition); + window.addEventListener("scroll", reposition, true); + return () => { + document.removeEventListener("pointerdown", outside); + window.removeEventListener("resize", reposition); + window.removeEventListener("scroll", reposition, true); + }; + }, [open, place]); + useEffect(() => { + if (disabled && open) setOpen(false); + }, [disabled, open]); + useEffect(() => { + if (!open || !popup.current) return; + const item = popup.current.querySelector(`[data-option-index="${active}"]`); + if (!item) return; + const top = item.offsetTop; + if (top < popup.current.scrollTop) popup.current.scrollTop = top; + else if ( + top + item.offsetHeight > + popup.current.scrollTop + popup.current.clientHeight + ) + popup.current.scrollTop = + top + item.offsetHeight - popup.current.clientHeight; + }, [active, open]); + return ( + <> + + {open && + position && + createPortal( +
+ {options.map((option, index) => ( + + ))} +
, + document.body, + )} + + ); +} diff --git a/apps/web/src/features/team-projects/team-projects-app.jsx b/apps/web/src/features/team-projects/team-projects-app.jsx new file mode 100644 index 00000000..05bc5638 --- /dev/null +++ b/apps/web/src/features/team-projects/team-projects-app.jsx @@ -0,0 +1,2857 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "@tanstack/react-router"; +import { ChevronDownIcon, UploadIcon } from "lucide-react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + activeUsers, + createMemo as createMemoApi, + currentUser, + getContext, + listTeamMemos, + previewAttachment, + revisions, + unknownWriteResult, + updateMemo as updateMemoApi, + uploadAttachment as uploadAttachmentApi, +} from "./flaremo.js"; +import { + CATEGORIES, + displayName, + followupStatus, + metadataText, + newProject, + PHASE_LABELS, + PHASES, + parseMemo, + readCorpus, + replaceBody, + replaceMetadata, + todayLocal, + validateActive, + validDate, + validIso, + validUrl, +} from "./model.js"; +import { + appPath, + filterUrl, + lastListKey, + projectEditUrl, + projectUrl, + readFilters, + readRoute, + validListUrl, +} from "./navigation.js"; +import { SelectMenu } from "./select-menu.jsx"; +import "./team-projects.css"; +import { useI18n } from "@/i18n"; +import { setTeamProjectsLocale, teamProjectsDateLocale, tr } from "./copy.js"; + +const SOURCE = window.location.origin; +const FIELDS = { + name: "名称", + goal: "目标", + owner: "负责人", + current_summary: "当前情况", + next_action: "下一步动作", + next_check_on: "下次跟进日期", + phase_history: "阶段说明", + references: "资料链接", + project_ref: "关联项目", +}; +const EMPTY = { + name: "", + phase: "active", + goal: "", + ownerName: "", + ownerId: "", + current_summary: "", + next_action: "", + next_check_on: "", + target_due_on: "", + idea_review_on: "", + blocker: "", + reason: "", + confirmNow: false, +}; +const UPDATE = { + category: "progress", + text: "", + occurred_on: "", + references: "", +}; +const source = (name) => + `${SOURCE}/memo/${encodeURIComponent(name.replace(/^memos\//, ""))}`; +function UploadControl({ + name, + disabled, + busy, + error, + onStart, + onChoose, + onCancel, +}) { + const input = useRef(null); + useEffect(() => { + const node = input.current; + if (!node) return; + node.addEventListener("cancel", onCancel); + return () => node.removeEventListener("cancel", onCancel); + }, [onCancel]); + return ( +
+ + + {error && ( + + {error} + + )} +
+ ); +} +const shortTime = (value) => + validIso(value) + ? new Date(value).toLocaleString(teamProjectsDateLocale()) + : value + ? tr("日期待修正") + : tr("未登记"); +const filled = (value) => (typeof value === "string" ? value.trim() : ""); +const show = (value) => + typeof value === "string" && value.trim() + ? value + : typeof value === "number" + ? String(value) + : tr("待补充"); +const projectTitle = (record) => filled(record.data.name) || tr("未命名项目"); +const shortId = (name) => name.replace(/^memos\//, "").slice(0, 8); +function Issues({ items }) { + return items?.length ? ( +

+ {items + .map( + (x) => + tr(FIELDS[x.field] || x.field) + + " " + + (x.kind === "missing" ? tr("待补充") : tr("待修正")), + ) + .join(" · ")} +

+ ) : null; +} +function ownerFor(form, users, old) { + const chosen = users.find((person) => person.id === form.ownerId); + if (chosen) return { name: chosen.name, user_id: chosen.id }; + return !form.ownerId && old?.name === filled(form.ownerName) ? old : null; +} +function initialForm(record) { + const d = record.data; + return { + name: typeof d.name === "string" ? d.name : "", + phase: d.phase, + goal: typeof d.goal === "string" ? d.goal : "", + ownerName: typeof d.owner?.name === "string" ? d.owner.name : "", + ownerId: typeof d.owner?.user_id === "string" ? d.owner.user_id : "", + current_summary: + typeof d.current_summary === "string" ? d.current_summary : "", + next_action: typeof d.next_action === "string" ? d.next_action : "", + next_check_on: validDate(d.next_check_on) ? d.next_check_on : "", + target_due_on: validDate(d.target_due_on) ? d.target_due_on : "", + idea_review_on: validDate(d.idea_review_on) ? d.idea_review_on : "", + blocker: typeof d.blocker === "string" ? d.blocker : "", + reason: "", + confirmNow: false, + }; +} +function ownerStatus(project, user) { + const owner = project.data.owner; + if (!owner || typeof owner !== "object" || typeof owner.user_id !== "string") + return tr("未关联账号"); + return owner.user_id === user?.id ? tr("已关联本账号") : tr("关联待核验"); +} +const NEXT_PHASES = { + idea: ["idea", "planned", "active", "cancelled"], + planned: ["planned", "active", "cancelled"], + active: ["active", "paused", "completed", "cancelled"], + paused: ["paused", "active", "completed", "cancelled"], + completed: ["completed", "active"], + cancelled: ["cancelled", "active"], +}; +const ACTIVE_REQUIRED = [ + "goal", + "ownerName", + "current_summary", + "next_action", + "next_check_on", +]; +function activating(form, mode, base) { + return ( + form.phase === "active" && (mode === "create" || form.phase !== base?.phase) + ); +} +function needsPhaseReason(form, mode, base) { + return ( + mode === "edit" && + form.phase !== base?.phase && + (["paused", "completed", "cancelled"].includes(form.phase) || + ["paused", "completed", "cancelled"].includes(base?.phase)) + ); +} +function projectFormErrors(form, mode, base) { + const errors = {}; + const originalName = base?.initial?.name || ""; + if ( + !filled(form.name) && + (mode === "create" || Boolean(originalName) || form.name !== originalName) + ) + errors.name = tr("请填写项目名称"); + if (mode === "edit" && !NEXT_PHASES[base?.phase]?.includes(form.phase)) + errors.phase = tr("这个阶段不能直接转换到所选阶段"); + if (activating(form, mode, base)) { + for (const key of ACTIVE_REQUIRED) { + if ( + key === "ownerName" + ? !filled(form.ownerId) + : key === "next_check_on" + ? !validDate(form[key]) + : !filled(form[key]) + ) + errors[key] = + tr(FIELDS[key === "ownerName" ? "owner" : key]) + tr("为进行中必填"); + } + } + for (const key of ["next_check_on", "target_due_on", "idea_review_on"]) + if (form[key] && !validDate(form[key])) errors[key] = tr("日期无效"); + if (needsPhaseReason(form, mode, base) && !filled(form.reason)) + errors.reason = tr("请填写阶段变化的原因或结果"); + return errors; +} +function requirement(key, form, mode, base) { + if (key === "phase") return tr("必填"); + if (key === "name") + return mode === "create" || filled(base?.initial?.name) + ? tr("必填") + : tr("待补充,可逐项维护"); + if (activating(form, mode, base) && ACTIVE_REQUIRED.includes(key)) + return tr("必填"); + if ( + mode === "edit" && + form.phase === "active" && + ACTIVE_REQUIRED.includes(key) && + !filled(form[key]) + ) + return tr("待补充,可逐项维护"); + return tr("可选"); +} + +export function DemoApp({ navigateTeam }) { + const { locale } = useI18n(); + setTeamProjectsLocale(locale); + const queryClient = useQueryClient(); + function invalidateHostMemos() { + void queryClient.invalidateQueries({ queryKey: ["memos"] }); + void queryClient.invalidateQueries({ queryKey: ["memo-stats"] }); + } + async function createMemo(content, clientId, signal) { + const result = await createMemoApi(content, clientId, signal); + invalidateHostMemos(); + return result; + } + async function updateMemo(name, content, signal) { + const result = await updateMemoApi(name, content, signal); + invalidateHostMemos(); + return result; + } + async function uploadAttachment(name, file, clientId, signal) { + const result = await uploadAttachmentApi(name, file, clientId, signal); + invalidateHostMemos(); + return result; + } + const location = useLocation(); + const initialFilters = useRef(readFilters()); + const [me, setMe] = useState(null), + [users, setUsers] = useState([]), + [usersError, setUsersError] = useState(false), + [rows, setRows] = useState([]), + [state, setState] = useState("loading"), + [message, setMessage] = useState(""), + [synced, setSynced] = useState(null); + const [filter, setFilter] = useState(initialFilters.current.view), + [memberFilter, setMemberFilter] = useState(initialFilters.current.member), + [groupByOwner, setGroupByOwner] = useState(initialFilters.current.group), + [phaseFilter, setPhaseFilter] = useState(initialFilters.current.phase), + [query, setQuery] = useState(initialFilters.current.query), + [ownerQuery, setOwnerQuery] = useState(initialFilters.current.owner), + [route, setRoute] = useState(() => readRoute()), + [selected, setSelected] = useState(() => readRoute().id || null), + [detail, setDetail] = useState(null), + [detailState, setDetailState] = useState("idle"); + const [form, setForm] = useState(EMPTY), + [formError, setFormError] = useState(null), + [mode, setMode] = useState(null), + [progress, setProgress] = useState(UPDATE), + [composerOpen, setComposerOpen] = useState(false), + [quickEdit, setQuickEdit] = useState(null), + [quickValue, setQuickValue] = useState(""), + [quickReason, setQuickReason] = useState(""), + [quickError, setQuickError] = useState(""), + [uploadError, setUploadError] = useState(null), + [saving, setSaving] = useState(false), + [uncertain, setUncertain] = useState(null); + const [history, setHistory] = useState(null), + [historyName, setHistoryName] = useState(null), + [historyState, setHistoryState] = useState("idle"), + [preview, setPreview] = useState(null), + [day, setDay] = useState(todayLocal()); + const identity = useRef(null), + generation = useRef(0), + requests = useRef(new Set()), + objectUrl = useRef(null), + selectedId = useRef(readRoute().id || null), + lock = useRef(null), + uncertainRef = useRef(null), + inflightRef = useRef(null), + previewSeq = useRef(0), + previewWork = useRef(null), + historySeq = useRef(0), + editBase = useRef(null), + quickBase = useRef(null), + fileChooserOpen = useRef(false), + focusRefreshTimer = useRef(null), + focusRefreshPending = useRef(false), + modalElement = useRef(null), + modalReturnFocus = useRef(null), + restoreScroll = useRef(null); + const formErrorRef = useRef(null); + const modalOpen = + Boolean(preview) || + (typeof mode === "object" && mode !== null && state === "ready"); + useEffect(() => { + if (modalOpen) { + if (!modalReturnFocus.current) + modalReturnFocus.current = document.activeElement; + requestAnimationFrame(() => { + if ( + modalElement.current && + !modalElement.current.contains(document.activeElement) + ) + modalElement.current.querySelector("button:not([disabled])")?.focus(); + }); + } else if (modalReturnFocus.current) { + const previous = modalReturnFocus.current; + modalReturnFocus.current = null; + requestAnimationFrame(() => previous.isConnected && previous.focus()); + } + }, [modalOpen]); + function onModalKeyDown(event) { + if (event.key === "Escape" && !saving) { + event.preventDefault(); + if (mode) setMode(null); + else closePreview(); + return; + } + if (event.key !== "Tab") return; + const nodes = [ + ...event.currentTarget.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', + ), + ].filter((node) => node.getClientRects().length > 0); + if (!nodes.length) return; + const first = nodes[0], + last = nodes[nodes.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + const task = useCallback(() => { + const c = new AbortController(); + requests.current.add(c); + return { + signal: c.signal, + abort: () => c.abort(), + done: () => requests.current.delete(c), + generation: generation.current, + }; + }, []); + const closePreview = () => { + previewSeq.current++; + previewWork.current?.abort(); + previewWork.current = null; + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + setPreview(null); + }; + const clear = useCallback(() => { + clearTimeout(focusRefreshTimer.current); + fileChooserOpen.current = false; + focusRefreshPending.current = false; + generation.current++; + previewSeq.current++; + historySeq.current++; + requests.current.forEach((c) => { + c.abort(); + }); + requests.current.clear(); + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + setRows([]); + setUsers([]); + setUsersError(false); + setSelected(null); + selectedId.current = null; + setDetail(null); + setHistory(null); + setHistoryName(null); + setPreview(null); + setForm(EMPTY); + setFormError(null); + setProgress(UPDATE); + setComposerOpen(false); + setMode(null); + setQuickEdit(null); + setUploadError(null); + quickBase.current = null; + editBase.current = null; + uncertainRef.current = null; + inflightRef.current = null; + lock.current = null; + setUncertain(null); + setSynced(null); + }, []); + const refresh = useCallback( + async (options = {}) => { + const background = options?.background === true; + focusRefreshPending.current = false; + if (inflightRef.current && !uncertainRef.current) { + uncertainRef.current = inflightRef.current; + setUncertain(inflightRef.current); + setMessage(tr("请求已中断,保存结果待确认;请核对或同次重试。")); + } + generation.current++; + previewSeq.current++; + historySeq.current++; + requests.current.forEach((c) => { + c.abort(); + }); + requests.current.clear(); + lock.current = null; + const work = task(); + if (!background) { + setState("loading"); + setMe(null); + setRows([]); + setDetail(null); + } + setHistory(null); + setHistoryName(null); + setPreview(null); + setSaving(false); + if (objectUrl.current) URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + try { + const user = await currentUser(work.signal); + if (work.generation !== generation.current) return; + const key = `${user.team?.id || "none"}:${user.id}`; + if (identity.current && identity.current !== key) { + clear(); + identity.current = key; + setMe(user); + const next = readRoute(); + selectedId.current = next.id || null; + setSelected(next.id || null); + return refresh(); + } + identity.current = key; + setMe(user); + if (!user.team) { + setState("no-team"); + return; + } + const memos = await listTeamMemos(work.signal); + const directory = await activeUsers(work.signal).catch(() => null); + if (work.generation !== generation.current) return; + setUsers(directory || []); + setUsersError(!directory); + let currentDetail = null; + let revokedName = null; + if (selectedId.current) { + const name = selectedId.current; + if (memos.some((m) => m.name === name)) { + currentDetail = await getContext(name, work.signal).catch( + () => null, + ); + } + if (work.generation !== generation.current) return; + if ( + currentDetail?.memo.visibility !== "protected" || + currentDetail?.memo.state !== "normal" + ) { + revokedName = name; + selectedId.current = null; + setSelected(name); + setDetail(null); + setDetailState("denied"); + setMode(null); + setQuickEdit(null); + quickBase.current = null; + } else { + setDetail(currentDetail); + setDetailState("ready"); + if (currentDetail.can_manage !== true) + setMode((old) => (old === "edit" ? null : old)); + } + } + setRows( + revokedName ? memos.filter((m) => m.name !== revokedName) : memos, + ); + setDay(todayLocal()); + setSynced(new Date().toISOString()); + setState("ready"); + if (uncertainRef.current?.kind === "memo") { + const match = memos.find( + (m) => m.payload?.client_id === uncertainRef.current.id, + ); + if (match) { + uncertainRef.current = null; + inflightRef.current = null; + setUncertain(null); + setMessage(tr("已核对保存结果:") + match.name + tr("")); + } + } + } catch (e) { + if (work.generation !== generation.current || e.name === "AbortError") + return; + if (e.status === 401) clear(); + setState(e.status === 401 ? "signed-out" : "failed"); + setMe(null); + setMessage( + e.status === 401 + ? tr("请先使用 FlareMo 账号登录。") + : tr("权限刷新失败;受保护内容已隐藏:") + tr(e.message), + ); + } finally { + work.done(); + } + }, + [clear, task], + ); + useEffect(() => { + refresh(); + const onFocus = () => { + setDay(todayLocal()); + // A native file chooser returns focus before its change/cancel event. Do not + // unmount that input or abort the upload that follows it. + if ( + fileChooserOpen.current || + inflightRef.current || + lock.current !== null + ) { + focusRefreshPending.current = true; + return; + } + clearTimeout(focusRefreshTimer.current); + // Let the focus-initiating click finish. Same-identity rechecks retain + // the DOM; revoked access, changed identity and failures hide it. + focusRefreshTimer.current = setTimeout(() => { + if ( + fileChooserOpen.current || + inflightRef.current || + lock.current !== null + ) { + focusRefreshPending.current = true; + return; + } + refresh({ background: true }); + }, 0); + }; + window.addEventListener("focus", onFocus); + const ticker = setInterval(() => setDay(todayLocal()), 15000); + return () => { + window.removeEventListener("focus", onFocus); + clearTimeout(focusRefreshTimer.current); + clearInterval(ticker); + requests.current.forEach((c) => { + c.abort(); + }); + }; + }, [refresh]); + const corpus = useMemo(() => readCorpus(rows), [rows]); + const project = selected ? corpus.projectById.get(selected) : null; + const updates = project + ? corpus.updates + .filter((x) => x.data.project_ref === selected) + .sort((a, b) => b.memo.create_time.localeCompare(a.memo.create_time)) + : []; + const visible = corpus.projects.filter((p) => { + const phase = p.data.phase; + if ( + filter === "current" && + !phaseFilter && + !["active", "planned"].includes(phase) + ) + return false; + if (filter === "followup" && !followupStatus(p, day)) return false; + if (filter === "ideas" && phase !== "idea") return false; + if ( + filter === "revisit" && + !( + phase === "idea" && + validDate(p.data.idea_review_on) && + p.data.idea_review_on <= day + ) + ) + return false; + if (filter === "paused" && phase !== "paused") return false; + if (filter === "ended" && !["completed", "cancelled"].includes(phase)) + return false; + if (filter === "mine" && p.data.owner?.user_id !== me?.id) return false; + if (memberFilter === "self" && p.data.owner?.user_id !== me?.id) + return false; + if ( + memberFilter !== "all" && + memberFilter !== "self" && + p.data.owner?.user_id !== memberFilter + ) + return false; + if (filter === "maintain" && p.memo.can_manage !== true) return false; + if (filter === "unlinked" && ownerStatus(p, me) === tr("已关联本账号")) + return false; + if (phaseFilter && phaseFilter !== "all" && phase !== phaseFilter) + return false; + if (filter === "issues" && !p.issues.length) return false; + if ( + query && + !displayName(p).toLowerCase().includes(query.toLowerCase()) && + !p.memo.name.toLowerCase().includes(query.toLowerCase()) + ) + return false; + if (ownerQuery && !filled(p.data.owner?.name).includes(ownerQuery)) + return false; + return true; + }); + const orderedVisible = groupByOwner + ? [...visible].sort( + (a, b) => + filled(a.data.owner?.name).localeCompare( + filled(b.data.owner?.name), + "zh-CN", + ) || projectTitle(a).localeCompare(projectTitle(b), "zh-CN"), + ) + : visible; + const activeNav = ["ideas", "revisit"].includes(filter) + ? "ideas" + : memberFilter === "self" + ? "mine" + : "current"; + async function open(name) { + selectedId.current = name; + editBase.current = null; + setMode(null); + setSelected(name); + setProgress(UPDATE); + setComposerOpen(false); + setQuickEdit(null); + setQuickError(""); + setUploadError(null); + quickBase.current = null; + setDetail(null); + setDetailState("loading"); + setHistory(null); + setHistoryName(null); + setHistoryState("idle"); + closePreview(); + const w = task(); + try { + const context = await getContext(name, w.signal); + if (w.generation !== generation.current || selectedId.current !== name) + return; + if ( + context.memo.visibility !== "protected" || + context.memo.state !== "normal" + ) { + setRows((old) => old.filter((x) => x.name !== name)); + setSelected(name); + setDetail(null); + setDetailState("denied"); + setMessage(tr("原记录已不在当前团队可见范围")); + return; + } + setDetail(context); + setDetailState("ready"); + } catch (e) { + if ( + w.generation !== generation.current || + selectedId.current !== name || + e.name === "AbortError" + ) + return; + setRows((old) => old.filter((x) => x.name !== name)); + setSelected(name); + setDetail(null); + setDetailState( + e.status === 403 || e.status === 404 ? "denied" : "failed", + ); + setMessage(tr("详情读取失败:") + tr(e.message)); + } finally { + w.done(); + } + } + function fail(text) { + if (mode === "create" || mode === "edit") { + setMessage(""); + setFormError({ summary: text, fields: {} }); + requestAnimationFrame(() => formErrorRef.current?.focus()); + } else { + setMessage(text); + } + } + function finishWrite(w) { + w.done(); + const ownsLock = lock.current === w.generation; + if (ownsLock) lock.current = null; + if (w.generation === generation.current) setSaving(false); + if (ownsLock && focusRefreshPending.current && !fileChooserOpen.current) { + focusRefreshPending.current = false; + queueMicrotask(() => refresh({ background: true })); + } + } + function updateForm(key, value, extra = {}) { + setForm((old) => ({ ...old, [key]: value, ...extra })); + if (key === "phase") { + setFormError(null); + return; + } + setFormError((old) => + old + ? { + summary: "", + fields: Object.fromEntries( + Object.entries(old.fields).filter(([field]) => field !== key), + ), + } + : null, + ); + } + function openCreate(phase) { + editBase.current = null; + setForm({ ...EMPTY, phase }); + setMode("create"); + setFormError(null); + setMessage(""); + } + function openEdit() { + if (!detail?.can_manage) return; + const initial = initialForm(project); + editBase.current = { + name: project.memo.name, + content: project.memo.content, + update_time: project.memo.update_time, + initial, + phase: project.data.phase, + }; + setForm(initial); + setMode("edit"); + setFormError(null); + setMessage(""); + } + function openQuick(field) { + if (!detail?.can_manage || !project) return; + quickBase.current = { + name: project.memo.name, + content: project.memo.content, + update_time: project.memo.update_time, + phase: project.data.phase, + }; + setQuickValue( + typeof project.data[field] === "string" ? project.data[field] : "", + ); + setQuickReason(""); + setQuickError(""); + setQuickEdit(field); + } + async function saveQuick() { + const base = quickBase.current; + if (!base || !quickEdit || lock.current !== null || uncertainRef.current) + return; + if ( + base.name !== selected || + base.name !== project?.memo.name || + selectedId.current !== base.name + ) { + setQuickError(tr("项目已切换,请重新打开当前项目的编辑入口")); + return; + } + const value = filled(quickValue); + if (quickEdit === "next_check_on" && value && !validDate(value)) { + setQuickError(tr("请选择有效日期")); + return; + } + if (quickEdit === "phase" && !NEXT_PHASES[base.phase]?.includes(value)) { + setQuickError(tr("不能直接切换到这个状态")); + return; + } + if ( + quickEdit === "phase" && + value !== base.phase && + (["paused", "completed", "cancelled"].includes(value) || + ["paused", "completed", "cancelled"].includes(base.phase)) && + !filled(quickReason) + ) { + setQuickError(tr("请说明状态变化的原因或结果")); + return; + } + const w = task(); + lock.current = w.generation; + setSaving(true); + setQuickError(""); + try { + const latest = await getContext(base.name, w.signal); + if ( + !latest.can_manage || + latest.memo.visibility !== "protected" || + latest.memo.state !== "normal" + ) { + setQuickError(tr("当前账号无权编辑这条项目记录")); + return; + } + if ( + latest.memo.update_time !== base.update_time || + latest.memo.content !== base.content + ) { + setQuickError(tr("项目已被其他入口更新,请刷新后再编辑")); + return; + } + const parsed = parseMemo(latest.memo); + if (parsed.type !== "project") { + setQuickError(tr("项目格式已变化,请刷新后再试")); + return; + } + if ( + w.generation !== generation.current || + selectedId.current !== base.name || + project?.memo.name !== base.name + ) + return; + const data = { ...parsed.data }; + if (quickEdit === "phase") { + if (value !== data.phase) { + if ( + data.phase_history != null && + !Array.isArray(data.phase_history) + ) { + setQuickError(tr("原阶段说明格式异常,请先修复原记录")); + return; + } + data.phase_history = [ + ...(Array.isArray(data.phase_history) ? data.phase_history : []), + { + from: data.phase, + to: value, + reason: filled(quickReason), + recorded_at: new Date().toISOString(), + }, + ]; + data.phase = value; + } + if (value === "active" && validateActive(data).length) { + setQuickError( + tr("启动为进行中前需补齐目标、负责人、当前情况、下一步和跟进日期"), + ); + return; + } + } else { + data[quickEdit] = value || null; + } + const content = replaceMetadata(latest.memo.content, data); + const pending = { + kind: "quick", + action: "quick", + memoName: base.name, + baseContent: base.content, + baseTime: base.update_time, + content, + }; + inflightRef.current = pending; + let saved; + try { + saved = await updateMemo(base.name, content, w.signal); + } catch (error) { + if (!unknownWriteResult(error)) { + inflightRef.current = null; + throw error; + } + const check = await getContext(base.name, w.signal).catch(() => null); + if (check?.memo.content === content) saved = check.memo; + else { + uncertainRef.current = pending; + setUncertain(pending); + setQuickError(tr("保存结果待确认,请核对后使用同一次提交重试")); + return; + } + } + inflightRef.current = null; + if (w.generation !== generation.current || !saved?.name) return; + setQuickEdit(null); + await refresh({ background: true }); + setMessage(tr("项目已更新")); + } catch (error) { + if (w.generation === generation.current && error.name !== "AbortError") + setQuickError(tr("保存失败:") + tr(error.message)); + } finally { + finishWrite(w); + } + } + function scrollHost() { + return ( + document.querySelector(".team-project-workbench")?.closest("main") || + window + ); + } + function scrollPosition() { + const host = scrollHost(); + return host instanceof HTMLElement ? host.scrollTop : window.scrollY; + } + function navigateTo(path, { replace = false } = {}) { + const target = new URL(path, window.location.origin); + if (route.kind === "list" && !replace) { + const listUrl = filterUrl({ + view: filter, + member: memberFilter, + phase: phaseFilter, + query, + owner: ownerQuery, + group: groupByOwner, + }); + window.sessionStorage.setItem(lastListKey, listUrl); + window.sessionStorage.setItem( + `${lastListKey}:scroll`, + String(scrollPosition()), + ); + } + void navigateTeam(Object.fromEntries(target.searchParams), replace); + const nextRoute = readRoute(target.pathname, target.search); + if (nextRoute.kind === "list") { + restoreScroll.current = Number( + window.sessionStorage.getItem(`${lastListKey}:scroll`) || 0, + ); + const nextFilters = readFilters(target.search); + setFilter(nextFilters.view); + setMemberFilter(nextFilters.member); + setPhaseFilter(nextFilters.phase); + setQuery(nextFilters.query); + setOwnerQuery(nextFilters.owner); + setGroupByOwner(nextFilters.group); + } + setRoute(nextRoute); + } + function returnToList() { + const saved = + validListUrl(window.sessionStorage.getItem(lastListKey)) || appPath("/"); + navigateTo(saved); + } + useEffect(() => { + const onLocation = () => { + const next = readRoute(location.pathname, location.searchStr); + if (next.kind === "list") { + restoreScroll.current = Number( + window.sessionStorage.getItem(`${lastListKey}:scroll`) || 0, + ); + const f = readFilters(location.searchStr); + setFilter(f.view); + setMemberFilter(f.member); + setPhaseFilter(f.phase); + setQuery(f.query); + setOwnerQuery(f.owner); + setGroupByOwner(f.group); + } + setRoute(next); + }; + onLocation(); + }, [location.pathname, location.searchStr]); + // biome-ignore lint/correctness/useExhaustiveDependencies: Route transitions are keyed by URL and permission state; callbacks read epoch-guarded refs and must not restart this effect on each render. + useEffect(() => { + if (state !== "ready") return; + if (route.kind === "list") { + selectedId.current = null; + setSelected(null); + setMode(null); + setQuickEdit(null); + quickBase.current = null; + closePreview(); + } else if (route.id && selectedId.current !== route.id) { + setMode(null); + open(route.id); + } else if (route.kind === "detail" && mode === "edit") { + setMode(null); + } else if (route.kind === "create" && mode !== "create") { + openCreate(route.phase); + } else if ( + route.kind === "edit" && + detail?.memo.name === route.id && + detail.can_manage && + mode !== "edit" + ) { + openEdit(); + } + }, [route.kind, route.id, state, detail?.memo.name, detail?.can_manage]); + // biome-ignore lint/correctness/useExhaustiveDependencies: List length intentionally schedules scroll restoration after rows render; scrollHost only reads the current DOM. + useEffect(() => { + if ( + route.kind !== "list" || + state !== "ready" || + selected !== null || + restoreScroll.current === null + ) + return; + const target = restoreScroll.current; + restoreScroll.current = null; + requestAnimationFrame(() => { + const host = scrollHost(); + host.scrollTo(0, target); + }); + }, [route.kind, state, selected, visible.length]); + useEffect(() => { + if (route.kind !== "list") return; + const url = filterUrl({ + view: filter, + member: memberFilter, + phase: phaseFilter, + query, + owner: ownerQuery, + group: groupByOwner, + }); + window.sessionStorage.setItem(lastListKey, url); + if (`${window.location.pathname}${window.location.search}` !== url) + void navigateTeam( + Object.fromEntries(new URL(url, window.location.origin).searchParams), + true, + ); + }, [ + route.kind, + filter, + memberFilter, + phaseFilter, + query, + ownerQuery, + groupByOwner, + navigateTeam, + ]); + async function saveProject() { + if (lock.current !== null || uncertainRef.current) { + fail(tr("先核对上一笔保存结果,再继续提交")); + return; + } + if ( + mode === "edit" && + (!project || editBase.current?.name !== project.memo.name) + ) { + fail(tr("项目编辑来源已变化,请重新打开")); + return; + } + const errors = projectFormErrors(form, mode, editBase.current); + if (Object.keys(errors).length) { + const summary = + tr("请先修正:") + Object.values(errors).join(";") + tr(""); + setFormError({ summary, fields: errors }); + setMessage(""); + requestAnimationFrame(() => formErrorRef.current?.focus()); + return; + } + const w = task(); + lock.current = w.generation; + setSaving(true); + setMessage(""); + try { + let saved; + let verifiedUsers = users; + if ( + mode === "create" || + form.ownerId !== editBase.current?.initial.ownerId || + form.ownerName !== editBase.current?.initial.ownerName + ) { + verifiedUsers = await activeUsers(w.signal); + setUsers(verifiedUsers); + setUsersError(false); + if ( + form.ownerId && + !verifiedUsers.some((person) => person.id === form.ownerId) + ) { + fail(tr("负责人已不在当前可见名册,请重新选择")); + return; + } + } + if (mode === "create") { + const data = newProject({ + ...form, + owner: ownerFor(form, verifiedUsers), + }); + if (form.phase !== "active" && filled(form.goal)) + data.goal = filled(form.goal); + const content = metadataText(data); + inflightRef.current = { + kind: "memo", + action: "project", + id: data.client_submission_id, + content, + }; + try { + saved = await createMemo( + content, + data.client_submission_id, + w.signal, + ); + inflightRef.current = null; + } catch (e) { + if (unknownWriteResult(e) && !w.signal.aborted) { + const current = await listTeamMemos(w.signal).catch(() => null); + saved = current?.find( + (x) => x.payload?.client_id === data.client_submission_id, + ); + if (!saved) { + uncertainRef.current = inflightRef.current; + setUncertain(uncertainRef.current); + fail(tr("保存结果待确认。已阻止重复创建;请刷新核对。")); + return; + } + inflightRef.current = null; + } else { + if (e.name !== "AbortError") inflightRef.current = null; + throw e; + } + } + } else { + const base = editBase.current; + const latest = await getContext(base.name, w.signal); + if (!latest.can_manage) { + fail(tr("原版当前账号无权维护这条项目主记录")); + return; + } + if ( + latest.memo.update_time !== base.update_time || + latest.memo.content !== base.content + ) { + fail(tr("原记录已变化,请刷新并重新查看后再保存")); + return; + } + const parsed = parseMemo(latest.memo); + if (parsed.type !== "project") { + fail(tr("原记录格式已变化,请重新查看")); + return; + } + const data = { ...parsed.data }; + const initial = base.initial; + for (const key of [ + "name", + "goal", + "current_summary", + "next_action", + "next_check_on", + "target_due_on", + "idea_review_on", + "blocker", + ]) { + if (form[key] !== initial[key]) + data[key] = filled(form[key]) || (key === "name" ? "" : null); + } + if ( + form.ownerId !== initial.ownerId || + form.ownerName !== initial.ownerName + ) + data.owner = ownerFor(form, verifiedUsers, data.owner); + if (form.confirmNow) data.confirmed_at = new Date().toISOString(); + if (data.phase !== form.phase) { + if ( + data.phase_history !== undefined && + data.phase_history !== null && + !Array.isArray(data.phase_history) + ) { + fail(tr("阶段说明字段格式错误,请先在原记录修复,不能覆盖原值")); + return; + } + const old = data.phase; + data.phase = form.phase; + data.phase_history = [ + ...(Array.isArray(data.phase_history) ? data.phase_history : []), + { + from: old, + to: form.phase, + reason: filled(form.reason), + recorded_at: new Date().toISOString(), + }, + ]; + if (form.phase === "active" && validateActive(data).length) { + fail(tr("启动为进行中前需修正全部必填字段")); + return; + } + } + saved = await updateMemo( + base.name, + replaceMetadata(latest.memo.content, data), + w.signal, + ); + } + if (w.generation !== generation.current) return; + if (!saved?.name) { + fail(tr("服务器未返回记录 ID,保存结果待确认")); + return; + } + setMode(null); + uncertainRef.current = null; + setUncertain(null); + selectedId.current = saved.name; + setSelected(saved.name); + navigateTo(projectUrl(saved.name), { replace: true }); + const key = identity.current; + await refresh(); + if (key === identity.current) { + await open(saved.name); + setMessage(tr("已保存到 FlareMo 团队记录。")); + } + } catch (e) { + if (w.generation === generation.current && e.name !== "AbortError") + fail(tr("保存失败:") + tr(e.message)); + } finally { + finishWrite(w); + } + } + async function publish() { + if ( + lock.current !== null || + uncertainRef.current || + !project || + !filled(progress.text) + ) { + if (uncertainRef.current) fail(tr("先核对上一笔保存结果,再继续提交")); + return; + } + if (progress.occurred_on && !validDate(progress.occurred_on)) { + fail(tr("发生日期无效")); + return; + } + const refs = progress.references.split("\n").map(filled).filter(Boolean); + if (refs.some((x) => !validUrl(x))) { + fail(tr("资料链接须为有效的 HTTP 或 HTTPS 地址")); + return; + } + const w = task(); + lock.current = w.generation; + setSaving(true); + const data = { + schema: "kosx.pm/1", + kind: "update", + project_ref: project.memo.name, + category: progress.category, + occurred_on: progress.occurred_on || null, + references: refs, + client_submission_id: crypto.randomUUID(), + }; + const content = metadataText(data, progress.text); + try { + const root = await getContext(project.memo.name, w.signal); + if (parseMemo(root.memo).type !== "project") + throw new Error(tr("关联项目已不可读或格式已变化,请刷新")); + inflightRef.current = { + kind: "memo", + action: "progress", + projectRef: data.project_ref, + id: data.client_submission_id, + content, + }; + let saved; + try { + saved = await createMemo(content, data.client_submission_id, w.signal); + inflightRef.current = null; + } catch (e) { + if (unknownWriteResult(e) && !w.signal.aborted) { + const current = await listTeamMemos(w.signal).catch(() => null); + saved = current?.find( + (x) => x.payload?.client_id === data.client_submission_id, + ); + if (!saved) { + uncertainRef.current = inflightRef.current; + setUncertain(uncertainRef.current); + fail(tr("进展保存结果待确认,已阻止重复提交。")); + return; + } + inflightRef.current = null; + } else { + if (e.name !== "AbortError") inflightRef.current = null; + throw e; + } + } + if (w.generation !== generation.current) return; + setProgress(UPDATE); + setComposerOpen(false); + const key = identity.current; + await refresh(); + if (key === identity.current) { + await open(data.project_ref); + fail(tr("进展已保存")); + } + } catch (e) { + if (w.generation === generation.current && e.name !== "AbortError") + fail(tr("发布失败:") + tr(e.message)); + } finally { + finishWrite(w); + } + } + async function saveOwnUpdate(update, text) { + if (lock.current !== null || !filled(text)) return; + const w = task(); + lock.current = w.generation; + setSaving(true); + try { + const latest = await getContext(update.memo.name, w.signal); + if ( + !latest.can_manage || + latest.memo.update_time !== update.memo.update_time + ) { + fail(tr("记录已变化或当前身份无权修改,请刷新")); + return; + } + await updateMemo( + update.memo.name, + replaceBody(latest.memo.content, text), + w.signal, + ); + if (w.generation !== generation.current) return; + setMode(null); + const key = identity.current; + await refresh(); + if (key === identity.current) { + await open(update.data.project_ref); + fail(tr("本人记录已修改")); + } + } catch (e) { + if (w.generation === generation.current && e.name !== "AbortError") + fail(tr("修改失败:") + tr(e.message)); + } finally { + finishWrite(w); + } + } + async function showHistory(name) { + const sequence = ++historySeq.current; + const projectName = selectedId.current; + const w = task(); + setHistory(null); + setHistoryName(name); + setHistoryState("loading"); + try { + const data = await revisions(name, w.signal); + if ( + w.generation !== generation.current || + sequence !== historySeq.current || + selectedId.current !== projectName + ) + return; + setHistory(data.revisions || []); + setHistoryState("ready"); + } catch (e) { + if ( + w.generation !== generation.current || + sequence !== historySeq.current || + selectedId.current !== projectName || + e.name === "AbortError" + ) + return; + setHistoryState(e.status === 403 ? "denied" : "failed"); + } finally { + w.done(); + } + } + async function showAttachment(attachment, name) { + closePreview(); + const sequence = previewSeq.current; + const w = task(); + previewWork.current = w; + setPreview({ state: "loading", filename: attachment.filename }); + try { + const result = await previewAttachment({ + attachment, + memoName: name, + signal: w.signal, + }); + if ( + w.generation !== generation.current || + sequence !== previewSeq.current || + selectedId.current !== selected + ) + return; + const url = URL.createObjectURL(result.blob); + objectUrl.current = url; + setPreview({ + state: "ready", + filename: attachment.filename, + url, + type: result.blob.type, + cacheControl: result.cacheControl, + }); + } catch (e) { + if ( + w.generation !== generation.current || + sequence !== previewSeq.current || + selectedId.current !== selected || + e.name === "AbortError" + ) + return; + setPreview({ state: "failed", filename: attachment.filename }); + fail(tr("附件权限待验证:") + tr(e.message)); + } finally { + if (previewWork.current === w) previewWork.current = null; + w.done(); + } + } + async function attach(file, name) { + if (!file || lock.current !== null || uncertainRef.current) return; + const w = task(), + id = crypto.randomUUID(); + lock.current = w.generation; + inflightRef.current = { kind: "attachment", id, file, memoName: name }; + setSaving(true); + setUploadError(null); + try { + await uploadAttachment(name, file, id, w.signal); + inflightRef.current = null; + if (w.generation !== generation.current) return; + const key = identity.current; + focusRefreshPending.current = false; + await refresh(); + if (key === identity.current) { + await open(selected); + fail(tr("资料已上传并绑定原记录")); + } + } catch (e) { + if ( + unknownWriteResult(e) && + e.name !== "AbortError" && + w.generation === generation.current + ) { + uncertainRef.current = inflightRef.current; + setUncertain(inflightRef.current); + } + if (!unknownWriteResult(e)) inflightRef.current = null; + if (w.generation === generation.current && e.name !== "AbortError") { + const text = unknownWriteResult(e) + ? tr("上传结果待确认;请核对后以同一提交标识重试") + : tr("上传失败:") + tr(e.message); + setUploadError({ name, text }); + fail(text); + } + } finally { + finishWrite(w); + } + } + function attachmentChosen(event, name) { + const file = event.target.files?.[0]; + event.target.value = ""; + fileChooserOpen.current = false; + if (file) { + focusRefreshPending.current = false; + void attach(file, name); + } else if (focusRefreshPending.current) { + focusRefreshPending.current = false; + void refresh({ background: true }); + } + } + function attachmentCancelled() { + fileChooserOpen.current = false; + if (focusRefreshPending.current) { + focusRefreshPending.current = false; + void refresh({ background: true }); + } + } + async function retryPending() { + const pending = uncertainRef.current; + if (!pending || state !== "ready" || lock.current !== null) return; + const w = task(); + lock.current = w.generation; + setSaving(true); + setMessage(tr("正在核对同一次提交…")); + try { + if (pending.kind === "memo") { + const current = await listTeamMemos(w.signal); + const found = current.find((x) => x.payload?.client_id === pending.id); + if (!found) await createMemo(pending.content, pending.id, w.signal); + } else if (pending.kind === "quick") { + const context = await getContext(pending.memoName, w.signal); + if ( + !context.can_manage || + context.memo.visibility !== "protected" || + context.memo.state !== "normal" + ) + throw new Error(tr("当前身份无权维护这条项目记录")); + if (context.memo.content !== pending.content) { + if ( + context.memo.content !== pending.baseContent || + context.memo.update_time !== pending.baseTime + ) + throw new Error(tr("原记录已变化,请刷新并核对,不能自动重试")); + await updateMemo(pending.memoName, pending.content, w.signal); + } + } else { + const context = await getContext(pending.memoName, w.signal); + if ( + !context.can_manage || + context.memo.visibility !== "protected" || + context.memo.state !== "normal" + ) + throw new Error(tr("当前身份无权为这条记录上传资料")); + await uploadAttachment( + pending.memoName, + pending.file, + pending.id, + w.signal, + ); + } + if (w.generation !== generation.current) return; + uncertainRef.current = null; + inflightRef.current = null; + setUncertain(null); + if (pending.action === "project") setMode(null); + if (pending.action === "quick") setQuickEdit(null); + if (pending.action === "progress") setProgress(UPDATE); + const key = identity.current; + await refresh(); + if (key === identity.current) { + if (selectedId.current) await open(selectedId.current); + fail(tr("同一次提交已核对并保存")); + } + } catch (e) { + if (w.generation === generation.current && e.name !== "AbortError") + fail(tr("仍无法确认结果:") + tr(e.message)); + } finally { + finishWrite(w); + } + } + + return ( +
+ {message && ( +
+ {message} + +
+ )} + {state !== "ready" ? ( +
+

+ {state === "loading" + ? tr("正在读取团队记录") + : state === "signed-out" + ? tr("请登录 FlareMo") + : state === "no-team" + ? tr("当前账号没有有效团队成员身份") + : tr("连接失败")} +

+

{message || tr("使用原版 FlareMo 账号登录,再返回此页刷新。")}

+ + {tr("\n\t\t\t\t\t\t打开 FlareMo\n\t\t\t\t\t")} + + +
+ ) : mode === "create" || mode === "edit" ? null : ( +
+
+ {selected && project ? ( + <> +
+ +
+
+
+
+

{projectTitle(project)}

+ + {tr(PHASE_LABELS[project.data.phase])} + +
+

+ {tr("负责人:")} + {show(project.data.owner?.name)} {tr(" · 最近更新:")} + {shortTime(project.memo.update_time)} +

+
+ {detail?.can_manage && ( + + )} + +
+
+
+ {tr("接下来")} + + {filled(project.data.next_action) || + (project.data.phase === "active" + ? tr("待补充") + : tr("尚未安排"))} + + + {tr("下次跟进:")} + {validDate(project.data.next_check_on) + ? project.data.next_check_on + : tr("尚未安排")} + +
+ {detail?.can_manage && ( +
+ + + +
+ )} + {quickEdit && ( +
+
+ + {quickEdit === "phase" + ? tr("项目状态") + : quickEdit === "next_action" + ? tr("下一步动作") + : tr("下次跟进日期")} + + {quickEdit === "phase" ? ( + ({ + value: phase, + label: tr(PHASE_LABELS[phase]), + }))} + /> + ) : ( + setQuickValue(e.target.value)} + /> + )} +
+ {quickEdit === "phase" && + quickValue !== quickBase.current?.phase && ( +