diff --git a/CHANGELOG.md b/CHANGELOG.md index de56c52f..3e567208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 3(组织架构切片):宿主内 org chart。Dashboard `/organization/org` 使用共享 `dashboard/src/org-ui` 的 `OrgChartPage`;树与 CRUD 走 `/api/org-module/org`(`{FREEOS_HOME}/org/org_chart.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 同时列出 Announcements 与 Org chart。未迁:Chat、员工目录页、汇报线/版本/导入导出图、其余 Open-12。 - Organization Phase 2:宿主内通知公告。Dashboard `/organization/announcements` 使用共享 `dashboard/src/org-ui` 页面;CRUD 走 `/api/org-module/announcements`(`{FREEOS_HOME}/org/announcements.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 写出同一页面的独立站骨架(完整打包仍待 Phase 5)。 ### 修复 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 28924c80..a77940c7 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -183,9 +183,13 @@ "packPreviewEmployees": "The preview now opens Employees so you can confirm the result.", "navWorkbench": "Workbench", "navAnnouncements": "Announcements", + "navOrgChart": "Org chart", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", - "openAnnouncements": "Open announcements" + "openAnnouncements": "Open announcements", + "orgChartTitle": "Organization chart", + "orgChartBody": "Departments and people on the host org tree. This page lives in the host — no sidecar or second port.", + "openOrgChart": "Open org chart" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 9206764c..d74a8e46 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -183,9 +183,13 @@ "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。", "navWorkbench": "工作台", "navAnnouncements": "通知公告", + "navOrgChart": "组织架构", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", - "openAnnouncements": "打开通知公告" + "openAnnouncements": "打开通知公告", + "orgChartTitle": "组织架构图", + "orgChartBody": "宿主内的部门与人员树。此页跑在宿主内,不需要边车或第二端口。", + "openOrgChart": "打开组织架构" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 826dbd45..926fbae8 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -18,6 +18,12 @@ describe("createOrgApiClient", () => { if (path === "/org-module/announcements/action/unread") { return { success: true, data: { count: 2 } }; } + if (path === "/org-module/org/tree") { + return { + success: true, + data: [{ id: 1, name: "HQ", parent_id: null, sort_order: 0 }], + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -29,6 +35,7 @@ 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.org.tree())[0]?.name).toBe("HQ"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index f0246235..91387ee0 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -61,8 +61,69 @@ export interface OrgAnnouncementsClient { pinned(): Promise; } +export interface OrgEmployee { + id: number; + name: string; + role: string; + description?: string; + employee_type: string; + agent_type?: string | null; + skills?: string; + avatar_emoji?: string; + department_id: number; + status: string; + is_online?: boolean; +} + +export interface OrgDepartment { + id: number; + name: string; + parent_id: number | null; + sort_order: number; + description?: string; + department_code?: string | null; + function_type?: string; + level?: number; + children?: OrgDepartment[]; + employees?: OrgEmployee[]; +} + +export interface DepartmentWrite { + name?: string; + parent_id?: number | null; + sort_order?: number; + description?: string; + department_code?: string | null; + function_type?: string; + level?: number; +} + +export interface EmployeeWrite { + name?: string; + department_id?: number; + role?: string; + description?: string; + employee_type?: string; + agent_type?: string | null; + skills?: string; + avatar_emoji?: string; + status?: string; +} + +export interface OrgChartClient { + tree(): Promise; + listDepartments(): Promise; + createDepartment(body: DepartmentWrite): Promise; + updateDepartment(id: number, body: DepartmentWrite): Promise; + removeDepartment(id: number): Promise; + createEmployee(body: EmployeeWrite): Promise; + updateEmployee(id: number, body: EmployeeWrite): Promise; + removeEmployee(id: number): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; + org: OrgChartClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -79,8 +140,10 @@ async function unwrap(payload: OrgEnvelope): Promise { export function createOrgApiClient(opts: { fetchJson: OrgFetcher; announcementsPrefix?: string; + orgPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; + const orgPrefix = opts.orgPrefix ?? "/org-module/org"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -163,5 +226,81 @@ export function createOrgApiClient(opts: { }, }; - return { announcements }; + const org: OrgChartClient = { + async tree() { + return unwrap( + await fetchJson>(`${orgPrefix}/tree`), + ); + }, + async listDepartments() { + return unwrap( + await fetchJson>( + `${orgPrefix}/departments`, + ), + ); + }, + async createDepartment(body) { + return unwrap( + await fetchJson>( + `${orgPrefix}/departments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + ); + }, + async updateDepartment(id, body) { + return unwrap( + await fetchJson>( + `${orgPrefix}/departments/${id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + ); + }, + async removeDepartment(id) { + await unwrap( + await fetchJson>( + `${orgPrefix}/departments/${id}`, + { method: "DELETE" }, + ), + ); + }, + async createEmployee(body) { + return unwrap( + await fetchJson>(`${orgPrefix}/employees`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async updateEmployee(id, body) { + return unwrap( + await fetchJson>( + `${orgPrefix}/employees/${id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + ); + }, + async removeEmployee(id) { + await unwrap( + await fetchJson>( + `${orgPrefix}/employees/${id}`, + { method: "DELETE" }, + ), + ); + }, + }; + + return { announcements, org }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 457a340e..314265bc 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -1,4 +1,4 @@ /** Catalog keys that have a shared org-ui page (keep in sync with contract.py). */ -export const SHARED_ORG_UI_MODULES = ["announcements"] as const; +export const SHARED_ORG_UI_MODULES = ["announcements", "organization"] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index c0718a04..6ede59e3 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -5,12 +5,19 @@ export type { Announcement, AnnouncementListData, AnnouncementWrite, + DepartmentWrite, + EmployeeWrite, OrgAnnouncementsClient, OrgApiClient, + OrgChartClient, + OrgDepartment, + OrgEmployee, OrgEnvelope, } from "./api/createClient"; export { AnnouncementPage } from "./pages/announcements/AnnouncementPage"; export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage"; +export { OrgChartPage } from "./pages/org/OrgChartPage"; +export type { OrgChartPageProps } from "./pages/org/OrgChartPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/org/OrgChartPage.module.css b/dashboard/src/org-ui/pages/org/OrgChartPage.module.css new file mode 100644 index 00000000..ac8a0219 --- /dev/null +++ b/dashboard/src/org-ui/pages/org/OrgChartPage.module.css @@ -0,0 +1,111 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.tree { + display: flex; + flex-direction: column; + gap: 12px; +} + +.node { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 14px; + background: var(--fn-bg-container, #fff); +} + +.nodeHead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.nodeTitle { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.nodeMeta { + margin: 6px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.people { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.person { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 999px; + padding: 4px 10px; + background: var(--fn-bg, #f9fafb); + cursor: pointer; + font-size: 13px; +} + +.personHidden { + display: none; +} + +.children { + margin-top: 12px; + margin-left: 16px; + padding-left: 12px; + border-left: 2px solid var(--fn-border, #e5e7eb); + display: flex; + flex-direction: column; + gap: 12px; +} + +.actions { + margin-left: auto; +} diff --git a/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx b/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx new file mode 100644 index 00000000..44b3af42 --- /dev/null +++ b/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { OrgChartPage } from "./OrgChartPage"; +import type { OrgChartClient } from "../../api/createClient"; + +function mockClient(): OrgChartClient { + return { + tree: vi.fn(async () => []), + listDepartments: vi.fn(async () => []), + createDepartment: vi.fn(async (body) => ({ + id: 3, + name: body.name || "HQ", + parent_id: body.parent_id ?? null, + sort_order: 0, + children: [], + employees: [], + })), + updateDepartment: vi.fn(), + removeDepartment: vi.fn(), + createEmployee: vi.fn(), + updateEmployee: vi.fn(), + removeEmployee: vi.fn(), + }; +} + +describe("OrgChartPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-org-chart")).toBeInTheDocument(); + expect(screen.getByText("Organization chart")).toBeInTheDocument(); + expect(screen.getByTestId("org-chart-empty")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.tree).toHaveBeenCalled(); + }); + + it("lets an admin add a root department through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-chart-add-root")); + fireEvent.change(screen.getByTestId("org-chart-dept-name"), { + target: { value: "HQ" }, + }); + fireEvent.click(screen.getByTestId("org-chart-dept-submit")); + await waitFor(() => { + expect(client.createDepartment).toHaveBeenCalledWith( + expect.objectContaining({ name: "HQ", parent_id: null }), + ); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/org/OrgChartPage.tsx b/dashboard/src/org-ui/pages/org/OrgChartPage.tsx new file mode 100644 index 00000000..a692cd63 --- /dev/null +++ b/dashboard/src/org-ui/pages/org/OrgChartPage.tsx @@ -0,0 +1,581 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input, Modal, Select, Space, Tag, Typography } from "antd"; +import { Network, Plus, Search } from "lucide-react"; +import type { + OrgChartClient, + OrgDepartment, + OrgEmployee, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { orgChartLabels } from "./labels"; +import styles from "./OrgChartPage.module.css"; + +export interface OrgChartPageProps { + client: OrgChartClient; + session: OrgSession; + locale: OrgLocale; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +type DeptForm = { + name: string; + description: string; + parent_id: number | null; +}; + +type EmpForm = { + name: string; + role: string; + description: string; + employee_type: string; + avatar_emoji: string; + department_id: number; +}; + +function countEmployees(dept: OrgDepartment): number { + const self = (dept.employees ?? []).length; + return ( + self + + (dept.children ?? []).reduce((sum, child) => sum + countEmployees(child), 0) + ); +} + +function flattenDepartments(nodes: OrgDepartment[]): OrgDepartment[] { + const out: OrgDepartment[] = []; + const walk = (items: OrgDepartment[]) => { + for (const item of items) { + out.push(item); + if (item.children?.length) walk(item.children); + } + }; + walk(nodes); + return out; +} + +function matchesQuery(dept: OrgDepartment, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + if (dept.name.toLowerCase().includes(q)) return true; + if ((dept.description || "").toLowerCase().includes(q)) return true; + if ( + (dept.employees ?? []).some( + (emp) => + emp.name.toLowerCase().includes(q) || + (emp.role || "").toLowerCase().includes(q), + ) + ) { + return true; + } + return (dept.children ?? []).some((child) => matchesQuery(child, query)); +} + +function employeeVisible(emp: OrgEmployee, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + return ( + emp.name.toLowerCase().includes(q) || + (emp.role || "").toLowerCase().includes(q) + ); +} + +export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { + const labels = orgChartLabels(locale); + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [deptModal, setDeptModal] = useState< + | { mode: "create"; parentId: number | null } + | { mode: "edit"; dept: OrgDepartment } + | null + >(null); + const [empModal, setEmpModal] = useState< + | { mode: "create"; departmentId: number } + | { mode: "edit"; emp: OrgEmployee } + | null + >(null); + const [detail, setDetail] = useState(null); + const [deptForm, setDeptForm] = useState({ + name: "", + description: "", + parent_id: null, + }); + const [empForm, setEmpForm] = useState({ + name: "", + role: "", + description: "", + employee_type: "human", + avatar_emoji: "👤", + department_id: 0, + }); + const [error, setError] = useState(""); + + const fetchTree = useCallback(async () => { + setLoading(true); + try { + setTree(await client.tree()); + } catch { + setTree([]); + } finally { + setLoading(false); + } + }, [client]); + + useEffect(() => { + void fetchTree(); + }, [fetchTree]); + + const totalEmployees = useMemo( + () => tree.reduce((sum, node) => sum + countEmployees(node), 0), + [tree], + ); + const flatDepts = useMemo(() => flattenDepartments(tree), [tree]); + const query = search.trim(); + const filtered = useMemo( + () => tree.filter((node) => matchesQuery(node, query)), + [tree, query], + ); + + const openCreateDept = (parentId: number | null) => { + setError(""); + setDeptForm({ name: "", description: "", parent_id: parentId }); + setDeptModal({ mode: "create", parentId }); + }; + + const openEditDept = (dept: OrgDepartment) => { + setError(""); + setDeptForm({ + name: dept.name, + description: dept.description || "", + parent_id: dept.parent_id, + }); + setDeptModal({ mode: "edit", dept }); + }; + + const openCreateEmp = (departmentId: number) => { + setError(""); + setEmpForm({ + name: "", + role: "", + description: "", + employee_type: "human", + avatar_emoji: "👤", + department_id: departmentId, + }); + setEmpModal({ mode: "create", departmentId }); + }; + + const openEditEmp = (emp: OrgEmployee) => { + setError(""); + setEmpForm({ + name: emp.name, + role: emp.role || "", + description: emp.description || "", + employee_type: emp.employee_type || "human", + avatar_emoji: emp.avatar_emoji || "👤", + department_id: emp.department_id, + }); + setEmpModal({ mode: "edit", emp }); + setDetail(null); + }; + + const submitDept = async () => { + const name = deptForm.name.trim(); + if (!name) { + setError(labels.required); + return; + } + try { + if (deptModal?.mode === "edit") { + await client.updateDepartment(deptModal.dept.id, { + name, + description: deptForm.description, + parent_id: deptForm.parent_id, + }); + } else { + await client.createDepartment({ + name, + description: deptForm.description, + parent_id: deptForm.parent_id, + }); + } + setDeptModal(null); + await fetchTree(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const submitEmp = async () => { + const name = empForm.name.trim(); + if (!name) { + setError(labels.required); + return; + } + try { + if (empModal?.mode === "edit") { + await client.updateEmployee(empModal.emp.id, { + name, + role: empForm.role, + description: empForm.description, + employee_type: empForm.employee_type, + avatar_emoji: empForm.avatar_emoji, + department_id: empForm.department_id, + }); + } else { + await client.createEmployee({ + name, + role: empForm.role, + description: empForm.description, + employee_type: empForm.employee_type, + avatar_emoji: empForm.avatar_emoji, + department_id: empForm.department_id, + }); + } + setEmpModal(null); + await fetchTree(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const deleteDept = async (dept: OrgDepartment) => { + if (!window.confirm(labels.confirmDeleteDept)) return; + try { + await client.removeDepartment(dept.id); + await fetchTree(); + } catch (err) { + window.alert( + err instanceof Error ? err.message : labels.confirmDeleteDept, + ); + } + }; + + const deleteEmp = async (emp: OrgEmployee) => { + if (!window.confirm(labels.confirmDeleteEmp)) return; + try { + await client.removeEmployee(emp.id); + setDetail(null); + await fetchTree(); + } catch (err) { + window.alert( + err instanceof Error ? err.message : labels.confirmDeleteEmp, + ); + } + }; + + const renderNode = (dept: OrgDepartment) => { + if (query && !matchesQuery(dept, query)) return null; + const people = dept.employees ?? []; + const visiblePeople = people.filter((emp) => employeeVisible(emp, query)); + return ( +
+
+ + {dept.name} + + {countEmployees(dept)} + {session.isAdmin ? ( + + + + + + + ) : null} +
+ {dept.description ? ( +

{dept.description}

+ ) : null} + {visiblePeople.length ? ( +
+ {visiblePeople.map((emp) => ( + + ))} +
+ ) : null} + {(dept.children ?? []).length ? ( +
+ {(dept.children ?? []).map((child) => renderNode(child))} +
+ ) : null} +
+ ); + }; + + return ( +
+
+
+ +

{labels.title}

+ + {totalEmployees} {labels.employees} + +
+ {session.isAdmin ? ( + + ) : null} +
+ +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={labels.search} + style={{ maxWidth: 260 }} + data-testid="org-chart-search" + /> +
+ + {loading ? ( +
{labels.loading}
+ ) : tree.length === 0 ? ( +
+ {labels.empty} +
+ ) : filtered.length === 0 ? ( +
{labels.noMatch}
+ ) : ( +
+ {filtered.map((node) => renderNode(node))} +
+ )} + + setDetail(null)} + > + {detail ? ( + +
+ {detail.avatar_emoji || "👤"} {detail.role || "—"} +
+ + {detail.employee_type === "ai" ? labels.typeAi : labels.typeHuman} + + {detail.description ? ( +

{detail.description}

+ ) : null} + {session.isAdmin ? ( + + + + + ) : null} +
+ ) : null} +
+ + setDeptModal(null)} + onOk={() => void submitDept()} + okText={deptModal?.mode === "edit" ? labels.save : labels.create} + okButtonProps={{ "data-testid": "org-chart-dept-submit" }} + > + +
+ + {labels.fieldName} + + + setDeptForm({ ...deptForm, name: event.target.value }) + } + data-testid="org-chart-dept-name" + /> +
+
+ + {labels.fieldDescription} + + + setDeptForm({ ...deptForm, description: event.target.value }) + } + /> +
+
+ + {labels.fieldParent} + + + setEmpForm({ ...empForm, name: event.target.value }) + } + data-testid="org-chart-emp-name" + /> +
+
+ + {labels.fieldRole} + + + setEmpForm({ ...empForm, role: event.target.value }) + } + /> +
+
+ + {labels.fieldDescription} + + + setEmpForm({ ...empForm, description: event.target.value }) + } + /> +
+ + + setEmpForm({ ...empForm, avatar_emoji: event.target.value }) + } + aria-label={labels.fieldEmoji} + /> + +
+ + {labels.fieldDepartment} + +