feat: add project-aware agent workspace
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { ResourceCache } from "@/shared/cache/resource-cache";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { NetworkViewParams } from "../map/camera";
|
||||
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
|
||||
|
||||
const projectSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
@@ -22,12 +24,27 @@ const projectPageSchema = z.object({
|
||||
|
||||
export type WorkbenchProject = z.infer<typeof projectSchema>;
|
||||
|
||||
const placeSearchConfigurationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.literal("tianditu"),
|
||||
admin_code: z.string().regex(/^(?:156)?[0-9]{6}$/),
|
||||
region: z.string().trim().min(1)
|
||||
});
|
||||
export type PlaceSearchConfiguration = z.infer<typeof placeSearchConfigurationSchema>;
|
||||
|
||||
export function parsePlaceSearchConfiguration(mapConfig: unknown): PlaceSearchConfiguration | null {
|
||||
if (!mapConfig || typeof mapConfig !== "object" || !("place_search" in mapConfig)) return null;
|
||||
const parsed = placeSearchConfigurationSchema.safeParse(mapConfig.place_search);
|
||||
return parsed.success && parsed.data.enabled ? parsed.data : null;
|
||||
}
|
||||
|
||||
const projectMetadataSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
code: z.string().trim().min(1),
|
||||
gs_workspace: z.string().trim().min(1),
|
||||
map_extent: z.unknown().nullable().optional(),
|
||||
map_config: z.unknown().optional(),
|
||||
status: z.string(),
|
||||
project_role: z.string().trim().min(1)
|
||||
});
|
||||
@@ -64,13 +81,14 @@ export type WorkbenchProjectPresentation = {
|
||||
systemRole: string | null;
|
||||
isSystemAdmin: boolean;
|
||||
networkView: NetworkViewParams | null;
|
||||
placeSearch: PlaceSearchConfiguration | null;
|
||||
metadataAvailable: boolean;
|
||||
accessContextAvailable: boolean;
|
||||
};
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
const PROJECT_CACHE_TTL_MS = 5 * 60_000;
|
||||
const projectContextPromises = new WeakMap<AccessTokenProvider, {
|
||||
const projectContextPromises = new WeakMap<WorkbenchAccess, {
|
||||
promise: Promise<WorkbenchProject>;
|
||||
expiresAt: number;
|
||||
}>();
|
||||
@@ -86,11 +104,13 @@ function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal)
|
||||
}
|
||||
|
||||
export async function findOwnedWorkbenchProject(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
|
||||
const accessToken = await getAccessToken?.();
|
||||
const accessToken = await getWorkbenchAccessToken(getAccessToken);
|
||||
signal?.throwIfAborted();
|
||||
const scopedProject = getWorkbenchProject(getAccessToken);
|
||||
if (scopedProject) return { project: scopedProject, accessToken };
|
||||
const existing = getAccessToken
|
||||
? projectContextPromises.get(getAccessToken)
|
||||
: undefined;
|
||||
@@ -123,7 +143,7 @@ export async function findOwnedWorkbenchProject(
|
||||
}
|
||||
|
||||
export async function fetchWorkbenchProjectPresentation(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkbenchProjectPresentation> {
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
@@ -147,11 +167,53 @@ export async function fetchWorkbenchProjectPresentation(
|
||||
systemRole: accessContext?.system_role ?? null,
|
||||
isSystemAdmin: accessContext?.is_system_admin ?? false,
|
||||
networkView: parseProjectMapExtent(metadata?.map_extent),
|
||||
placeSearch: parsePlaceSearchConfiguration(metadata?.map_config),
|
||||
metadataAvailable: metadata !== null,
|
||||
accessContextAvailable: accessContext !== null
|
||||
};
|
||||
}
|
||||
|
||||
const projectListCaches = new WeakMap<AccessTokenProvider, ResourceCache>();
|
||||
|
||||
export function clearWorkbenchProjectCache(provider?: AccessTokenProvider) {
|
||||
if (provider) { projectListCaches.delete(provider); projectContextPromises.delete(provider); }
|
||||
}
|
||||
|
||||
export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal, force = false) {
|
||||
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
|
||||
let cache = projectListCaches.get(getAccessToken);
|
||||
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
|
||||
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
|
||||
PROJECT_CACHE_TTL_MS, { force });
|
||||
if (!signal) return pending;
|
||||
signal.throwIfAborted();
|
||||
return new Promise<WorkbenchProject[]>((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
const token = await getAccessToken?.();
|
||||
signal?.throwIfAborted();
|
||||
const projects: WorkbenchProject[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
|
||||
headers: createWorkbenchHeaders(token), signal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
const page = projectPageSchema.parse(payload);
|
||||
projects.push(...page.items.filter((project) => project.status === "active"));
|
||||
offset += page.items.length;
|
||||
if (offset >= page.total) break;
|
||||
if (!page.items.length) throw new Error("项目列表分页未返回后续数据");
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
|
||||
let bbox: z.infer<typeof bboxSchema> | null = null;
|
||||
if (value && typeof value === "object" && "bbox" in value) {
|
||||
|
||||
Reference in New Issue
Block a user