feat(desktop): implement workspace foundation + desktop-client skeleton

Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
2026-04-19 14:18:20 +08:00
parent 98f9e95875
commit b16e9f0bd1
141 changed files with 21533 additions and 357 deletions
@@ -0,0 +1,105 @@
import { Modal } from "ant-design-vue";
type ClientErrorTone = "error" | "warning";
type ClientActionKind = "bind-account" | "open-console";
interface ClientErrorPresentation {
tone: ClientErrorTone;
title: string;
content: string;
}
const REMOTE_METHOD_PREFIX = /^Error invoking remote method '[^']+':\s*/i;
const ERROR_PREFIX = /^Error:\s*/i;
function rawErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "";
}
export function unwrapClientErrorMessage(error: unknown, fallback: string): string {
let message = rawErrorMessage(error).trim();
let previous = "";
while (message && message !== previous) {
previous = message;
message = message
.replace(REMOTE_METHOD_PREFIX, "")
.replace(ERROR_PREFIX, "")
.trim();
}
return message || fallback;
}
function presentClientError(kind: ClientActionKind, error: unknown): ClientErrorPresentation {
const message = unwrapClientErrorMessage(
error,
kind === "bind-account" ? "账号绑定失败" : "打开创作台失败",
);
if (message === "desktop_account_bind_window_closed") {
return {
tone: "warning",
title: "授权已中断",
content: "授权窗口已关闭,本次绑定没有完成。重新点击“绑定账号”即可继续。",
};
}
if (message === "desktop_account_upsert_failed") {
return {
tone: "error",
title: "保存授权账号失败",
content: "平台侧授权已经完成,但客户端回写账号信息失败。请稍后重试。",
};
}
if (message === "desktop_account_bind_failed") {
return {
tone: "error",
title: "账号绑定失败",
content: "授权流程执行失败,请稍后重新发起绑定。",
};
}
if (message.startsWith("desktop_publish_platform_not_supported:")) {
const platformId = message.split(":")[1] || "当前平台";
return {
tone: "error",
title: "平台暂不支持",
content: `${platformId} 暂时还不支持这个操作。`,
};
}
if (kind === "open-console") {
return {
tone: "error",
title: "打开创作台失败",
content: message,
};
}
return {
tone: "error",
title: "账号绑定失败",
content: message,
};
}
export function showClientActionError(kind: ClientActionKind, error: unknown): void {
const presentation = presentClientError(kind, error);
const showModal = presentation.tone === "warning" ? Modal.warning : Modal.error;
showModal({
title: presentation.title,
content: presentation.content,
okText: "知道了",
centered: true,
maskClosable: true,
});
}
@@ -0,0 +1,50 @@
const relativeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" });
export function formatRelativeTime(value: number): string {
const deltaSeconds = Math.round((value - Date.now()) / 1000);
const absSeconds = Math.abs(deltaSeconds);
if (absSeconds < 60) {
return relativeFormatter.format(deltaSeconds, "second");
}
if (absSeconds < 3600) {
return relativeFormatter.format(Math.round(deltaSeconds / 60), "minute");
}
if (absSeconds < 86_400) {
return relativeFormatter.format(Math.round(deltaSeconds / 3600), "hour");
}
return relativeFormatter.format(Math.round(deltaSeconds / 86_400), "day");
}
export function formatClock(value: number | null): string {
if (!value) {
return "N/A";
}
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
day: "2-digit",
}).format(value);
}
export function formatDateTime(value: number | null): string {
if (!value) {
return "N/A";
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(value);
}
export function titleCaseToken(value: string): string {
return value
.split(/[_-]/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
@@ -0,0 +1,192 @@
import logoBaijiahao from "../assets/logos/logo_baijiahao.png";
import logoBilibili from "../assets/logos/logo_bilibili.png";
import logoDongchedi from "../assets/logos/logo_dongchedi.svg";
import logoJianshu from "../assets/logos/logo_jianshu.svg";
import logoJuejin from "../assets/logos/logo_juejin.png";
import logoQiehao from "../assets/logos/logo_qiehao.png";
import logoSmzdm from "../assets/logos/logo_smzdm.svg";
import logoSouhu from "../assets/logos/logo_souhu.png";
import logoToutiao from "../assets/logos/logo_toutiao.png";
import logoWangyihao from "../assets/logos/logo_wangyihao.png";
import logoWeixinGzh from "../assets/logos/logo_weixin_gzh.svg";
import logoZhihu from "../assets/logos/logo_zhihu.png";
import logoZol from "../assets/logos/logo_zol.png";
export interface DesktopMediaDefinition {
id: string;
label: string;
shortName: string;
accent: string;
loginUrl: string | null;
logoUrl: string | null;
category: "publish" | "monitoring";
description: string;
}
export const desktopPublishMediaCatalog: DesktopMediaDefinition[] = [
{
id: "toutiaohao",
label: "头条号",
shortName: "头",
accent: "#f5222d",
loginUrl: "https://mp.toutiao.com/auth/page/login",
logoUrl: logoToutiao,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "baijiahao",
label: "百家号",
shortName: "百",
accent: "#31445a",
loginUrl: "https://baijiahao.baidu.com/builder/theme/bjh/login",
logoUrl: logoBaijiahao,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "sohuhao",
label: "搜狐号",
shortName: "搜",
accent: "#fa8c16",
loginUrl: "https://mp.sohu.com/mpfe/v4/login",
logoUrl: logoSouhu,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "qiehao",
label: "企鹅号",
shortName: "Q",
accent: "#111827",
loginUrl: "https://om.qq.com",
logoUrl: logoQiehao,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "zhihu",
label: "知乎",
shortName: "知",
accent: "#1677ff",
loginUrl: "https://www.zhihu.com/signin",
logoUrl: logoZhihu,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "wangyihao",
label: "网易号",
shortName: "网",
accent: "#ff4d4f",
loginUrl: "https://mp.163.com/login.html",
logoUrl: logoWangyihao,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "jianshu",
label: "简书",
shortName: "简",
accent: "#f56a5e",
loginUrl: "https://www.jianshu.com/sign_in",
logoUrl: logoJianshu,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "bilibili",
label: "bilibili",
shortName: "B",
accent: "#eb2f96",
loginUrl: "https://www.bilibili.com",
logoUrl: logoBilibili,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "juejin",
label: "稀土掘金",
shortName: "掘",
accent: "#1677ff",
loginUrl: "https://juejin.cn/login",
logoUrl: logoJuejin,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "smzdm",
label: "什么值得买",
shortName: "值",
accent: "#f5222d",
loginUrl: "https://zhiyou.smzdm.com/user/login",
logoUrl: logoSmzdm,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "weixin_gzh",
label: "微信公众号",
shortName: "微",
accent: "#13c26b",
loginUrl: "https://mp.weixin.qq.com/cgi-bin/loginpage",
logoUrl: logoWeixinGzh,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "zol",
label: "中关村在线",
shortName: "Z",
accent: "#ff4d4f",
loginUrl: "https://post.zol.com.cn/v2/manage/works/all",
logoUrl: logoZol,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
{
id: "dongchedi",
label: "懂车帝",
shortName: "懂",
accent: "#fadb14",
loginUrl: "https://mp.dcdapp.com/login",
logoUrl: logoDongchedi,
category: "publish",
description: "发布媒体 / 浏览器登录检测",
},
];
export const desktopMonitoringMediaCatalog: DesktopMediaDefinition[] = [
{
id: "deepseek",
label: "DeepSeek",
shortName: "D",
accent: "#4468ff",
loginUrl: "https://chat.deepseek.com/",
logoUrl: null,
category: "monitoring",
description: "AI 监测 / 登录后可采集",
},
{
id: "qwen",
label: "通义千问",
shortName: "Q",
accent: "#6b46ff",
loginUrl: "https://www.qianwen.com/",
logoUrl: null,
category: "monitoring",
description: "AI 监测 / 匿名可用",
},
{
id: "doubao",
label: "豆包",
shortName: "豆",
accent: "#00a870",
loginUrl: "https://www.doubao.com/",
logoUrl: null,
category: "monitoring",
description: "AI 监测 / 匿名可用",
},
];
export const desktopMediaCatalog = [...desktopPublishMediaCatalog, ...desktopMonitoringMediaCatalog];
@@ -0,0 +1,29 @@
import type { RuntimeAccount, RuntimeTask, RuntimeTone } from "../types";
export function healthTone(health: RuntimeAccount["health"]): RuntimeTone {
switch (health) {
case "live":
return "success";
case "captcha":
return "warn";
case "risk":
return "danger";
default:
return "info";
}
}
export function taskTone(status: RuntimeTask["status"]): RuntimeTone {
switch (status) {
case "succeeded":
return "success";
case "waiting_user":
return "warn";
case "failed":
case "aborted":
case "unknown":
return "danger";
default:
return "info";
}
}