feat: implement browser extension for media publishing and add backend support for media management

This commit is contained in:
2026-04-03 00:39:15 +08:00
parent 8958cb44c0
commit 32d6a462cd
60 changed files with 8268 additions and 26 deletions
+52
View File
@@ -9,6 +9,10 @@ import type {
BrandRequest,
Competitor,
CompetitorRequest,
CreatePluginSessionRequest,
CreatePluginSessionResponse,
CreatePublishBatchRequest,
CreatePublishBatchResponse,
GenerateFromRuleRequest,
GenerateFromRuleResponse,
InstantTaskListParams,
@@ -18,6 +22,11 @@ import type {
KeywordRequest,
LoginRequest,
LoginResponse,
MediaPlatform,
PlatformAccount,
PublishRecord,
RegisterPluginInstallationRequest,
RegisterPluginInstallationResponse,
PromptRule,
PromptRuleGroup,
PromptRuleGroupRequest,
@@ -68,6 +77,16 @@ const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED =
const publicClient = createApiClient({ baseURL });
export function getApiBaseURL(): string {
if (baseURL) {
return baseURL;
}
if (typeof window !== "undefined") {
return window.location.origin;
}
return "";
}
export const apiClient = createApiClient({
baseURL,
auth: {
@@ -192,6 +211,15 @@ export const articlesApi = {
versions(id: number) {
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
},
publishBatch(id: number, payload: CreatePublishBatchRequest) {
return apiClient.post<CreatePublishBatchResponse, CreatePublishBatchRequest>(
`/api/tenant/articles/${id}/publish-batches`,
payload,
);
},
publishRecords(id: number) {
return apiClient.get<PublishRecord[]>(`/api/tenant/articles/${id}/publish-records`);
},
remove(id: number) {
return apiClient.remove<null>(`/api/tenant/articles/${id}`);
},
@@ -340,6 +368,30 @@ export const brandsApi = {
},
};
export const mediaApi = {
platforms() {
return apiClient.get<MediaPlatform[]>("/api/tenant/media/platforms");
},
accounts() {
return apiClient.get<PlatformAccount[]>("/api/tenant/media/platform-accounts");
},
createPluginSession(payload: CreatePluginSessionRequest) {
return apiClient.post<CreatePluginSessionResponse, CreatePluginSessionRequest>(
"/api/tenant/media/plugin-sessions",
payload,
);
},
registerPluginInstallation(payload: RegisterPluginInstallationRequest) {
return apiClient.post<RegisterPluginInstallationResponse, RegisterPluginInstallationRequest>(
"/api/tenant/media/plugin-installations/register",
payload,
);
},
unbindAccount(id: number) {
return apiClient.remove<null>(`/api/tenant/media/platform-accounts/${id}`);
},
};
export const promptRulesApi = {
list(params?: PromptRuleListParams) {
return apiClient.get<PromptRuleListResponse>("/api/tenant/prompt-rules", { params });
+3
View File
@@ -14,6 +14,9 @@ const generateStatusMap: Record<string, { label: string; color: string }> = {
const publishStatusMap: Record<string, { label: string; color: string }> = {
unpublished: { label: "status.publish.unpublished", color: "default" },
publishing: { label: "status.publish.publishing", color: "processing" },
success: { label: "status.publish.success", color: "success" },
failed: { label: "status.publish.failed", color: "error" },
partial_success: { label: "status.publish.partial_success", color: "warning" },
published: { label: "status.publish.published", color: "success" },
publish_success: { label: "status.publish.publish_success", color: "success" },
publish_failed: { label: "status.publish.publish_failed", color: "error" },
+132
View File
@@ -0,0 +1,132 @@
import type {
PublisherBindRequest,
PublisherBindResponse,
PublisherLocalPlatformState,
PublisherPluginPingResponse,
PublisherRegisterInstallationPayload,
PublisherRegisterInstallationResult,
PublisherPublishArticleRequest,
PublisherPublishResponse,
} from "@geo/shared-types";
type PluginAction = "ping" | "detectPlatforms" | "registerInstallation" | "bindAccount" | "publishArticle";
type PluginSuccessPayload<T> = {
source: "geo-extension";
type: "geo.publisher.response";
requestId: string;
success: true;
data: T;
};
type PluginErrorPayload = {
source: "geo-extension";
type: "geo.publisher.response";
requestId: string;
success: false;
error: string;
};
type PluginPayloadMap = {
ping: PublisherPluginPingResponse;
detectPlatforms: PublisherLocalPlatformState[];
registerInstallation: PublisherRegisterInstallationResult;
bindAccount: PublisherBindResponse;
publishArticle: PublisherPublishResponse;
};
function nextRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `geo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function requestPlugin<T extends PluginAction>(
action: T,
payload?: T extends "bindAccount"
? PublisherBindRequest
: T extends "registerInstallation"
? PublisherRegisterInstallationPayload
: T extends "publishArticle"
? PublisherPublishArticleRequest
: undefined,
timeoutMs = 4000,
): Promise<PluginPayloadMap[T]> {
if (typeof window === "undefined") {
throw new Error("plugin bridge is only available in browser contexts");
}
const requestId = nextRequestId();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
cleanup();
reject(new Error("publisher_plugin_timeout"));
}, timeoutMs);
const handleMessage = (event: MessageEvent<PluginSuccessPayload<PluginPayloadMap[T]> | PluginErrorPayload>) => {
if (event.source !== window || !event.data || event.data.type !== "geo.publisher.response") {
return;
}
if (event.data.requestId !== requestId || event.data.source !== "geo-extension") {
return;
}
cleanup();
if (event.data.success) {
resolve(event.data.data);
return;
}
reject(new Error(event.data.error || "publisher_plugin_unknown_error"));
};
const cleanup = () => {
window.clearTimeout(timer);
window.removeEventListener("message", handleMessage);
};
window.addEventListener("message", handleMessage);
window.postMessage(
{
source: "geo-admin",
type: "geo.publisher.request",
requestId,
action,
payload,
},
window.location.origin,
);
});
}
export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse> {
try {
return await requestPlugin("ping", undefined, 1500);
} catch {
return {
installed: false,
capabilities: [],
};
}
}
export async function detectPublisherPlatforms(): Promise<PublisherLocalPlatformState[]> {
return requestPlugin("detectPlatforms", undefined, 2500);
}
export async function registerPublisherInstallation(
payload: PublisherRegisterInstallationPayload,
): Promise<PublisherRegisterInstallationResult> {
return requestPlugin("registerInstallation", payload, 5000);
}
export async function bindPublisherPlatform(payload: PublisherBindRequest): Promise<PublisherBindResponse> {
return requestPlugin("bindAccount", payload, 10000);
}
export async function publishWithPublisherPlugin(
payload: PublisherPublishArticleRequest,
): Promise<PublisherPublishResponse> {
return requestPlugin("publishArticle", payload, 25000);
}
@@ -0,0 +1,73 @@
import type { PublisherLocalPlatformState, PublisherPluginPingResponse } from "@geo/shared-types";
import { getApiBaseURL, mediaApi } from "./api";
import {
detectPublisherPlatforms,
pingPublisherPlugin,
registerPublisherInstallation,
} from "./publisher-plugin";
export interface PublisherRuntimeState {
ping: PublisherPluginPingResponse;
localPlatforms: PublisherLocalPlatformState[];
pluginInstallationId: number | null;
}
function detectBrowserName(): string {
const ua = navigator.userAgent;
if (/Edg\//.test(ua)) {
return "Edge";
}
if (/Chrome\//.test(ua) && !/Edg\//.test(ua)) {
return "Chrome";
}
if (/Firefox\//.test(ua)) {
return "Firefox";
}
if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) {
return "Safari";
}
return navigator.platform || "Unknown";
}
export async function loadPublisherRuntimeState(): Promise<PublisherRuntimeState> {
const ping = await pingPublisherPlugin();
if (!ping.installed || !ping.installation_key) {
return {
ping,
localPlatforms: [],
pluginInstallationId: null,
};
}
const localPlatforms = await detectPublisherPlatforms();
let pluginInstallationId = ping.plugin_installation_id ?? null;
try {
const registration = await mediaApi.registerPluginInstallation({
installation_key: ping.installation_key,
installation_name: "GEO Publisher",
browser_name: detectBrowserName(),
client_version: ping.version,
});
await registerPublisherInstallation({
plugin_installation_id: registration.plugin_installation_id,
installation_token: registration.installation_token,
api_base_url: getApiBaseURL(),
});
pluginInstallationId = registration.plugin_installation_id;
} catch (error) {
console.error("publisher installation registration failed", error);
}
return {
ping: {
...ping,
plugin_installation_id: pluginInstallationId,
},
localPlatforms,
pluginInstallationId,
};
}