Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

### 新增

- Wave 2(宿主原生组织深度):公告已读名单、任务附件(`{FREEOS_HOME}/org/task-files`)、架构图汇报线/部门类型/JSON 导入、工作台与总览内联批准宿主治理暂停、目录启用展示 `delivery`/`host_path`。不扩展永久 Node,不删除 `ManagedOrganizationRuntime`。双身份工作室 vs 组织房间不变。迁移图计数不变(9 native / 11 slice / 12 iframe)。
- Wave 1(宿主原生组织):人才市场落到宿主 `org_chart.sqlite`(`talent_pool`)与 `/api/org-module/talent*`;资产总线 apply 无 Node 也写入名册/人才;治理待处理暂停出现在组织首页与工作台总览;`/organization` 即使桌面集成也不再被 iframe 吞掉(原 App 仅作过渡链接);目录叠加 `delivery`/`host_path`。双身份边界与默认零 Node 不变。迁移图:人才市场 `managed_node_iframe` → `org_ui_slice`(13→12 iframe,10→11 slice)。
- 双身份边界(P1.3):工作室本机登录与组织房间登录分成两扇门。组织管理员不再被抬成宿主 `Role.ADMIN`;工作室访客不能拿宿主通行证写组织房间业务。界面与文档用「工作室 / 另一间房间 / 各自留白」说明两条路径。
- P1.2:`freeos org export-standalone` 默认 `--mode full` 写出可商业化 openXYOS **源码包**(`openxyos/` Apache-2.0 完整 App.tsx 树 + `slice/` MIT 宿主桥 + `modules.json` 对照清单)。`--mode slice` 保留原先 org-ui SPA + `FREEOS_UPSTREAM` 反代。工作室智能体对话不导出;组织沟通协作在完整树。说明见 `docs/org-export.md`。
Expand Down
22 changes: 22 additions & 0 deletions dashboard/src/api/modules/orgModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,20 @@ export interface OrgOverview {
pending_pauses?: number;
enabled?: boolean;
href?: string;
pauses?: OrgGovernancePause[];
};
}

export interface OrgGovernancePause {
pause_id: string;
tool_name: string;
category?: string;
action?: string;
reason?: string;
status?: string;
created_at?: number;
}

