forked from bai/curriculum-project-hub
feat: add paginated project discovery
This commit is contained in:
@@ -1,97 +1,46 @@
|
||||
export interface OnboardingProjectOption {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly folderName?: string | undefined;
|
||||
}
|
||||
import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js";
|
||||
|
||||
export interface OnboardingFolderOption {
|
||||
readonly folderId: string;
|
||||
readonly name: string;
|
||||
}
|
||||
export type ProjectOnboardingView =
|
||||
| { readonly mode: "search"; readonly result: ProjectDiscoveryPage }
|
||||
| { readonly mode: "browse"; readonly result: ProjectFolderPage };
|
||||
|
||||
export interface ProjectOnboardingActionValue {
|
||||
readonly action: "create_project_from_chat" | "bind_project";
|
||||
readonly action: "create_project_from_chat" | "bind_project" | "browse_folder" | "search_page" | "rename_project";
|
||||
readonly organization_id: string;
|
||||
readonly project_id?: string | undefined;
|
||||
readonly folder_id?: string | undefined;
|
||||
readonly search_query?: string | undefined;
|
||||
readonly page?: number | undefined;
|
||||
}
|
||||
|
||||
export function buildUnboundChatOnboardingCard(params: {
|
||||
readonly organizationId: string;
|
||||
readonly organizationName: string;
|
||||
readonly folders: readonly OnboardingFolderOption[];
|
||||
readonly projects: readonly OnboardingProjectOption[];
|
||||
readonly canCreateProject: boolean;
|
||||
readonly searchQuery?: string | undefined;
|
||||
readonly view: ProjectOnboardingView;
|
||||
}): Record<string, unknown> {
|
||||
const actions: unknown[] = [];
|
||||
if (params.canCreateProject) {
|
||||
const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3);
|
||||
for (const folder of folders) {
|
||||
actions.push({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` },
|
||||
type: "primary",
|
||||
value: {
|
||||
project_onboarding: {
|
||||
action: "create_project_from_chat",
|
||||
organization_id: params.organizationId,
|
||||
...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const elements: unknown[] = [{
|
||||
tag: "markdown",
|
||||
content: onboardingSummary(params.organizationName, params.view),
|
||||
}];
|
||||
|
||||
if (params.view.mode === "browse") {
|
||||
appendFolderNavigation(elements, params.organizationId, params.view.result);
|
||||
}
|
||||
appendProjectResults(
|
||||
elements,
|
||||
params.organizationId,
|
||||
params.view.mode === "search" ? params.view.result.items : params.view.result.projects,
|
||||
);
|
||||
appendPagination(elements, params.organizationId, params.view);
|
||||
appendCreation(elements, params.organizationId, params.canCreateProject, params.view);
|
||||
|
||||
for (const project of params.projects.slice(0, 5)) {
|
||||
actions.push({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: buttonProjectLabel(project) },
|
||||
type: "default",
|
||||
value: {
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: params.organizationId,
|
||||
project_id: project.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (elements.length === 1) {
|
||||
elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" });
|
||||
}
|
||||
|
||||
const elements: unknown[] = [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: [
|
||||
`这个飞书群还没有绑定项目。`,
|
||||
``,
|
||||
`组织: **${escapeMarkdown(params.organizationName)}**`,
|
||||
...(params.searchQuery === undefined || params.searchQuery === ""
|
||||
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
|
||||
: [
|
||||
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
|
||||
params.projects.length === 0
|
||||
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
|
||||
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
|
||||
]),
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
if (actions.length > 0) {
|
||||
elements.push({ tag: "action", actions });
|
||||
} else {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "绑定项目" },
|
||||
template: "blue",
|
||||
},
|
||||
header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" },
|
||||
elements,
|
||||
};
|
||||
}
|
||||
@@ -103,54 +52,237 @@ export function buildProjectOnboardingResolvedCard(params: {
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: params.title },
|
||||
template: params.template,
|
||||
},
|
||||
header: { title: { tag: "plain_text", content: params.title }, template: params.template },
|
||||
elements: [{ tag: "markdown", content: params.body }],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjectManagementCard(params: {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly projectName: string;
|
||||
readonly title?: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: params.title ?? "项目管理" },
|
||||
template: "green",
|
||||
},
|
||||
elements: [
|
||||
{ tag: "markdown", content: `当前项目: **${escapeMarkdown(params.projectName)}**` },
|
||||
{
|
||||
tag: "form",
|
||||
name: "project_rename_form",
|
||||
fallback: {
|
||||
tag: "fallback_text",
|
||||
text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" },
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "input",
|
||||
name: "project_name",
|
||||
required: true,
|
||||
max_length: 100,
|
||||
default_value: params.projectName,
|
||||
placeholder: { tag: "plain_text", content: "请输入项目名称" },
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
name: "project_rename_submit",
|
||||
text: { tag: "plain_text", content: "保存项目名称" },
|
||||
type: "primary",
|
||||
complex_interaction: true,
|
||||
action_type: "form_submit",
|
||||
value: {
|
||||
project_onboarding: {
|
||||
action: "rename_project",
|
||||
organization_id: params.organizationId,
|
||||
project_id: params.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null {
|
||||
const raw = unwrapValue(value);
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
if (!("project_onboarding" in raw)) return null;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("project_onboarding" in raw)) return null;
|
||||
const action = raw.project_onboarding;
|
||||
if (typeof action !== "object" || action === null || Array.isArray(action)) return null;
|
||||
const rawAction = (action as { action?: unknown }).action;
|
||||
const organizationId = (action as { organization_id?: unknown }).organization_id;
|
||||
const projectId = (action as { project_id?: unknown }).project_id;
|
||||
const folderId = (action as { folder_id?: unknown }).folder_id;
|
||||
if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") {
|
||||
return null;
|
||||
}
|
||||
if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) {
|
||||
return null;
|
||||
}
|
||||
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) {
|
||||
return null;
|
||||
}
|
||||
const fields = action as Record<string, unknown>;
|
||||
const rawAction = fields.action;
|
||||
const organizationId = fields.organization_id;
|
||||
const projectId = fields.project_id;
|
||||
const folderId = fields.folder_id;
|
||||
const searchQuery = fields.search_query;
|
||||
const page = fields.page;
|
||||
if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null;
|
||||
if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null;
|
||||
if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null;
|
||||
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null;
|
||||
if (page !== undefined && !validPage(page)) return null;
|
||||
return {
|
||||
action: rawAction,
|
||||
organization_id: organizationId,
|
||||
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
|
||||
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
|
||||
...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}),
|
||||
...(typeof page === "number" ? { page } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function onboardingSummary(organizationName: string, view: ProjectOnboardingView): string {
|
||||
if (view.mode === "search") {
|
||||
const result = view.result;
|
||||
return [
|
||||
"这个飞书群还没有绑定项目。",
|
||||
"",
|
||||
`组织: **${escapeMarkdown(organizationName)}**`,
|
||||
`搜索: **${escapeMarkdown(result.query)}**`,
|
||||
result.totalItems === 0
|
||||
? "没有匹配的可绑定项目,请换一个关键词。"
|
||||
: `共 **${result.totalItems}** 个匹配结果 · 第 **${result.page}/${result.totalPages}** 页`,
|
||||
].join("\n");
|
||||
}
|
||||
const location = view.result.breadcrumb === "" ? "根目录" : view.result.breadcrumb;
|
||||
return [
|
||||
"这个飞书群还没有绑定项目。",
|
||||
"",
|
||||
`组织: **${escapeMarkdown(organizationName)}**`,
|
||||
`当前位置: **${escapeMarkdown(location)}**`,
|
||||
"可以进入 folder 浏览,或选择有管理权限的未绑定项目。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function appendFolderNavigation(elements: unknown[], organizationId: string, result: ProjectFolderPage): void {
|
||||
const navigation: unknown[] = [];
|
||||
if (result.folderId !== null) {
|
||||
navigation.push(actionButton("⬆ 上一级", {
|
||||
action: "browse_folder",
|
||||
organization_id: organizationId,
|
||||
...(result.parentFolderId !== null ? { folder_id: result.parentFolderId } : {}),
|
||||
page: 1,
|
||||
}));
|
||||
}
|
||||
for (const folder of result.childFolders) {
|
||||
navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, {
|
||||
action: "browse_folder",
|
||||
organization_id: organizationId,
|
||||
folder_id: folder.folderId,
|
||||
page: 1,
|
||||
}));
|
||||
}
|
||||
for (let index = 0; index < navigation.length; index += 5) {
|
||||
elements.push({ tag: "action", actions: navigation.slice(index, index + 5) });
|
||||
}
|
||||
}
|
||||
|
||||
function appendProjectResults(
|
||||
elements: unknown[],
|
||||
organizationId: string,
|
||||
projects: readonly ProjectDiscoveryPage["items"][number][],
|
||||
): void {
|
||||
for (const project of projects) {
|
||||
const path = project.breadcrumb === "" ? "根目录" : project.breadcrumb;
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: `**${escapeMarkdown(project.name)}**\n${escapeMarkdown(path)}`,
|
||||
});
|
||||
elements.push({
|
||||
tag: "action",
|
||||
actions: [actionButton("绑定这个项目", {
|
||||
action: "bind_project",
|
||||
organization_id: organizationId,
|
||||
project_id: project.projectId,
|
||||
})],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendPagination(elements: unknown[], organizationId: string, view: ProjectOnboardingView): void {
|
||||
const result = projectPage(view);
|
||||
if (result.totalPages <= 1) return;
|
||||
const actions: unknown[] = [];
|
||||
if (result.page > 1) actions.push(pageButton("上一页", organizationId, view, result.page - 1));
|
||||
if (result.page < result.totalPages) actions.push(pageButton("下一页", organizationId, view, result.page + 1));
|
||||
elements.push({ tag: "action", actions });
|
||||
}
|
||||
|
||||
function appendCreation(
|
||||
elements: unknown[],
|
||||
organizationId: string,
|
||||
canCreateProject: boolean,
|
||||
view: ProjectOnboardingView,
|
||||
): void {
|
||||
if (!canCreateProject || view.mode !== "browse") return;
|
||||
elements.push({
|
||||
tag: "action",
|
||||
actions: [actionButton(view.result.folderId === null ? "新建项目" : "在此 folder 新建项目", {
|
||||
action: "create_project_from_chat",
|
||||
organization_id: organizationId,
|
||||
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
|
||||
}, "primary")],
|
||||
});
|
||||
}
|
||||
|
||||
function pageButton(label: string, organizationId: string, view: ProjectOnboardingView, page: number): unknown {
|
||||
if (view.mode === "search") {
|
||||
return actionButton(label, {
|
||||
action: "search_page",
|
||||
organization_id: organizationId,
|
||||
search_query: view.result.query,
|
||||
page,
|
||||
});
|
||||
}
|
||||
return actionButton(label, {
|
||||
action: "browse_folder",
|
||||
organization_id: organizationId,
|
||||
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage {
|
||||
if (view.mode === "search") return view.result;
|
||||
return {
|
||||
query: "",
|
||||
page: view.result.page,
|
||||
pageSize: view.result.pageSize,
|
||||
totalItems: view.result.totalFolders + view.result.totalProjects,
|
||||
totalPages: view.result.totalPages,
|
||||
items: view.result.projects,
|
||||
};
|
||||
}
|
||||
|
||||
function actionButton(
|
||||
label: string,
|
||||
value: ProjectOnboardingActionValue,
|
||||
type: "default" | "primary" = "default",
|
||||
): unknown {
|
||||
return {
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: label },
|
||||
type,
|
||||
value: { project_onboarding: value },
|
||||
};
|
||||
}
|
||||
|
||||
function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] {
|
||||
return value === "create_project_from_chat" || value === "bind_project"
|
||||
|| value === "browse_folder" || value === "search_page" || value === "rename_project";
|
||||
}
|
||||
|
||||
function validPage(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 10_000;
|
||||
}
|
||||
|
||||
function unwrapValue(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function buttonProjectLabel(project: OnboardingProjectOption): string {
|
||||
const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `;
|
||||
const label = `${folderPrefix}${project.name}`;
|
||||
return buttonLabel(label, 24);
|
||||
try { return JSON.parse(value); } catch { return value; }
|
||||
}
|
||||
|
||||
function buttonLabel(label: string, maxLength: number): string {
|
||||
|
||||
Reference in New Issue
Block a user