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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## [Unreleased]

### 新增

- 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)。

### 修复


Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@
"assemblePreviewExperts": "Opening Experts so you can select the imported colleagues.",
"packReceipt": "Packed and pushed to the signed-in openXYOS tenant.",
"packLanded": "Signed-in tenant {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}",
"packPreviewEmployees": "The preview now opens Employees so you can confirm the result."
"packPreviewEmployees": "The preview now opens Employees so you can confirm the result.",
"navWorkbench": "Workbench",
"navAnnouncements": "Announcements",
"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"
},
"systemSettings": {
"subtitle": "Workbench, remote desktop, and host administration in one place."
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@
"assemblePreviewExperts": "正在打开「专家」页,导入的同事可在那里选用。",
"packReceipt": "已打包并推送到当前登录的 openXYOS 租户。",
"packLanded": "当前登录租户 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}",
"packPreviewEmployees": "右侧预览已打开员工页,可直接核对。"
"packPreviewEmployees": "右侧预览已打开员工页,可直接核对。",
"navWorkbench": "工作台",
"navAnnouncements": "通知公告",
"announcementsTitle": "通知公告",
"announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。",
"openAnnouncements": "打开通知公告"
},
"systemSettings": {
"subtitle": "工作台、远程桌面与主机管理集中在这里。"
Expand Down
50 changes: 50 additions & 0 deletions dashboard/src/org-ui/api/createClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { createOrgApiClient } from "./createClient";

describe("createOrgApiClient", () => {
it("calls the in-host announcements prefix and unwraps the envelope", async () => {
const fetchJson = vi.fn(async (path: string) => {
if (path.startsWith("/org-module/announcements?")) {
return {
success: true,
data: {
list: [{ id: 1, title: "Hi" }],
total: 1,
page: 1,
limit: 10,
},
};
}
if (path === "/org-module/announcements/action/unread") {
return { success: true, data: { count: 2 } };
}
throw new Error(`unexpected ${path}`);
});
const client = createOrgApiClient({ fetchJson });
const listed = await client.announcements.list({
page: 1,
limit: 10,
type: "all",
});
expect(listed.total).toBe(1);
expect(listed.list[0]?.title).toBe("Hi");
expect(await client.announcements.unread()).toEqual({ count: 2 });
expect(fetchJson).toHaveBeenCalled();
});

it("throws the envelope error", async () => {
const client = createOrgApiClient({
fetchJson: async () => ({ success: false, error: "Permission denied" }),
});
await expect(
client.announcements.create({
title: "x",
content: "y",
type: "notice",
priority: "normal",
is_pinned: false,
expires_at: null,
}),
).rejects.toThrow("Permission denied");
});
});
167 changes: 167 additions & 0 deletions dashboard/src/org-ui/api/createClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { OrgFetcher } from "../shell";

export type AnnouncementType = "notice" | "policy" | "news" | "emergency";
export type AnnouncementPriority = "low" | "normal" | "important" | "urgent";

export interface Announcement {
id: number;
title: string;
content: string;
type: AnnouncementType | string;
priority: AnnouncementPriority | string;
is_pinned: number;
is_read?: boolean;
read_count?: number;
total_users?: number;
read_percent?: number;
published_at: string;
expires_at: string | null;
creator_name?: string;
created_at?: string;
updated_at?: string | null;
}

export interface AnnouncementListData {
list: Announcement[];
total: number;
page: number;
limit: number;
}

export interface AnnouncementWrite {
title: string;
content: string;
type: string;
priority: string;
is_pinned: boolean | number;
expires_at: string | null;
}

export interface OrgEnvelope<T> {
success: boolean;
data?: T;
error?: string;
}

export interface OrgAnnouncementsClient {
list(params: {
page: number;
limit: number;
type: string;
search?: string;
}): Promise<AnnouncementListData>;
get(id: number): Promise<Announcement>;
unread(): Promise<{ count: number }>;
markRead(id: number): Promise<void>;
markAllRead(): Promise<{ marked: number }>;
create(body: AnnouncementWrite): Promise<Announcement>;
update(id: number, body: AnnouncementWrite): Promise<Announcement>;
remove(id: number): Promise<void>;
togglePin(id: number): Promise<{ is_pinned: boolean }>;
pinned(): Promise<Announcement[]>;
}

export interface OrgApiClient {
announcements: OrgAnnouncementsClient;
}

async function unwrap<T>(payload: OrgEnvelope<T>): Promise<T> {
if (!payload.success) {
throw new Error(payload.error || "request failed");
}
return payload.data as T;
}

/**
* Factory used by Dashboard (FreeOS JWT via `request`) and standalone export.
* Default prefix matches the in-host BFF; standalone can pass `/announcements`.
*/
export function createOrgApiClient(opts: {
fetchJson: OrgFetcher;
announcementsPrefix?: string;
}): OrgApiClient {
const prefix = opts.announcementsPrefix ?? "/org-module/announcements";
const fetchJson = opts.fetchJson;

const announcements: OrgAnnouncementsClient = {
async list(params) {
const query = new URLSearchParams({
page: String(params.page),
limit: String(params.limit),
type: params.type,
});
if (params.search) query.set("search", params.search);
return unwrap(
await fetchJson<OrgEnvelope<AnnouncementListData>>(
`${prefix}?${query.toString()}`,
),
);
},
async get(id) {
return unwrap(
await fetchJson<OrgEnvelope<Announcement>>(`${prefix}/${id}`),
);
},
async unread() {
return unwrap(
await fetchJson<OrgEnvelope<{ count: number }>>(
`${prefix}/action/unread`,
),
);
},
async markRead(id) {
await unwrap(
await fetchJson<OrgEnvelope<undefined>>(`${prefix}/${id}/read`, {
method: "POST",
}),
);
},
async markAllRead() {
return unwrap(
await fetchJson<OrgEnvelope<{ marked: number }>>(`${prefix}/read-all`, {
method: "POST",
}),
);
},
async create(body) {
return unwrap(
await fetchJson<OrgEnvelope<Announcement>>(prefix, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
},
async update(id, body) {
return unwrap(
await fetchJson<OrgEnvelope<Announcement>>(`${prefix}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
},
async remove(id) {
await unwrap(
await fetchJson<OrgEnvelope<undefined>>(`${prefix}/${id}`, {
method: "DELETE",
}),
);
},
async togglePin(id) {
return unwrap(
await fetchJson<OrgEnvelope<{ is_pinned: boolean }>>(
`${prefix}/${id}/toggle-pin`,
{ method: "PUT" },
),
);
},
async pinned() {
return unwrap(
await fetchJson<OrgEnvelope<Announcement[]>>(`${prefix}/pinned`),
);
},
};

return { announcements };
}
4 changes: 4 additions & 0 deletions dashboard/src/org-ui/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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 type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number];
20 changes: 20 additions & 0 deletions dashboard/src/org-ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export { SHARED_ORG_UI_MODULES } from "./contract";
export type { SharedOrgUiModule } from "./contract";
export { createOrgApiClient } from "./api/createClient";
export type {
Announcement,
AnnouncementListData,
AnnouncementWrite,
OrgAnnouncementsClient,
OrgApiClient,
OrgEnvelope,
} from "./api/createClient";
export { AnnouncementPage } from "./pages/announcements/AnnouncementPage";
export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage";
export type {
IdentityBridge,
OrgFetcher,
OrgLocale,
OrgSession,
ShellAdapter,
} from "./shell";
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
.page {
max-width: 880px;
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;
}

.card {
cursor: pointer;
}

.cardTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}

.unreadDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4d4f;
flex-shrink: 0;
}

.preview {
margin: 6px 0 0;
color: var(--fn-text-secondary, #667085);
font-size: 13px;
}

.meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--fn-text-secondary, #667085);
}

.empty {
text-align: center;
padding: 48px 12px;
color: var(--fn-text-secondary, #667085);
}

.detailBody {
white-space: pre-wrap;
line-height: 1.6;
}
Loading
Loading