export interface OrgSidecarStart {
started: boolean;
already: boolean;
Expand Down Expand Up @@ -233,4 +244,15 @@ export const orgModuleApi = {
body: JSON.stringify({ dest }),
},
),
resolvePause: (pauseId: string, approve: boolean) =>
request<{
status: string;
execute: boolean;
blocked: boolean;
reason: string;
}>("/org-module/governance/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pause_id: pauseId, approve }),
}),
};
6 changes: 6 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@
"roomHint": "This is the organization room — you can arrange it on its own, next to everyday studio chat. The two ways in stay apart.",
"pauseBanner": "{{count}} governance pause(s) waiting for a human decision.",
"pauseBannerAction": "Open governance",
"pauseApprove": "Approve",
"pauseReject": "Reject",
"pauseTool": "Tool",
"pauseReason": "Reason",
"pauseResolveFailed": "Could not resolve this pause",
"pauseResolved": "Pause updated",
"originalAppHint": "Unmigrated App verticals (workflows, contracts, …) still use the original preview — a transition, not the home.",
"originalAppAction": "Open original App (transition)",
"catalogNative": "Host page",
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@
"roomHint": "这里是组织房间:可以单独布置,和日常工作室同属一个 FreeOS,两套入口互不合并。",
"pauseBanner": "有 {{count}} 条治理暂停等待人工决定。",
"pauseBannerAction": "打开治理",
"pauseApprove": "批准",
"pauseReject": "驳回",
"pauseTool": "工具",
"pauseReason": "原因",
"pauseResolveFailed": "无法处理该暂停",
"pauseResolved": "暂停已更新",
"originalAppHint": "尚未迁入的 App 垂类(流程、合同等)仍走原预览——只是过渡,不是首页。",
"originalAppAction": "打开原 App(过渡)",
"catalogNative": "宿主页",
Expand Down
59 changes: 59 additions & 0 deletions dashboard/src/org-ui/api/createClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@ describe("createOrgApiClient", () => {
},
};
}
if (path === "/org-module/announcements/1/readers") {
return {
success: true,
data: {
readers: [
{ user_id: 1, user_name: "Ada", read_at: "2026-09-19T00:00:00Z" },
],
count: 1,
},
};
}
if (path === "/org-module/org/import") {
return {
success: true,
data: {
departments: { created: 1, updated: 0, skipped: 0 },
reporting_lines: { applied: 0, skipped: 0 },
tree: [],
},
};
}
if (path === "/org-module/tasks/3/attachments") {
return {
success: true,
data: [{ id: 11, filename: "notes.txt", size_bytes: 12 }],
};
}
if (path === "/org-module/announcements/action/unread") {
return { success: true, data: { count: 2 } };
}
Expand Down Expand Up @@ -144,13 +171,23 @@ describe("createOrgApiClient", () => {
expect(listed.total).toBe(1);
expect(listed.list[0]?.title).toBe("Hi");
expect(await client.announcements.unread()).toEqual({ count: 2 });
expect((await client.announcements.readers(1)).readers[0]?.user_name).toBe(
"Ada",
);
expect((await client.org.tree())[0]?.name).toBe("HQ");
expect(
(await client.org.importChart({ departments: [{ name: "HQ" }] }))
.departments.created,
).toBe(1);
expect((await client.employees.list())[0]?.name).toBe("Ada");
expect((await client.talent.list())[0]?.name).toBe("Policy Analyst");
expect((await client.governance.pauses()).pauses[0]?.pause_id).toBe("p1");
expect((await client.skills.list()).skills[0]?.slug).toBe("org-employees");
expect((await client.knowledge.list()).bases[0]?.name).toBe("Policies");
expect((await client.tasks.list())[0]?.title).toBe("Ship Tasks slice");
expect((await client.tasks.listAttachments(3))[0]?.filename).toBe(
"notes.txt",
);
expect((await client.reflections.list())[0]?.failure_reasons).toBe(
"const dead zone",
);
Expand All @@ -175,6 +212,28 @@ describe("createOrgApiClient", () => {
expect(fetchJson).toHaveBeenCalled();
});

it("uploads and downloads task attachments without JSON Content-Type", async () => {
const uploadForm = vi.fn(async () => ({
success: true,
data: { id: 12, filename: "brief.txt", size_bytes: 4 },
}));
const fetchBlob = vi.fn(async () => new Blob(["ok"]));
const client = createOrgApiClient({
fetchJson: async () => ({ success: true, data: {} }),
uploadForm,
fetchBlob,
});
const file = new File(["ok"], "brief.txt", { type: "text/plain" });
expect((await client.tasks.uploadAttachment(3, file)).filename).toBe(
"brief.txt",
);
expect(uploadForm).toHaveBeenCalled();
expect(await client.tasks.downloadAttachment(3, 12)).toBeInstanceOf(Blob);
expect(fetchBlob).toHaveBeenCalledWith(
"/org-module/tasks/3/attachments/12/file",
);
});

it("throws the envelope error", async () => {
const client = createOrgApiClient({
fetchJson: async () => ({ success: false, error: "Permission denied" }),
Expand Down
111 changes: 111 additions & 0 deletions dashboard/src/org-ui/api/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface OrgEnvelope<T> {
error?: string;
}

export interface AnnouncementReader {
user_id: number;
user_name: string;
read_at: string;
}

export interface OrgAnnouncementsClient {
list(params: {
page: number;
Expand All @@ -51,6 +57,9 @@ export interface OrgAnnouncementsClient {
search?: string;
}): Promise<AnnouncementListData>;
get(id: number): Promise<Announcement>;
readers(
id: number,
): Promise<{ readers: AnnouncementReader[]; count: number }>;
unread(): Promise<{ count: number }>;
markRead(id: number): Promise<void>;
markAllRead(): Promise<{ marked: number }>;
Expand All @@ -74,6 +83,8 @@ export interface OrgEmployee {
department_name?: string | null;
status: string;
is_online?: boolean;
reports_to?: number | null;
reports_to_name?: string | null;
created_at?: string;
updated_at?: string | null;
}
Expand Down Expand Up @@ -111,6 +122,7 @@ export interface EmployeeWrite {
skills?: string;
avatar_emoji?: string;
status?: string;
reports_to?: number | null;
}

export interface OrgChartClient {
Expand All @@ -122,6 +134,14 @@ export interface OrgChartClient {
createEmployee(body: EmployeeWrite): Promise<OrgEmployee>;
updateEmployee(id: number, body: EmployeeWrite): Promise<OrgEmployee>;
removeEmployee(id: number): Promise<void>;
importChart(body: {
departments?: Array<Record<string, unknown>>;
reporting_lines?: Array<Record<string, unknown>>;
}): Promise<{
departments: { created: number; updated: number; skipped: number };
reporting_lines: { applied: number; skipped: number };
tree: OrgDepartment[];
}>;
}

export interface EmployeeListParams {
Expand Down Expand Up @@ -428,8 +448,10 @@ export interface OrgTask {
subtask_count?: number;
subtask_done?: number;
comment_count?: number;
attachment_count?: number;
subtasks?: OrgTaskSubtask[];
comments?: OrgTaskComment[];
attachments?: OrgTaskAttachment[];
}

export interface OrgTaskSubtask {
Expand All @@ -450,6 +472,18 @@ export interface OrgTaskComment {
created_at: string;
}

export interface OrgTaskAttachment {
id: number;
task_id: number;
filename: string;
stored_name?: string;
media_type?: string;
size_bytes: number;
uploaded_by?: number | null;
uploader_name?: string;
created_at: string;
}

export interface OrgTaskStats {
total: number;
todo: number;
Expand Down Expand Up @@ -488,6 +522,10 @@ export interface OrgTasksClient {
): Promise<OrgTaskSubtask>;
removeSubtask(id: number, subtaskId: number): Promise<void>;
addComment(id: number, content: string): Promise<OrgTaskComment>;
listAttachments(id: number): Promise<OrgTaskAttachment[]>;
uploadAttachment(id: number, file: File): Promise<OrgTaskAttachment>;
downloadAttachment(id: number, attachmentId: number): Promise<Blob>;
removeAttachment(id: number, attachmentId: number): Promise<void>;
}

export type OrgReflectionType =
Expand Down Expand Up @@ -728,11 +766,14 @@ export interface OrgWorkspaceOverview {
pending_pauses?: number;
enabled?: boolean;
href?: string;
pauses?: GovernancePause[];
};
}

export interface OrgWorkspaceClient {
overview(): Promise<OrgWorkspaceOverview>;
pauses(status?: string): Promise<GovernancePauseList>;
resolve(pauseId: string, approve: boolean): Promise<GovernanceDecision>;
}

export interface OrgApiClient {
Expand Down Expand Up @@ -763,6 +804,8 @@ async function unwrap<T>(payload: OrgEnvelope<T>): Promise<T> {
*/
export function createOrgApiClient(opts: {
fetchJson: OrgFetcher;
uploadForm?: <T>(path: string, body: FormData) => Promise<T>;
fetchBlob?: (path: string) => Promise<Blob>;
announcementsPrefix?: string;
orgPrefix?: string;
governancePrefix?: string;
Expand Down Expand Up @@ -807,6 +850,13 @@ export function createOrgApiClient(opts: {
await fetchJson<OrgEnvelope<Announcement>>(`${prefix}/${id}`),
);
},
async readers(id) {
return unwrap(
await fetchJson<
OrgEnvelope<{ readers: AnnouncementReader[]; count: number }>
>(`${prefix}/${id}/readers`),
);
},
async unread() {
return unwrap(
await fetchJson<OrgEnvelope<{ count: number }>>(
Expand Down Expand Up @@ -942,6 +992,25 @@ export function createOrgApiClient(opts: {
),
);
},
async importChart(body) {
return unwrap(
await fetchJson<
OrgEnvelope<{
departments: {
created: number;
updated: number;
skipped: number;
};
reporting_lines: { applied: number; skipped: number };
tree: OrgDepartment[];
}>
>(`${orgPrefix}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
},
};

const employees: OrgEmployeesClient = {
Expand Down Expand Up @@ -1219,6 +1288,42 @@ export function createOrgApiClient(opts: {
),
);
},
async listAttachments(id) {
return unwrap(
await fetchJson<OrgEnvelope<OrgTaskAttachment[]>>(
`${tasksPrefix}/${id}/attachments`,
),
);
},
async uploadAttachment(id, file) {
if (!opts.uploadForm) {
throw new Error("upload is not available");
}
const body = new FormData();
body.append("upload", file);
return unwrap(
await opts.uploadForm<OrgEnvelope<OrgTaskAttachment>>(
`${tasksPrefix}/${id}/attachments`,
body,
),
);
},
async downloadAttachment(id, attachmentId) {
if (!opts.fetchBlob) {
throw new Error("download is not available");
}
return opts.fetchBlob(
`${tasksPrefix}/${id}/attachments/${attachmentId}/file`,
);
},
async removeAttachment(id, attachmentId) {
await unwrap(
await fetchJson<OrgEnvelope<undefined>>(
`${tasksPrefix}/${id}/attachments/${attachmentId}`,
{ method: "DELETE" },
),
);
},
};

const reflections: OrgReflectionsClient = {
Expand Down Expand Up @@ -1368,6 +1473,12 @@ export function createOrgApiClient(opts: {
}
throw new Error(raw?.error || "request failed");
},
async pauses(status) {
return governance.pauses(status);
},
async resolve(pauseId, approve) {
return governance.resolve(pauseId, approve);
},
};

return {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/org-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { createOrgApiClient } from "./api/createClient";
export type {
Announcement,
AnnouncementListData,
AnnouncementReader,
AnnouncementWrite,
DepartmentWrite,
EmployeeListParams,
Expand Down Expand Up @@ -39,6 +40,7 @@ export type {
OrgKnowledgeStats,
OrgKnowledgeWrite,
OrgTask,
OrgTaskAttachment,
OrgTaskComment,
OrgTaskListParams,
OrgTaskPriority,
Expand Down
Loading
Loading