diff --git a/.gitignore b/.gitignore index 5216aee..c4929d7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ apps/*/firefox-mv*/ .claude .superpowers/* .playwright-mcp/* +.playwright-cli/* *.log *.tmp diff --git a/apps/admin-web/public/logos/logo_baijiahao.png b/apps/admin-web/public/logos/logo_baijiahao.png new file mode 100644 index 0000000..fcc5602 Binary files /dev/null and b/apps/admin-web/public/logos/logo_baijiahao.png differ diff --git a/apps/admin-web/public/logos/logo_bilibili.png b/apps/admin-web/public/logos/logo_bilibili.png new file mode 100644 index 0000000..7e2d13f Binary files /dev/null and b/apps/admin-web/public/logos/logo_bilibili.png differ diff --git a/apps/admin-web/public/logos/logo_dongchedi.svg b/apps/admin-web/public/logos/logo_dongchedi.svg new file mode 100644 index 0000000..a397c81 --- /dev/null +++ b/apps/admin-web/public/logos/logo_dongchedi.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/apps/admin-web/public/logos/logo_jianshu.svg b/apps/admin-web/public/logos/logo_jianshu.svg new file mode 100644 index 0000000..656b6dd --- /dev/null +++ b/apps/admin-web/public/logos/logo_jianshu.svg @@ -0,0 +1,4 @@ + + + 简书 + \ No newline at end of file diff --git a/apps/admin-web/public/logos/logo_juejin.png b/apps/admin-web/public/logos/logo_juejin.png new file mode 100644 index 0000000..6ee5fc0 Binary files /dev/null and b/apps/admin-web/public/logos/logo_juejin.png differ diff --git a/apps/admin-web/public/logos/logo_qiehao.png b/apps/admin-web/public/logos/logo_qiehao.png new file mode 100644 index 0000000..744bcc7 Binary files /dev/null and b/apps/admin-web/public/logos/logo_qiehao.png differ diff --git a/apps/admin-web/public/logos/logo_smzdm.svg b/apps/admin-web/public/logos/logo_smzdm.svg new file mode 100644 index 0000000..f85ad4c --- /dev/null +++ b/apps/admin-web/public/logos/logo_smzdm.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/apps/admin-web/public/logos/logo_souhu.png b/apps/admin-web/public/logos/logo_souhu.png new file mode 100644 index 0000000..9a63b32 Binary files /dev/null and b/apps/admin-web/public/logos/logo_souhu.png differ diff --git a/apps/admin-web/public/logos/logo_toutiao.png b/apps/admin-web/public/logos/logo_toutiao.png new file mode 100644 index 0000000..9995c7a Binary files /dev/null and b/apps/admin-web/public/logos/logo_toutiao.png differ diff --git a/apps/admin-web/public/logos/logo_wangyihao.png b/apps/admin-web/public/logos/logo_wangyihao.png new file mode 100644 index 0000000..bdcef05 Binary files /dev/null and b/apps/admin-web/public/logos/logo_wangyihao.png differ diff --git a/apps/admin-web/public/logos/logo_weixin_gzh.svg b/apps/admin-web/public/logos/logo_weixin_gzh.svg new file mode 100644 index 0000000..f306286 --- /dev/null +++ b/apps/admin-web/public/logos/logo_weixin_gzh.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/apps/admin-web/public/logos/logo_zhihu.png b/apps/admin-web/public/logos/logo_zhihu.png new file mode 100644 index 0000000..b5381c2 Binary files /dev/null and b/apps/admin-web/public/logos/logo_zhihu.png differ diff --git a/apps/admin-web/public/logos/logo_zol.png b/apps/admin-web/public/logos/logo_zol.png new file mode 100644 index 0000000..64432a2 Binary files /dev/null and b/apps/admin-web/public/logos/logo_zol.png differ diff --git a/apps/admin-web/src/components/ArticlePublishStatus.vue b/apps/admin-web/src/components/ArticlePublishStatus.vue index ef6c20e..310b12f 100644 --- a/apps/admin-web/src/components/ArticlePublishStatus.vue +++ b/apps/admin-web/src/components/ArticlePublishStatus.vue @@ -1,11 +1,11 @@ @@ -533,50 +608,73 @@ function showPublishFailures(result: PublisherPublishResponse): void { .publish-modal { display: flex; flex-direction: column; - gap: 24px; + gap: 20px; } .publish-modal__hero { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 20px; - border: 1px solid #e5e5e5; - border-radius: 12px; - background: #f7f7f7; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + padding: 18px 20px; + border: 1px solid #dce6f4; + border-radius: 16px; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 32%), + linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); } -.publish-modal__hero-top { - display: flex; - align-items: center; - gap: 16px; - justify-content: space-between; - margin-bottom: 12px; +.publish-modal__eyebrow { + display: inline-block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + color: #1d4ed8; + letter-spacing: 0.08em; + text-transform: uppercase; } .publish-modal__hero h3 { margin: 0; - font-size: 16px; - font-weight: 600; - color: #262626; - line-height: 1.5; + color: #0f172a; + font-size: 18px; + font-weight: 700; + line-height: 1.45; } -.eyebrow { - font-size: 16px; - text-transform: uppercase; - color: #000000; - margin: 0; +.publish-modal__hero p { + margin: 10px 0 0; + color: #526072; + font-size: 13px; + line-height: 1.7; } -.publish-modal__hero-actions { - display: flex; - align-items: center; - gap: 10px; +.publish-modal__hero-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(88px, 1fr)); + gap: 12px; + min-width: 300px; } -.hero-tag { - margin: 0; +.publish-modal__metric { + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e5edf8; +} + +.publish-modal__metric strong { + display: block; + color: #0f172a; + font-size: 24px; + font-weight: 700; + line-height: 1; +} + +.publish-modal__metric span { + display: block; + margin-top: 8px; + color: #64748b; + font-size: 12px; } .publish-modal__section { @@ -588,156 +686,208 @@ function showPublishFailures(result: PublisherPublishResponse): void { margin-bottom: 12px; } -.publish-modal__section-header.inline-header { +.publish-modal__section-header--inline { display: flex; align-items: center; gap: 12px; - margin-bottom: 8px; } .publish-modal__section-header h3 { - margin: 0 0 6px 0; + margin: 0 0 6px; + color: #0f172a; font-size: 15px; - font-weight: 600; - color: #262626; + font-weight: 700; } -.publish-modal__section-header.inline-header h3 { +.publish-modal__section-header--inline h3 { margin: 0; } .required-star { color: #ff4d4f; margin-right: 4px; - font-family: SimSun, sans-serif; } .muted { - font-size: 13px; - color: #8c8c8c; margin: 0; + color: #64748b; + font-size: 13px; + line-height: 1.7; } -.publish-modal__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin-top: 4px; +.publish-modal__loading { + padding: 8px 0; } -.publish-modal__card { +.publish-modal__list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 380px; + overflow-y: auto; + padding-right: 6px; +} + +/* Custom scrollbar for list */ +.publish-modal__list::-webkit-scrollbar { + width: 6px; +} +.publish-modal__list::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; +} + +.publish-modal__list-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px 16px; - border: 1px solid #d9d9d9; + border: 1px solid #e2e8f0; border-radius: 8px; - background: #fff; + background: #ffffff; text-align: left; cursor: pointer; transition: all 0.2s ease; } -.publish-modal__card:hover:not(.publish-modal__card--disabled) { - border-color: #1677ff; +.publish-modal__list-item:hover:not(.publish-modal__list-item--disabled) { + border-color: #94a3b8; + background: #f8fafc; } -.publish-modal__card--active { - border-color: #1677ff; +.publish-modal__list-item--active { + border-color: #355dff; + background: #f0f4ff; } -.publish-modal__card--disabled { - opacity: 0.6; +.publish-modal__list-item--disabled { cursor: not-allowed; - background-color: #f7f7f7; + opacity: 0.65; + background: #f8fafc; } -.publish-modal__card-left { +.publish-modal__identity { display: flex; align-items: center; gap: 12px; + min-width: 0; + flex: 1; +} + +.publish-modal__meta-group { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; } .publish-modal__check { - width: 16px; - height: 16px; - border: 1px solid #d9d9d9; + width: 18px; + height: 18px; + border: 1px solid #c7d4e5; border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - background: #fff; - transition: all 0.2s; -} - -.publish-modal__card--active .publish-modal__check { - background-color: #1677ff; - border-color: #1677ff; -} - -.publish-modal__check-inner { - width: 8px; - height: 8px; - background-color: transparent; - border: 2px solid #fff; - border-top: 0; - border-left: 0; - transform: rotate(45deg) scale(1) translate(-1px, -1px); - display: block; -} - -.publish-modal__card-badge { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: #f7f7f7; - border: 1px solid #e5e5e5; - font-size: 12px; - font-weight: 700; + background: #fff; + flex-shrink: 0; } -.publish-modal__card-text { +.publish-modal__list-item--active .publish-modal__check { + background: #355dff; + border-color: #355dff; +} + +.publish-modal__check-inner { + width: 7px; + height: 7px; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(45deg) translate(-1px, -1px); +} + +.publish-modal__avatar { + width: 36px; + height: 36px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 14px; + font-weight: 700; + overflow: hidden; + flex-shrink: 0; +} + +.publish-modal__avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.publish-modal__identity-copy { display: flex; flex-direction: column; + min-width: 0; } -.publish-modal__card-name { +.publish-modal__identity-copy strong { + color: #0f172a; font-size: 14px; - font-weight: 500; - color: #262626; + font-weight: 600; line-height: 1.3; } -.publish-modal__card-sub { - font-size: 12px; - font-weight: 400; - color: #8c8c8c; +.publish-modal__identity-copy span { margin-top: 2px; + color: #64748b; + font-size: 12px; + line-height: 1.5; + word-break: break-all; } -.publish-modal__card-status { - font-size: 13px; +.publish-modal__state-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 24px; + padding: 0 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; } -.status-selectable { - color: #52c41a; +.publish-modal__state-pill--immediate { + background: #edfdf2; + color: #15803d; + border: 1px solid #bbf7d0; } -.status-disabled { - color: #1677ff; +.publish-modal__state-pill--queued { + background: #fff7e6; + color: #b45309; + border: 1px solid #fef08a; } -.publish-modal__loading { - padding: 12px 0; +.publish-modal__state-pill--unavailable { + background: #fff1f2; + color: #be123c; + border: 1px solid #fecdd3; +} + +.publish-modal__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; } .publish-modal__cover-body { display: flex; - margin-top: 14px; + margin-top: 12px; } .publish-modal__cover-preview-wrap { @@ -758,28 +908,29 @@ function showPublishFailures(result: PublisherPublishResponse): void { background: radial-gradient(circle at top, rgba(70, 102, 255, 0.08), transparent 45%), #fbfcff; - color: #475467; + color: #687588; + font-size: 13px; + gap: 8px; cursor: pointer; overflow: hidden; + transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.publish-modal__cover-preview:hover { + border-color: #90a7ff; + box-shadow: 0 14px 34px rgba(67, 87, 255, 0.12); + transform: translateY(-1px); } .publish-modal__cover-preview img { + display: block; width: 100%; height: 124px; object-fit: cover; } .publish-modal__cover-plus { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - margin-right: 8px; - border-radius: 999px; - background: #eef4ff; - color: #355dff; - font-size: 28px; + font-size: 24px; line-height: 1; } @@ -787,38 +938,29 @@ function showPublishFailures(result: PublisherPublishResponse): void { position: absolute; top: 8px; right: 8px; - display: flex; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: rgba(20, 30, 50, 0.68); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; - padding: 0; - border: 0; - border-radius: 999px; - background: rgba(255, 255, 255, 0.96); - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.14); - color: #ff4d4f; cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; } .publish-modal__cover-remove:hover { - transform: scale(1.04); - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); - color: #ff7875; + background: rgba(20, 30, 50, 0.82); } -@media (max-width: 860px) { +@media (max-width: 900px) { .publish-modal__hero { - flex-direction: column; + grid-template-columns: minmax(0, 1fr); } - .publish-modal__grid { - grid-template-columns: 1fr; - } - - .publish-modal__cover-body { - display: flex; + .publish-modal__hero-metrics { + min-width: 0; } } diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 615e2b1..d02d39b 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -152,7 +152,7 @@ const enUS = { }, media: { title: "Media Management", - description: "Manage media bindings, local extension state, and one-click publishing from a single control plane.", + description: "Review desktop-managed media bindings, authorization state, and sync results in one place.", }, brands: { title: "Brand Library", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 3d086a3..69e06a1 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -152,7 +152,7 @@ const zhCN = { }, media: { title: "媒体管理", - description: "统一管理媒体账号绑定、本地插件状态和一键发布执行链路。", + description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。", }, brands: { title: "品牌词库", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 7b42c97..59724db 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -15,6 +15,8 @@ import type { CompetitorRequest, CreatePluginSessionRequest, CreatePluginSessionResponse, + CreatePublishJobRequest, + CreatePublishJobResponse, CreatePublishBatchRequest, CreatePublishBatchResponse, GenerateFromRuleRequest, @@ -104,6 +106,7 @@ import type { CreateKolPromptRequest, UpdateKolPackageRequest, UpdateKolPromptRequest, + DesktopAccountInfo, UserInfo, WorkspaceOverview, } from "@geo/shared-types"; @@ -869,7 +872,7 @@ export const brandsApi = { export const mediaApi = { platforms() { - return removedMediaApi("platforms"); + return apiClient.get("/api/tenant/media/platforms"); }, accounts() { return removedMediaApi("accounts"); @@ -888,6 +891,22 @@ export const mediaApi = { }, }; +export const tenantAccountsApi = { + list() { + return apiClient.get("/api/tenant/accounts"); + }, + requestDelete(id: string, options?: { undo?: boolean }) { + const query = options?.undo ? "?undo=true" : ""; + return apiClient.post(`/api/tenant/accounts/${encodeURIComponent(id)}/request-delete${query}`); + }, +}; + +export const publishJobsApi = { + create(payload: CreatePublishJobRequest) { + return apiClient.post("/api/tenant/publish-jobs", payload); + }, +}; + function removedMediaApi(action: string): Promise { return Promise.reject(new ApiClientError({ message: "desktop_media_api_removed", diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index ec526e1..63a159f 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -50,6 +50,10 @@ const errorMessageMap: Record = { brand_not_found: "品牌不存在或已删除", keyword_not_found: "关键词不存在或已删除", publish_cover_required: "已选择百家号,请先上传封面图", + desktop_account_client_missing: "所选账号还没有绑定桌面客户端,暂时无法发布", + desktop_account_not_found: "所选桌面账号不存在,请刷新后重试", + desktop_publish_job_store_unavailable: "发布队列暂时不可用,请稍后重试", + desktop_media_api_removed: "旧版媒体接口已下线,请刷新页面后重试", publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试", publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试", publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试", diff --git a/apps/admin-web/src/lib/publish-platforms.ts b/apps/admin-web/src/lib/publish-platforms.ts index 985ae55..35d0f65 100644 --- a/apps/admin-web/src/lib/publish-platforms.ts +++ b/apps/admin-web/src/lib/publish-platforms.ts @@ -1,4 +1,4 @@ -import type { MediaPlatform, PlatformAccount } from "@geo/shared-types"; +import type { DesktopAccountInfo, MediaPlatform, PlatformAccount } from "@geo/shared-types"; export interface PublishPlatformOption { id: string; @@ -53,10 +53,14 @@ export function normalizePublishPlatformIds(platformIds?: Array | null, ): PublishPlatformOption[] { const boundPlatformIds = new Set( - (accounts ?? []).map((account) => normalizePublishPlatformId(account.platform_id)).filter(Boolean), + (accounts ?? []) + .map((account) => + normalizePublishPlatformId("platform_id" in account ? account.platform_id : account.platform), + ) + .filter(Boolean), ); return platforms.map((platform) => { diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index af1cf5d..0af5d5e 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -10,7 +10,7 @@ import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue"; import CoverPickerModal from "@/components/CoverPickerModal.vue"; import PublishArticleModal from "@/components/PublishArticleModal.vue"; import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue"; -import { articlesApi, mediaApi, resolveApiURL } from "@/lib/api"; +import { articlesApi, mediaApi, resolveApiURL, tenantAccountsApi } from "@/lib/api"; import { coverUploadRequired, deriveCoverFileName, @@ -55,8 +55,8 @@ const platformsQuery = useQuery({ }); const accountsQuery = useQuery({ - queryKey: ["media", "platform-accounts", "article-editor"], - queryFn: () => mediaApi.accounts(), + queryKey: ["tenant", "desktop-accounts", "article-editor"], + queryFn: () => tenantAccountsApi.list(), }); const publishPlatformOptions = computed(() => diff --git a/apps/admin-web/src/views/MediaView.vue b/apps/admin-web/src/views/MediaView.vue index 8fd4c6c..c1c7e49 100644 --- a/apps/admin-web/src/views/MediaView.vue +++ b/apps/admin-web/src/views/MediaView.vue @@ -1,261 +1,286 @@ @@ -462,9 +460,10 @@ function toneClass(tone: CardTone): string { } .media-view__header-title p { - margin: 6px 0 0 0; + margin: 6px 0 0; font-size: 13px; color: #8c8c8c; + line-height: 1.7; } .media-view__header-actions { @@ -472,177 +471,77 @@ function toneClass(tone: CardTone): string { gap: 12px; } -.panel { - background: #ffffff; - border-radius: 12px; - padding: 24px; - border: 1px solid #e6edf5; - display: flex; - flex-direction: column; -} - -.panel-title { - margin: 0 0 16px 0; - font-size: 16px; - font-weight: 700; - color: #1a1a1a; - display: flex; - align-items: center; -} - -.panel-title::before { - content: ""; - display: inline-block; - width: 4px; - height: 14px; - background: #1f5cff; - border-radius: 2px; - margin-right: 8px; -} - -.media-runtime-card { +.overview-strip { display: grid; - grid-template-columns: minmax(0, 1.3fr) minmax(320px, 1fr); - gap: 18px; + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.media-runtime-card__copy { - display: flex; - flex-direction: column; - justify-content: center; +.overview-chip { + padding: 18px 24px; + border-top: 1px solid #eef2f7; + border-right: 1px solid #eef2f7; } -.media-runtime-card__copy h2 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: #1a1a1a; +.overview-chip:last-child { + border-right: none; } -.media-runtime-card__copy p:last-child { - margin: 10px 0 0; - color: #8c8c8c; - font-size: 13px; - line-height: 1.6; -} - -.media-runtime-card__stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.media-runtime-card__stats article { - padding: 16px; - border: 1px solid #e6edf5; - border-radius: 12px; - background: #f7f9fc; -} - -.media-runtime-card__stats span { +.overview-chip span { display: block; color: #8c8c8c; font-size: 12px; } -.media-runtime-card__stats strong { +.overview-chip strong { display: block; margin-top: 8px; color: #1a1a1a; - font-size: 20px; + font-size: 24px; font-weight: 700; } -.media-list-wrapper { - padding: 0; - overflow: hidden; - background: transparent; - border: none; +.panel { + background: #fff; + border-radius: 12px; + padding: 24px; + border: 1px solid #e6edf5; } -.custom-tabs { +.media-list-panel { display: flex; - background: #fff; - border-bottom: 1px solid #f0f0f0; - padding: 0 24px; - border-radius: 12px 12px 0 0; -} - -.custom-tab { - position: relative; - padding: 16px 24px; - font-size: 14px; - font-weight: 500; - color: #595959; - cursor: pointer; - transition: all 0.2s; -} - -.custom-tab.active { - color: #1677ff; - font-weight: 600; -} - -.custom-tab.active::after { - content: ""; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: #1677ff; -} - -.custom-tab-badge { - position: absolute; - top: 4px; - right: -8px; - background: #f5f5f5; - color: f3f#737373; - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - line-height: 1; - transform: scale(0.9); -} - -.media-list-content { - padding: 28px; - background: #fff; - -} - -.media-list-desc { - font-size: 13px; - color: #8c8c8c; - margin-bottom: 20px; + flex-direction: column; + gap: 20px; } .media-list-toolbar { display: flex; justify-content: space-between; + gap: 16px; align-items: center; - margin-bottom: 24px; } .media-search { width: 320px; - border-radius: 8px; -} -.media-search :deep(.ant-input) { - font-size: 13px; } -.media-filter { - background: #fff; - border-radius: 6px; +.media-list-header { + display: flex; + flex-direction: column; + gap: 6px; } -.media-list-title { - font-size: 15px; +.panel-title { + margin: 0; + font-size: 16px; font-weight: 700; color: #1a1a1a; - margin-bottom: 16px; +} + +.panel-desc { + margin: 0; + color: #8c8c8c; + font-size: 13px; + line-height: 1.7; } .media-grid { @@ -652,30 +551,15 @@ function toneClass(tone: CardTone): string { } .media-card { - position: relative; - overflow: hidden; - padding: 20px; - border: 1px solid #ccd3da; - border-radius: 12px; - background: #fff; - transition: all 0.2s; + border: 1px solid #e6edf5; + border-radius: 16px; + padding: 18px; + background: + radial-gradient(circle at top right, rgba(31, 92, 255, 0.06), transparent 28%), + linear-gradient(180deg, #ffffff 0%, #fbfcff 100%); display: flex; flex-direction: column; -} - -.media-card--default{ - background: hsl(0, 0%, 100%); -} - -.media-card:hover { - border-color: #1677ff; - box-shadow: 0 4px 12px rgba(22, 119, 255, 0.1); -} - -.media-card--success, -.media-card--warning, -.media-card--error { - background: #ffffff; + gap: 18px; } .media-card__head { @@ -689,145 +573,137 @@ function toneClass(tone: CardTone): string { display: flex; align-items: center; gap: 12px; + min-width: 0; } -.media-card__badge { +.media-card__avatar { + width: 44px; + height: 44px; + border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border-radius: 8px; - background: #fafafa; - border: 1px solid #f0f0f0; - font-size: 16px; + color: #fff; + font-size: 18px; font-weight: 700; + overflow: hidden; + flex-shrink: 0; } -.media-card__identity-text { - display: flex; - flex-direction: column; +.media-card__avatar img { + width: 100%; + height: 100%; + object-fit: cover; } -.media-card__identity-text h3 { +.media-card__identity-copy { + min-width: 0; +} + +.media-card__identity-copy h3 { margin: 0; color: #1a1a1a; - font-size: 14px; - font-weight: 700; -} - -.media-card__identity-text p { - margin: 2px 0 0; - color: #737373; - font-size: 11px; -} - -.more-icon { - color: #737373; - font-size: 18px; - cursor: pointer; - padding: 0 4px; - line-height: 1; -} -.more-icon:hover { - color: #8c8c8c; -} - -.media-card__body { - margin-top: 16px; - flex: 1; -} - -.media-card__account-name { font-size: 15px; - font-weight: 600; - color: #1a1a1a; - margin-bottom: 2px; + font-weight: 700; + line-height: 1.3; } -.media-card__account-id { - font-size: 12px; +.media-card__identity-copy p { + margin: 4px 0 0; color: #8c8c8c; + font-size: 12px; } -.media-card__account-unbound { +.media-card__platform-badge { + width: 36px; + height: 36px; + border-radius: 12px; + background: #f7f9fc; + border: 1px solid #e6edf5; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; + flex-shrink: 0; + overflow: hidden; +} + +.media-card__platform-badge img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.media-card__meta { + display: flex; + flex-direction: column; + gap: 10px; +} + +.meta-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.meta-label { + color: #8c8c8c; + font-size: 12px; + flex-shrink: 0; +} + +.meta-value { + color: #1a1a1a; font-size: 13px; - color: #737373; + font-weight: 500; + text-align: right; + word-break: break-all; } .media-card__footer { - margin-top: 20px; - display: flex; - align-items: center; - justify-content: space-between; - border-top: 1px solid #f0f0f0; - padding-top: 12px; + padding-top: 4px; } -.media-card__status { +.footer-badge { display: inline-flex; align-items: center; - gap: 6px; - font-size: 12px; - color: #8c8c8c; -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; -} -.status-dot.success { background-color: #52c41a; } -.status-dot.default { background-color: #d9d9d9; } -.status-dot.warning, .status-dot.error { background-color: #faad14; } - -.media-card__actions { - display: flex; - gap: 8px; -} - -.action-btn-bind { + min-height: 28px; + padding: 0 10px; border-radius: 999px; + background: #f3f6fb; + color: #516074; font-size: 12px; - color: #595959; - border-color: #d9d9d9; - height: 28px; - padding: 0 12px; - display: flex; - align-items: center; - gap: 4px; -} -.action-btn-bind:hover { - color: #1677ff; - border-color: #1677ff; + font-weight: 600; + line-height: 1.6; } -.action-btn-publish { - border-radius: 999px; - font-size: 12px; - background: #e6f4ff; - color: #1677ff; - border: none; - height: 28px; - padding: 0 12px; - display: flex; - align-items: center; - gap: 4px; -} -.action-btn-publish:hover { - background: #bae0ff; +.footer-badge--warning { + background: #fff7e6; + color: #d48806; } -@media (max-width: 1080px) { - .media-runtime-card, - .media-grid { - grid-template-columns: 1fr; +@media (max-width: 900px) { + .overview-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); } -} -@media (max-width: 720px) { - .media-runtime-card__stats { - grid-template-columns: 1fr; + .overview-chip:nth-child(2) { + border-right: none; + } + + .overview-chip:nth-child(n + 3) { + border-top: 1px solid #eef2f7; + } + + .media-list-toolbar { + flex-direction: column; + align-items: stretch; + } + + .media-search { + width: 100%; } } diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index d605e09..f95e733 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -9,6 +9,7 @@ "zip": "wxt zip" }, "dependencies": { + "@geo/publisher-platforms": "workspace:*", "@geo/shared-types": "workspace:*", "js-md5": "^0.8.3", "marked": "^17.0.5", diff --git a/apps/browser-extension/src/adapters/toutiaohao.ts b/apps/browser-extension/src/adapters/toutiaohao.ts index 4c97832..8556cd8 100644 --- a/apps/browser-extension/src/adapters/toutiaohao.ts +++ b/apps/browser-extension/src/adapters/toutiaohao.ts @@ -1,3 +1,8 @@ +import { + fetchToutiaoMediaSnapshot, + publishToutiaoArticle, +} from "../../../../packages/publisher-platforms/src/toutiao"; + import type { StoredPlatformState } from "../platforms"; import { @@ -9,236 +14,46 @@ import { normalizeArticleHtml, uploadHtmlImages, } from "./common"; -import type { AdapterContext, PlatformAdapter, PlatformPublishResult } from "./types"; - -type ToutiaoMediaInfoResponse = { - data?: { - user?: { - id_str?: string; - screen_name?: string; - https_avatar_url?: string; - }; - media?: { - has_third_party_ad_permission?: boolean; - has_toutiao_ad_permission?: boolean; - }; - }; - code?: number; - message?: string; -}; - -type ToutiaoUploadPictureResponse = { - url?: string; - web_uri?: string; - origin_web_uri?: string; - rigin_web_uri?: string; - width?: number; - height?: number; -}; - -type ToutiaoSpiceImageResponse = { - data?: { - image_url?: string; - image_uri?: string; - image_width?: number; - image_height?: number; - }; -}; - -type ToutiaoPublishResponse = { - code?: number; - message?: string; - data?: { - pgc_id?: string | number; - }; -}; - -async function fetchMediaInfo(): Promise { - const response = await fetchJson("https://mp.toutiao.com/mp/agw/media/get_media_info", { - headers: { - accept: "application/json, text/plain, */*", - }, - }).catch(() => null); - return response?.data ?? null; -} +import type { AdapterContext, PlatformAdapter } from "./types"; async function detectToutiaohao(platform: StoredPlatformState): Promise { try { - const data = await fetchMediaInfo(); - const user = data?.user; - if (!user?.id_str || !user.screen_name) { + const media = await fetchToutiaoMediaSnapshot(fetchJson); + if (!media?.platformUid || !media.screenName) { return disconnectedPlatform(platform, "未检测到头条号登录态"); } - return connectedPlatform(platform, user.id_str, user.screen_name, user.https_avatar_url ?? null); + return connectedPlatform(platform, media.platformUid, media.screenName, media.avatarUrl); } catch (error) { return disconnectedPlatform(platform, error instanceof Error ? error.message : "头条号登录检测失败"); } } -async function uploadCover(sourceUrl: string, publishType: "publish" | "draft") { - const blob = await fetchImageBlob(sourceUrl); - if (!blob) { - return null; - } - - if (publishType === "publish") { - const form = new FormData(); - form.append("upfile", blob, "cover.png"); - const uploaded = await fetchJson( - "https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture", - { - method: "POST", - body: form, - }, - ).catch(() => null); - - if (!uploaded?.url || !uploaded.web_uri) { - return null; - } - - return { - id: 0, - url: uploaded.url, - uri: uploaded.web_uri, - origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "", - thumb_width: uploaded.width ?? 0, - thumb_height: uploaded.height ?? 0, - }; - } - - const form = new FormData(); - form.append("image", blob, "cover.png"); - const first = await fetchJson("https://mp.toutiao.com/spice/image?device_platform=web", { - method: "POST", - body: form, - }).catch(() => null); - - const imageUrl = first?.data?.image_url; - if (!imageUrl) { - return null; - } - - const second = new FormData(); - second.append("imageUrl", imageUrl); - const final = await fetchJson( - "https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1", +async function publishToutiaohao(context: AdapterContext) { + const result = await publishToutiaoArticle( { - method: "POST", - body: second, + title: context.article.title, + html: normalizeArticleHtml(context.article), + coverAssetUrl: context.article.cover_asset_url, + publishType: context.article.publish_type, }, - ).catch(() => null); - - if (!final?.data?.image_url || !first.data?.image_uri) { - return null; - } - - return { - id: "", - url: final.data.image_url, - uri: first.data.image_uri, - ic_uri: "", - thumb_width: first.data.image_width ?? 0, - thumb_height: first.data.image_height ?? 0, - extra: { - from_content_uri: "", - from_content: "0", - }, - }; -} - -async function uploadContentImage(sourceUrl: string): Promise { - const blob = await fetchImageBlob(sourceUrl); - if (!blob) { - return null; - } - - const form = new FormData(); - form.append("image", blob, "image.png"); - const uploaded = await fetchJson("https://mp.toutiao.com/spice/image?device_platform=web", { - method: "POST", - body: form, - }).catch(() => null); - - return uploaded?.data?.image_url ?? null; -} - -async function publishToutiaohao(context: AdapterContext): Promise { - const mediaInfo = await fetchMediaInfo(); - const user = mediaInfo?.user; - if (!user?.id_str || !user.screen_name) { - throw new Error("toutiaohao_not_logged_in"); - } - - const content = normalizeArticleHtml(context.article); - const processed = await uploadHtmlImages(content, uploadContentImage); - - const cover = context.article.cover_asset_url?.trim() - ? await uploadCover(context.article.cover_asset_url.trim(), context.article.publish_type) - : null; - - const hasAdPermission = Boolean( - mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission, - ); - - const body = new URLSearchParams({ - content: processed.html, - title: context.article.title, - mp_editor_stat: JSON.stringify({ code_block: 1 }), - is_refute_rumor: "0", - save: context.article.publish_type === "publish" ? "1" : "0", - timer_status: "0", - draft_form_data: JSON.stringify({ coverType: 1 }), - article_ad_type: hasAdPermission ? "3" : "2", - pgc_feed_covers: JSON.stringify(cover ? [cover] : []), - is_fans_article: "0", - govern_forward: "0", - praise: "0", - disable_praise: "0", - tree_plan_article: "0", - activity_tag: "0", - trends_writing_tag: "0", - claim_exclusive: "0", - info_source: JSON.stringify({ - source_type: 5, - source_author_uid: "", - time_format: "", - position: {}, - }), - }); - - const response = await fetchJson( - "https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body, + fetchJson, + fetchImageBlob, + uploadHtmlImages, }, ); - const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : ""; - if (response.code !== 0 || !articleId) { - throw new Error(response.message || "toutiaohao_publish_failed"); - } - - if (context.article.publish_type === "draft") { - return manageUrlResult( - true, - "pending_review", - articleId, - "https://mp.toutiao.com/profile_v4/graphic/publish", - "头条号草稿保存成功。", - ); + if (!result.success) { + throw new Error(result.message); } return manageUrlResult( true, - "success", - articleId, - "https://mp.toutiao.com/profile_v4/index", - "头条号发布成功。", - `https://www.toutiao.com/article/${articleId}/`, + result.status, + result.articleId, + result.externalManageUrl, + result.message, + result.externalArticleUrl, ); } diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index 91d762c..111a206 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "out/main/bootstrap.cjs", "scripts": { - "dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev", + "dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev --inspect=9229", "build": "electron-vite build", "package:mac": "electron-builder --mac --arm64 ", "test": "vitest run", @@ -14,11 +14,13 @@ "dependencies": { "@ant-design/icons-vue": "^7.0.1", "@geo/http-client": "workspace:*", + "@geo/publisher-platforms": "workspace:*", "@geo/shared-types": "workspace:*", "@geo/ui-shared": "workspace:*", "ant-design-vue": "^4.2.6", "better-sqlite3": "^12.8.0", "electron-updater": "^6.0.0", + "marked": "^17.0.5", "pino": "^9.0.0", "vue": "^3.5.31", "vue-router": "^4.5.1" diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index f391f71..dd87b30 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -32,6 +32,10 @@ import { } from "./external-window"; import { upsertDesktopAccount } from "./transport/api-client"; import { STANDARD_USER_AGENT } from "./user-agent"; +import { + normalizeAccountPlatformUid, + sameAccountPlatformUid, +} from "../shared/account-identity"; interface DetectedAccount { platformUid: string; @@ -65,6 +69,11 @@ export interface PublishAccountProfile { avatarUrl: string | null; } +export interface PublishAccountLocalInspection { + availability: "missing" | "ready" | "expired"; + profile: PublishAccountProfile | null; +} + type ToutiaoMediaInfoResponse = { data?: { user?: { @@ -648,7 +657,11 @@ async function deriveToutiaoFallbackUid(session: Session, displayName: string): function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount { const displayName = normalizeText(account.displayName) ?? "未命名账号"; - const platformUid = boundedIdentity("platform", account.platformUid || displayName); + const normalizedPlatformUid = normalizeAccountPlatformUid(account.platformUid); + const platformUid = + normalizedPlatformUid + || normalizeAccountPlatformUid(boundedIdentity("platform", displayName)) + || displayName; return { platformUid, @@ -772,7 +785,7 @@ async function recoverToutiaoSessionHandle(account: PublishAccountIdentity): Pro continue; } - if (expectedUid && detected.platformUid === expectedUid) { + if (expectedUid && sameAccountPlatformUid(detected.platformUid, expectedUid)) { console.info("[desktop-session] recovered toutiao partition by uid", { accountId: account.id, partition, @@ -805,6 +818,13 @@ async function recoverToutiaoSessionHandle(account: PublishAccountIdentity): Pro export async function ensurePublishAccountSessionHandle( account: PublishAccountIdentity, ): Promise { + const existing = await findLocalPublishAccountSessionHandle(account); + return existing ?? createSessionHandle(account.id); +} + +async function findLocalPublishAccountSessionHandle( + account: PublishAccountIdentity, +): Promise { const existing = getSessionHandle(account.id); if (existing) { return existing; @@ -822,19 +842,123 @@ export async function ensurePublishAccountSessionHandle( } } - return createSessionHandle(account.id); + return null; +} + +function toPublishAccountProfile(detected: DetectedAccount | null): PublishAccountProfile | null { + if (!detected) { + return null; + } + + const normalized = sanitizeDetectedAccount(detected); + return { + platformUid: normalized.platformUid, + displayName: normalized.displayName, + avatarUrl: normalized.avatarUrl, + }; +} + +async function resolvePublishAccountProfileFromHandle( + account: PublishAccountIdentity, + handle: SessionHandle, +): Promise { + if (account.platform === "toutiaohao") { + return await detectToutiaoFromSession(handle.session); + } + + const definition = publishBindingDefinitions[account.platform]; + if (!definition) { + return null; + } + + const detected = await definition.detect({ + session: handle.session, + webContents: undefined as unknown as WebContents, + }).catch(() => null); + + return toPublishAccountProfile(detected); +} + +export async function inspectPublishAccountLocalSession( + account: PublishAccountIdentity, +): Promise { + const handle = await findLocalPublishAccountSessionHandle(account); + if (!handle) { + return { + availability: "missing", + profile: null, + }; + } + + const profile = await resolvePublishAccountProfileFromHandle(account, handle); + if (!profile) { + const consoleReady = await verifyPublishAccountConsoleAccess(account, handle).catch(() => false); + if (consoleReady) { + return { + availability: "ready", + profile: { + platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid, + displayName: account.displayName, + avatarUrl: null, + }, + }; + } + + if (account.platform === "toutiaohao" && await hasToutiaoAuthCookies(handle.session)) { + return { + availability: "ready", + profile: { + platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid, + displayName: account.displayName, + avatarUrl: null, + }, + }; + } + + return { + availability: "expired", + profile: null, + }; + } + + return { + availability: "ready", + profile, + }; } export async function resolvePublishAccountProfile( account: PublishAccountIdentity, ): Promise { const handle = await ensurePublishAccountSessionHandle(account); + return await resolvePublishAccountProfileFromHandle(account, handle); +} - if (account.platform === "toutiaohao") { - return await detectToutiaoFromSession(handle.session); +async function finalizeDetectedAccountForBind( + definition: PublishPlatformBindingDefinition, + context: DetectContext, + detected: DetectedAccount, +): Promise { + if (detected.avatarUrl) { + return detected; } - return null; + let resolved = detected; + for (let attempt = 0; attempt < 3; attempt += 1) { + await sleep(400 + attempt * 250); + const retried = await definition.detect(context).catch(() => null); + if (!retried) { + continue; + } + + const sanitized = sanitizeDetectedAccount(retried); + resolved = sanitized; + if (sanitized.avatarUrl) { + break; + } + } + + return resolved; } function isToutiaoLoginURL(url: string): boolean { @@ -908,6 +1032,69 @@ async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Sessio return extractToutiaoAccount(sessionResponse) !== null; } +function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { + if (!currentURL || !loginURL) { + return false; + } + + if (currentURL.startsWith(loginURL)) { + return true; + } + + try { + const current = new URL(currentURL); + const login = new URL(loginURL); + if (current.origin !== login.origin) { + return false; + } + + const currentPath = current.pathname.replace(/\/+$/, ""); + const loginPath = login.pathname.replace(/\/+$/, ""); + return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`); + } catch { + return false; + } +} + +async function verifyPublishAccountConsoleAccess( + account: PublishAccountIdentity, + handle: SessionHandle, +): Promise { + const definition = publishBindingDefinitions[account.platform]; + if (!definition) { + return false; + } + + const window = createBoundWindow( + `${definition.label} 静默校验`, + definition.consoleUrl, + handle.session, + { show: false }, + ); + + try { + if (account.platform === "toutiaohao") { + return await verifyToutiaoConsoleAccess(window, handle.session); + } + + await waitForWindowNavigationSettled(window); + if (window.isDestroyed()) { + return false; + } + + const currentURL = window.webContents.getURL(); + if (!currentURL || currentURL.startsWith("data:text/html")) { + return false; + } + + return !looksLikeLoginRedirect(currentURL, definition.loginUrl); + } finally { + if (!window.isDestroyed()) { + window.destroy(); + } + } +} + async function detectBaijiahao({ session }: DetectContext): Promise { const response = await sessionFetchJson( session, @@ -1274,8 +1461,14 @@ const publishBindingDefinitions: Record; error?: Record; - reviewUrl?: string | null; } export interface PublishAdapter { platform: string; + executionMode?: AdapterExecutionMode; publish(context: PublishAdapterContext, payload: Record): Promise; } export interface MonitorAdapter { provider: string; + executionMode?: AdapterExecutionMode; query(context: AdapterContext, payload: Record): Promise; } diff --git a/apps/desktop-client/src/main/adapters/common.ts b/apps/desktop-client/src/main/adapters/common.ts index e0de487..5570225 100644 --- a/apps/desktop-client/src/main/adapters/common.ts +++ b/apps/desktop-client/src/main/adapters/common.ts @@ -1,5 +1,6 @@ import type { DesktopArticleContent } from "@geo/shared-types"; import type { Session, WebContents, WebContentsView } from "electron/main"; +import { marked } from "marked"; import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent"; @@ -32,7 +33,8 @@ export function normalizeRemoteUrl(value?: string | null): string | null { } export function normalizeArticleHtml(article: DesktopArticleContent): string { - let next = article.html_content?.trim() ?? ""; + const source = article.html_content?.trim() || markdownToHtml(article.markdown_content ?? ""); + let next = source.trim(); next = next.replace(//gi, ""); next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ""); next = next.replace(/\sstyle="[^"]*"/gi, ""); @@ -41,6 +43,18 @@ export function normalizeArticleHtml(article: DesktopArticleContent): string { return next.trim(); } +function markdownToHtml(markdown: string): string { + const source = markdown.trim(); + if (!source) { + return ""; + } + return marked.parse(source, { + async: false, + gfm: true, + breaks: false, + }) as string; +} + export function extractImageSources(html: string): string[] { const sources = new Set(); for (const match of html.matchAll(/]*src=(['"])(.*?)\1[^>]*>/gi)) { diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index 127ac79..44885b9 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -597,11 +597,16 @@ function parseRuntimeState(value: unknown): DoubaoRuntimeState | null { } async function loadDoubaoRuntimeState(context: Parameters[0]): Promise { + if (!context.view) { + throw new Error("doubao_view_required"); + } + const view = context.view; + context.reportProgress("doubao.bootstrap_view"); - await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal); + await ensureViewLoaded(view, DOUBAO_REFERER, context.signal); context.reportProgress("doubao.read_runtime_state"); - const state = await context.view.webContents.executeJavaScript( + const state = await view.webContents.executeJavaScript( `(() => { try { const safeParse = (value) => { @@ -711,7 +716,19 @@ function buildAdapterError( export const doubaoAdapter: MonitorAdapter = { provider: "doubao", + executionMode: "view", async query(context, payload) { + if (!context.view) { + return { + status: "failed", + summary: "豆包监测缺少页面执行上下文。", + error: buildAdapterError( + "doubao_view_required", + "doubao_view_required", + ), + }; + } + const questionText = extractQuestionText(payload); const runtimeState = await loadDoubaoRuntimeState(context); const requestBody = buildDoubaoRequestBody(questionText, runtimeState); diff --git a/apps/desktop-client/src/main/adapters/toutiao.ts b/apps/desktop-client/src/main/adapters/toutiao.ts index a0de742..cae7bc4 100644 --- a/apps/desktop-client/src/main/adapters/toutiao.ts +++ b/apps/desktop-client/src/main/adapters/toutiao.ts @@ -1,3 +1,6 @@ +import { + publishToutiaoArticle, +} from "../../../../../packages/publisher-platforms/src/toutiao"; import type { JsonValue } from "@geo/shared-types"; import { @@ -8,289 +11,75 @@ import { } from "./common"; import type { PublishAdapter } from "./base"; -type ToutiaoMediaInfoResponse = { - data?: { - user?: { - id_str?: string; - screen_name?: string; - https_avatar_url?: string; - }; - media?: { - has_third_party_ad_permission?: boolean; - has_toutiao_ad_permission?: boolean; - }; - }; - code?: number; - message?: string; -}; - -type ToutiaoUploadPictureResponse = { - url?: string; - web_uri?: string; - origin_web_uri?: string; - rigin_web_uri?: string; - width?: number; - height?: number; -}; - -type ToutiaoSpiceImageResponse = { - data?: { - image_url?: string; - image_uri?: string; - image_width?: number; - image_height?: number; - }; -}; - -type ToutiaoPublishResponse = { - code?: number; - message?: string; - data?: { - pgc_id?: string | number; - }; -}; - -async function fetchMediaInfo(context: Parameters[0]) { - const response = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/mp/agw/media/get_media_info", - { - headers: { - accept: "application/json, text/plain, */*", - }, - }, - ).catch(() => null); - - return response?.data ?? null; -} - -async function uploadCover( - context: Parameters[0], - sourceUrl: string, -) { - const blob = await fetchImageBlob(sourceUrl); - if (!blob) { - return null; - } - - if (context.article.publish_type === "publish") { - const form = new FormData(); - form.append("upfile", blob, "cover.png"); - const uploaded = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture", - { - method: "POST", - body: form, - }, - ).catch(() => null); - - if (!uploaded?.url || !uploaded.web_uri) { - return null; - } - - return { - id: 0, - url: uploaded.url, - uri: uploaded.web_uri, - origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "", - thumb_width: uploaded.width ?? 0, - thumb_height: uploaded.height ?? 0, - }; - } - - const form = new FormData(); - form.append("image", blob, "cover.png"); - const first = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/spice/image?device_platform=web", - { - method: "POST", - body: form, - }, - ).catch(() => null); - - const imageUrl = first?.data?.image_url; - if (!imageUrl) { - return null; - } - - const second = new FormData(); - second.append("imageUrl", imageUrl); - const final = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1", - { - method: "POST", - body: second, - }, - ).catch(() => null); - - if (!final?.data?.image_url || !first.data?.image_uri) { - return null; - } - - return { - id: "", - url: final.data.image_url, - uri: first.data.image_uri, - ic_uri: "", - thumb_width: first.data.image_width ?? 0, - thumb_height: first.data.image_height ?? 0, - extra: { - from_content_uri: "", - from_content: "0", - }, - }; -} - -async function uploadContentImage( - context: Parameters[0], - sourceUrl: string, -): Promise { - const blob = await fetchImageBlob(sourceUrl); - if (!blob) { - return null; - } - - const form = new FormData(); - form.append("image", blob, "image.png"); - const uploaded = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/spice/image?device_platform=web", - { - method: "POST", - body: form, - }, - ).catch(() => null); - - return uploaded?.data?.image_url ?? null; -} - function buildResultPayload( articleId: string, - publishType: "publish" | "draft", mediaName: string, + externalManageUrl: string, + externalArticleUrl: string | null, ): Record { - const manageURL = - publishType === "draft" - ? "https://mp.toutiao.com/profile_v4/graphic/publish" - : "https://mp.toutiao.com/profile_v4/index"; - return { platform: "toutiaohao", media_name: mediaName, external_article_id: articleId, - external_manage_url: manageURL, - external_article_url: - publishType === "publish" ? `https://www.toutiao.com/article/${articleId}/` : null, - publish_type: publishType, + external_manage_url: externalManageUrl, + external_article_url: externalArticleUrl, + publish_type: "publish", }; } export const toutiaoAdapter: PublishAdapter = { platform: "toutiaohao", + executionMode: "session", async publish(context) { - context.reportProgress("toutiao.media_info"); - const mediaInfo = await fetchMediaInfo(context); - const user = mediaInfo?.user; - if (!user?.id_str || !user.screen_name) { - return { - status: "failed", - summary: "头条号登录态失效,无法执行发布。", - error: { - code: "toutiaohao_not_logged_in", - message: "未检测到头条号登录态", - }, - }; - } - context.reportProgress("toutiao.normalize_html"); const html = normalizeArticleHtml(context.article); - if (!html) { - return { - status: "failed", - summary: "文章内容为空,无法推送到头条号。", - error: { - code: "article_content_empty", - message: "html_content is empty", - }, - }; - } - context.reportProgress("toutiao.upload_content_images"); - const processed = await uploadHtmlImages(html, async (sourceUrl) => - uploadContentImage(context, sourceUrl), - ); - - context.reportProgress("toutiao.upload_cover"); - const cover = context.article.cover_asset_url?.trim() - ? await uploadCover(context, context.article.cover_asset_url.trim()) - : null; - - const hasAdPermission = Boolean( - mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission, - ); - - const body = new URLSearchParams({ - content: processed.html, - title: context.article.title, - mp_editor_stat: JSON.stringify({ code_block: 1 }), - is_refute_rumor: "0", - save: context.article.publish_type === "publish" ? "1" : "0", - timer_status: "0", - draft_form_data: JSON.stringify({ coverType: 1 }), - article_ad_type: hasAdPermission ? "3" : "2", - pgc_feed_covers: JSON.stringify(cover ? [cover] : []), - is_fans_article: "0", - govern_forward: "0", - praise: "0", - disable_praise: "0", - tree_plan_article: "0", - activity_tag: "0", - trends_writing_tag: "0", - claim_exclusive: "0", - info_source: JSON.stringify({ - source_type: 5, - source_author_uid: "", - time_format: "", - position: {}, - }), - }); - - context.reportProgress("toutiao.submit"); - const response = await sessionFetchJson( - context.session, - "https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231", + const result = await publishToutiaoArticle( { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - referer: "https://mp.toutiao.com/profile_v4/graphic/publish", + title: context.article.title, + html, + coverAssetUrl: context.article.cover_asset_url, + publishType: "publish", + }, + { + fetchJson: (input, init) => sessionFetchJson(context.session, input, init), + fetchImageBlob, + uploadHtmlImages, + reportProgress(stage) { + context.reportProgress(`toutiao.${stage}`); }, - body, }, ); - const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : ""; - if (response.code !== 0 || !articleId) { + if (!result.success) { + const summary = + result.code === "toutiaohao_not_logged_in" + ? "头条号登录态失效,无法执行发布。" + : result.code === "article_content_empty" + ? "文章内容为空,无法推送到头条号。" + : result.message || "头条号发布失败。"; + return { status: "failed", - summary: response.message || "头条号发布失败。", + summary, error: { - code: "toutiaohao_publish_failed", - message: response.message || "toutiaohao_publish_failed", + code: result.code, + message: result.message, }, }; } - const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name); - const draft = context.article.publish_type === "draft"; + const payload = buildResultPayload( + result.articleId, + result.mediaName, + result.externalManageUrl, + result.externalArticleUrl, + ); + return { status: "succeeded", - summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。", payload, - reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null, + summary: "头条号发布成功。", }; }, }; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 0e1b94b..fd631be 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -8,12 +8,18 @@ import type { import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; import { bindPublishAccount, openPublishAccountConsole } from "./account-binder"; -import { openTaskReview, refreshRuntimeAccounts, resolveParkedTask, syncRuntimeSession } from "./runtime-controller"; +import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy"; +import { + refreshRuntimeAccounts, + releaseRuntimeSession, + syncRuntimeSession, + unbindRuntimeAccount, +} from "./runtime-controller"; import { createRuntimeSnapshot } from "./runtime-snapshot"; import { initScheduler } from "./scheduler"; import { initSessionRegistry } from "./session-registry"; import { initSingleInstance } from "./single-instance"; -import { initTransport } from "./transport/api-client"; +import { initTransport, listDesktopPublishTasks, retryDesktopPublishTask } from "./transport/api-client"; import { initTray } from "./tray"; import { STANDARD_USER_AGENT } from "./user-agent"; @@ -41,6 +47,7 @@ process.on("uncaughtException", (error) => { }); let mainWindow: ElectronBrowserWindow | null = null; +let quitReleaseInFlight = false; function rendererURL(): string | null { return process.env.ELECTRON_RENDERER_URL ?? null; @@ -66,6 +73,7 @@ async function mountRendererView(window: ElectronBrowserWindow): Promise { }); view.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + registerRendererDevtoolsProxyTarget(view.webContents); window.contentView.addChildView(view); syncViewBounds(window, view); window.on("resize", () => syncViewBounds(window, view)); @@ -121,6 +129,10 @@ function registerBridgeHandlers(): void { await refreshRuntimeAccounts(); return account; }); + safeHandle("desktop:refresh-runtime-accounts", async () => { + await refreshRuntimeAccounts(); + return null; + }); safeHandle( "desktop:open-publish-account-console", async ( @@ -131,17 +143,19 @@ function registerBridgeHandlers(): void { return null; }, ); - safeHandle("desktop:task-open-review", async (_event, taskId: string) => { - await openTaskReview(taskId); - return null; - }); safeHandle( - "desktop:task-resolve-parked", - async (_event, taskId: string, status: "succeeded" | "failed" | "unknown") => { - await resolveParkedTask(taskId, status); + "desktop:unbind-publish-account", + async (_event, accountId: string, syncVersion: number) => { + await unbindRuntimeAccount(accountId, syncVersion); return null; }, ); + safeHandle("desktop:list-publish-tasks", async (_event, params?: { page?: number; page_size?: number; title?: string }) => { + return listDesktopPublishTasks(params); + }); + safeHandle("desktop:retry-publish-task", async (_event, taskId: string) => { + return retryDesktopPublishTask(taskId); + }); ipcMain.handle( "desktop:runtime-session-sync", (_event, session: DesktopRuntimeSessionSyncRequest | null) => { @@ -149,6 +163,10 @@ function registerBridgeHandlers(): void { return null; }, ); + safeHandle("desktop:runtime-session-release", async (_event, revoke?: boolean) => { + await releaseRuntimeSession({ revoke: Boolean(revoke) }); + return null; + }); } if (!initSingleInstance(() => { @@ -191,3 +209,19 @@ app.on("window-all-closed", () => { app.quit(); } }); + +app.on("before-quit", (event) => { + if (quitReleaseInFlight) { + return; + } + + quitReleaseInFlight = true; + event.preventDefault(); + + void Promise.race([ + releaseRuntimeSession(), + new Promise((resolve) => setTimeout(resolve, 1500)), + ]).finally(() => { + app.quit(); + }); +}); diff --git a/apps/desktop-client/src/main/lease-manager.ts b/apps/desktop-client/src/main/lease-manager.ts index 8ea0f5c..60bb581 100644 --- a/apps/desktop-client/src/main/lease-manager.ts +++ b/apps/desktop-client/src/main/lease-manager.ts @@ -6,7 +6,7 @@ interface LeaseSnapshot { leaseExpiresAt: number | null; lastExtendedAt: number | null; lastReleasedAt: number | null; - lastOutcome: "leased" | "extended" | "parked" | "completed" | "failed" | "cleared" | null; + lastOutcome: "leased" | "extended" | "completed" | "failed" | "cleared" | null; } interface ActiveLeaseState { diff --git a/apps/desktop-client/src/main/renderer-devtools-proxy.ts b/apps/desktop-client/src/main/renderer-devtools-proxy.ts new file mode 100644 index 0000000..94700af --- /dev/null +++ b/apps/desktop-client/src/main/renderer-devtools-proxy.ts @@ -0,0 +1,74 @@ +import type { WebContents } from "electron/main"; + +interface RendererProxyRequest { + url: string; + method: string; + headers?: Record; + body?: string; +} + +interface RendererProxyResponse { + ok: boolean; + status: number; + statusText: string; + headers: Record; + bodyText: string; + error?: string; +} + +let rendererWebContents: WebContents | null = null; + +export function registerRendererDevtoolsProxyTarget(webContents: WebContents): void { + rendererWebContents = webContents; + webContents.once("destroyed", () => { + if (rendererWebContents === webContents) { + rendererWebContents = null; + } + }); +} + +export function canUseRendererDevtoolsProxy(): boolean { + return Boolean(process.env.ELECTRON_RENDERER_URL && rendererWebContents && !rendererWebContents.isDestroyed()); +} + +export async function rendererDevtoolsFetch( + request: RendererProxyRequest, +): Promise { + if (!rendererWebContents || rendererWebContents.isDestroyed()) { + throw new Error("renderer_devtools_proxy_unavailable"); + } + + const script = `(async () => { + try { + const response = await fetch(${JSON.stringify(request.url)}, { + method: ${JSON.stringify(request.method)}, + headers: ${JSON.stringify(request.headers ?? {})}, + body: ${JSON.stringify(request.body ?? null)}, + }); + const bodyText = await response.text(); + return JSON.stringify({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + bodyText, + }); + } catch (error) { + return JSON.stringify({ + ok: false, + status: 0, + statusText: "", + headers: {}, + bodyText: "", + error: String(error && (error.message || error)), + }); + } + })()`; + + const serialized = await rendererWebContents.executeJavaScript(script, true); + if (typeof serialized !== "string" || !serialized) { + throw new Error("renderer_devtools_proxy_empty_response"); + } + + return JSON.parse(serialized) as RendererProxyResponse; +} diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index cb8076e..e31e64f 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -17,7 +17,16 @@ import { type MonitorAdapter, type PublishAdapter, } from "./adapters"; -import { ensurePublishAccountSessionHandle, resolvePublishAccountProfile, type PublishAccountProfile } from "./account-binder"; +import { + ensurePublishAccountSessionHandle, + inspectPublishAccountLocalSession, + type PublishAccountProfile, +} from "./account-binder"; +import { + buildAccountIdentityKey, + normalizeAccountPlatformUid, + sameAccountPlatformUid, +} from "../shared/account-identity"; import { acquireHotView } from "./view-pool"; import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager"; import { @@ -34,11 +43,11 @@ import { setSchedulerPhase, setSchedulerQueueDepth, } from "./scheduler"; -import { createSessionHandle, getSessionHandle } from "./session-registry"; -import { openReviewWindow } from "./review-window"; +import { clearSessionHandle, createSessionHandle, getSessionHandle } from "./session-registry"; import { completeDesktopTask, configureTransport, + deleteDesktopAccount, extendDesktopTask, getDesktopArticleContent, heartbeatDesktopClient, @@ -46,9 +55,11 @@ import { listDesktopAccounts, noteTransportHeartbeat, noteTransportPull, - parkDesktopTask, + offlineDesktopClient, + revokeDesktopClient, setTransportAuthState, setTransportSseState, + upsertDesktopAccount, } from "./transport/api-client"; import { SseClient, SseClientError } from "./transport/sse-client"; @@ -56,17 +67,14 @@ type RuntimeTone = "info" | "success" | "warn" | "danger"; type RuntimeTaskStatus = | "queued" | "in_progress" - | "waiting_user" | "succeeded" | "failed" | "unknown" | "aborted"; -type RuntimeTaskMode = "auto" | "manual"; type RuntimeTaskRouting = "rabbitmq-primary" | "db-recovery"; const heartbeatIntervalMs = 25_000; const pullIntervalMs = 60_000; -const accountsSyncIntervalMs = 90_000; const leaseExtendIntervalMs = 60_000; const maxActivityItems = 60; @@ -80,7 +88,6 @@ interface RuntimeTaskRecord { accountName: string; clientId: string; status: RuntimeTaskStatus; - mode: RuntimeTaskMode; routing: RuntimeTaskRouting; leaseExpiresAt: number | null; updatedAt: number; @@ -140,7 +147,6 @@ interface RuntimeState { leaseInFlight: boolean; heartbeatTimer: ReturnType | null; pullTimer: ReturnType | null; - accountsTimer: ReturnType | null; sseClient: SseClient | null; lastHeartbeatAt: number; lastHeartbeatStatus: "idle" | "success" | "failed"; @@ -168,7 +174,6 @@ const state: RuntimeState = { leaseInFlight: false, heartbeatTimer: null, pullTimer: null, - accountsTimer: null, sseClient: null, lastHeartbeatAt: 0, lastHeartbeatStatus: "idle", @@ -213,6 +218,33 @@ export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null } } +export async function releaseRuntimeSession(options: { revoke?: boolean } = {}): Promise { + const currentSession = state.session; + const shouldReleaseRemote = canRunLive(currentSession); + + if (shouldReleaseRemote) { + try { + if (options.revoke) { + await revokeDesktopClient(); + } else { + await offlineDesktopClient(); + } + } catch (error) { + console.warn("[desktop-runtime] release runtime session failed", { + revoke: Boolean(options.revoke), + message: errorMessage(error), + }); + } + } + + stopRuntime(); + clearRuntimeState(); + configureTransport(null); + setTransportAuthState("pending"); + state.session = null; + state.client = null; +} + export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot { return { session: state.session ? { ...state.session } : null, @@ -238,103 +270,6 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot { }; } -export async function openTaskReview(taskId: string): Promise { - const task = state.tasks.get(taskId); - if (!task) { - throw new Error("desktop_task_not_found"); - } - if (task.kind !== "publish") { - throw new Error("desktop_task_review_only_for_publish"); - } - - const reviewURL = resolveTaskReviewUrl(task); - if (!reviewURL) { - throw new Error("desktop_task_review_url_missing"); - } - - const sessionHandle = createSessionHandle(task.accountId || task.id); - await openReviewWindow({ - taskId: task.id, - title: `${task.title} · 审核`, - url: reviewURL, - session: sessionHandle.session, - }); - - recordActivity("info", "Review Window Opened", `${task.title} 已打开审核页。`); -} - -export async function resolveParkedTask( - taskId: string, - status: "succeeded" | "failed" | "unknown", -): Promise { - const existing = state.tasks.get(taskId); - if (!existing) { - throw new Error("desktop_task_not_found"); - } - if (existing.status !== "waiting_user") { - throw new Error("desktop_task_not_waiting_user"); - } - if (!canRunLive(state.session)) { - throw new Error("desktop_runtime_not_authenticated"); - } - if (state.currentTaskId && state.currentTaskId !== taskId) { - throw new Error("desktop_runtime_busy"); - } - - state.currentTaskId = taskId; - setSchedulerCurrentTask(taskId); - setSchedulerPhase("running"); - - try { - const leased = await leaseDesktopTask({ task_id: taskId }, { fromParked: true }); - if (!leased.task || !leased.lease_token) { - throw new Error("desktop_task_resume_lease_missing"); - } - - setActiveLease({ - taskId, - attemptId: leased.attempt_id ?? null, - leaseExpiresAt: leased.lease_expires_at ?? leased.task.lease_expires_at ?? null, - }); - - const resumed = upsertTaskFromInfo(leased.task, { - routing: existing.routing, - mode: existing.mode, - attemptId: leased.attempt_id ?? null, - leaseToken: leased.lease_token, - summary: "已从 parked 重新领取租约,准备回写人工审核结果。", - }); - - const resolutionPayload = buildManualResolutionPayload(resumed, status); - const completed = await completeDesktopTask(taskId, { - lease_token: leased.lease_token, - status, - payload: resolutionPayload.payload, - error: resolutionPayload.error, - }); - - noteLeaseReleased(status === "failed" ? "failed" : "completed"); - upsertTaskFromInfo(completed, { - routing: existing.routing, - mode: existing.mode, - summary: resolutionPayload.summary, - attemptId: null, - leaseToken: null, - }); - - recordActivity( - status === "failed" ? "danger" : status === "unknown" ? "warn" : "success", - "Parked Task Resolved", - `${existing.title} 已按人工审核结果回写为 ${status}。`, - ); - } finally { - state.currentTaskId = null; - setSchedulerCurrentTask(null); - noteSchedulerTaskFinished(); - processQueue(); - } -} - function startRuntime(): void { if (!canRunLive(state.session) || state.running) { return; @@ -349,7 +284,6 @@ function startRuntime(): void { connectEventStream(); scheduleHeartbeatLoop(); schedulePullLoop(); - scheduleAccountsSyncLoop(); void sendHeartbeat("startup"); void syncAccounts("startup"); @@ -372,10 +306,6 @@ function stopRuntime(): void { clearInterval(state.pullTimer); state.pullTimer = null; } - if (state.accountsTimer) { - clearInterval(state.accountsTimer); - state.accountsTimer = null; - } if (state.sseClient) { state.sseClient.stop(); state.sseClient = null; @@ -434,16 +364,6 @@ function schedulePullLoop(): void { }, pullIntervalMs); } -function scheduleAccountsSyncLoop(): void { - if (state.accountsTimer) { - clearInterval(state.accountsTimer); - } - - state.accountsTimer = setInterval(() => { - void syncAccounts("timer"); - }, accountsSyncIntervalMs); -} - function connectEventStream(): void { if (!canRunLive(state.session)) { return; @@ -499,7 +419,6 @@ function connectEventStream(): void { sseClient.on("task_available", taskEventHandler); sseClient.on("task_leased", taskEventHandler); sseClient.on("task_extended", taskEventHandler); - sseClient.on("task_parked", taskEventHandler); sseClient.on("task_completed", taskEventHandler); sseClient.on("task_canceled", taskEventHandler); sseClient.on("task_reconciled", taskEventHandler); @@ -541,7 +460,6 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void { accountName: existing?.accountName ?? "待同步账号", clientId: event.target_client_id, status: event.status, - mode: existing?.mode ?? "auto", routing: existing?.routing ?? "rabbitmq-primary", leaseExpiresAt: event.status === "in_progress" ? existing?.leaseExpiresAt ?? null : null, updatedAt: parseTimestamp(event.updated_at) ?? Date.now(), @@ -575,6 +493,7 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise { cpu_arch: state.client?.cpu_arch ?? process.arch, client_version: state.client?.client_version ?? "0.1.0-dev", channel: state.client?.channel ?? "dev", + account_ids: state.accounts.filter((account) => account.health === "live").map((account) => account.id), }); state.client = response.client; @@ -609,52 +528,115 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise { } } -async function syncAccounts(source: "startup" | "timer" | "manual"): Promise { +async function syncAccounts(source: "startup" | "manual"): Promise { if (!canRunLive(state.session)) { return; } try { const accounts = await listDesktopAccounts(); + const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null; state.lastAccountsSyncAt = Date.now(); state.lastError = null; noteSchedulerAccountsSync(); setSchedulerError(null); - if (source === "startup") { - recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`); - } - state.accountProfiles.clear(); - state.accounts = await Promise.all(accounts.map(async (account) => { - await ensurePublishAccountSessionHandle({ + const syncedAccounts: Array = await Promise.all(accounts.map(async (account) => { + const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid; + const identity = { id: account.id, + platform: account.platform, + platformUid: normalizedPlatformUid, + displayName: account.display_name, + } as const; + const local = await inspectPublishAccountLocalSession(identity); + + if (local.availability === "missing") { + return null; + } + + if (local.profile && !sameAccountPlatformUid(local.profile.platformUid, normalizedPlatformUid)) { + return { + ...account, + platform_uid: normalizedPlatformUid, + health: "expired" as const, + }; + } + + let resolvedAccount: DesktopAccountInfo = { + ...account, + platform_uid: normalizedPlatformUid, + health: local.availability === "expired" ? ("expired" as const) : ("live" as const), + }; + + const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId); + if (shouldRelinkClient) { + try { + const updated = await upsertDesktopAccount({ + platform: resolvedAccount.platform, + platform_uid: normalizedPlatformUid, + display_name: local.profile?.displayName ?? resolvedAccount.display_name, + avatar_url: local.profile?.avatarUrl ?? resolvedAccount.avatar_url, + account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid, + health: resolvedAccount.health, + verified_at: new Date().toISOString(), + tags: resolvedAccount.tags, + }); + + resolvedAccount = { + ...updated, + platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid, + health: resolvedAccount.health, + }; + } catch (error) { + console.warn("[desktop-runtime] account relink failed", { + accountId: resolvedAccount.id, + platform: resolvedAccount.platform, + platformUid: resolvedAccount.platform_uid, + message: errorMessage(error), + }); + } + } + + return resolvedAccount; + })); + const duplicateAccounts: DesktopAccountInfo[] = []; + const dedupedAccounts: DesktopAccountInfo[] = []; + const seenAccountKeys = new Set(); + + for (const account of syncedAccounts) { + if (!account) { + continue; + } + + const identityKey = buildAccountIdentityKey({ platform: account.platform, platformUid: account.platform_uid, displayName: account.display_name, }); - - const profile = await resolvePublishAccountProfile({ - id: account.id, - platform: account.platform, - platformUid: account.platform_uid, - displayName: account.display_name, - }).catch(() => null); - - if (profile) { - state.accountProfiles.set(account.id, profile); - return { - ...account, - display_name: profile.displayName, - platform_uid: profile.platformUid, - }; + if (seenAccountKeys.has(identityKey)) { + duplicateAccounts.push(account); + continue; } - return account; - })); + seenAccountKeys.add(identityKey); + dedupedAccounts.push(account); + } + + state.accounts = dedupedAccounts; + + if (duplicateAccounts.length > 0) { + void cleanupDuplicateDesktopAccounts(duplicateAccounts); + } + + if (source === "startup") { + recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`); + } refreshAccountNames(); + void sendHeartbeat("timer"); } catch (error) { const message = errorMessage(error); state.lastError = message; @@ -669,10 +651,46 @@ async function syncAccounts(source: "startup" | "timer" | "manual"): Promise { + for (const account of accounts) { + try { + await deleteDesktopAccount(account.id, account.sync_version); + } catch (error) { + console.warn("[desktop-runtime] duplicate desktop account cleanup failed", { + accountId: account.id, + platform: account.platform, + platformUid: account.platform_uid, + message: errorMessage(error), + }); + } + } +} + export async function refreshRuntimeAccounts(): Promise { await syncAccounts("manual"); } +export async function unbindRuntimeAccount(accountId: string, syncVersion: number): Promise { + const matchedAccount = state.accounts.find((item) => item.id === accountId) ?? null; + + await deleteDesktopAccount(accountId, syncVersion); + await clearSessionHandle(accountId); + + state.accounts = state.accounts.filter((item) => item.id !== accountId); + state.accountProfiles.delete(accountId); + refreshAccountNames(); + + if (matchedAccount) { + recordActivity( + "info", + "Account Unbound", + `${matchedAccount.display_name} 已解绑,并清理了当前机器上的本地会话缓存。`, + ); + } + + await syncAccounts("manual"); +} + function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void { if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) { return; @@ -787,8 +805,6 @@ async function executeLeasedTask( } const task = leased.task; - const payload = normalizeJsonObject(task.payload); - const mode = resolveTaskMode(payload); state.currentTaskId = task.id; setSchedulerCurrentTask(task.id); @@ -802,13 +818,9 @@ async function executeLeasedTask( const taskRecord = upsertTaskFromInfo(task, { routing, - mode, attemptId: leased.attempt_id ?? null, leaseToken: leased.lease_token, - summary: - mode === "manual" && task.kind === "publish" - ? "已领取租约,准备切到 waiting_user parked 状态。" - : `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`, + summary: `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`, }); recordActivity( @@ -825,11 +837,6 @@ async function executeLeasedTask( }, leaseExtendIntervalMs); try { - if (mode === "manual" && task.kind === "publish") { - await prepareManualPublishReview(taskRecord, abortController.signal); - return; - } - const execution = await executeTaskAdapter(taskRecord, abortController.signal); const completed = await completeDesktopTask(taskRecord.id, { lease_token: leased.lease_token, @@ -895,112 +902,6 @@ async function executeLeasedTask( } } -async function prepareManualPublishReview(task: RuntimeTaskRecord, signal: AbortSignal): Promise { - const preparation = await executeTaskAdapter(task, signal); - if (preparation.status === "failed") { - const completed = await completeDesktopTask(task.id, { - lease_token: task.leaseToken as string, - status: "failed", - payload: preparation.payload, - error: preparation.error, - }); - - noteLeaseReleased("failed"); - upsertTaskFromInfo(completed, { - routing: task.routing, - mode: task.mode, - summary: preparation.summary, - attemptId: null, - leaseToken: null, - }); - - recordActivity("danger", "Manual Review Prepare Failed", `${task.title} 无法进入人工审核态。`); - return; - } - - const reviewUrl = preparation.reviewUrl ?? resolveTaskReviewUrl({ - ...task, - result: preparation.payload ?? task.result, - }); - if (!reviewUrl) { - const completed = await completeDesktopTask(task.id, { - lease_token: task.leaseToken as string, - status: "unknown", - payload: preparation.payload, - error: preparation.error ?? { - code: "manual_review_url_missing", - message: "manual publish review url is not available for current platform", - }, - }); - - noteLeaseReleased("completed"); - upsertTaskFromInfo(completed, { - routing: task.routing, - mode: task.mode, - summary: "当前平台暂未提供可打开的审核页,任务回写为 unknown,等待后续对账。", - attemptId: null, - leaseToken: null, - }); - - recordActivity("warn", "Manual Review Unavailable", `${task.title} 暂无可用审核页。`); - return; - } - - if (reviewUrl) { - try { - await openTaskReviewWindow(task, reviewUrl); - } catch (error) { - recordActivity("warn", "Review Window Open Failed", errorMessage(error)); - } - } - - await parkLeasedTask(task, { - summary: - preparation.summary || "任务已进入人工审核态,等待当前客户端操作员确认。", - payload: preparation.payload ?? null, - error: preparation.error ?? null, - }); -} - -async function parkLeasedTask( - task: RuntimeTaskRecord, - options: { - summary: string; - payload?: Record | null; - error?: Record | null; - }, -): Promise { - if (!task.leaseToken) { - return; - } - - try { - if (options.payload) { - rememberTaskLocalResult(task.id, options.payload); - } - if (options.error) { - rememberTaskLocalError(task.id, options.error); - } - - const parked = await parkDesktopTask(task.id, { - lease_token: task.leaseToken, - reason: "waiting_user", - }); - - noteLeaseReleased("parked"); - upsertTaskFromInfo(parked, { - routing: task.routing, - summary: options.summary, - attemptId: null, - leaseToken: null, - }); - - recordActivity("warn", "Task Parked", `${task.title} 已进入人工审核态。`); - } catch (error) { - recordActivity("danger", "Task Park Failed", `${task.title} 进入人工审核态失败:${errorMessage(error)}`); - } -} - async function extendActiveLease(taskId: string, leaseToken: string): Promise { try { const task = await extendDesktopTask(taskId, { lease_token: leaseToken }); @@ -1035,16 +936,18 @@ async function executeTaskAdapter( return buildScaffoldResult(task, payload, "publish adapter is not implemented yet"); } - const viewHandle = acquireHotView(task.accountId || task.id); + const viewHandle = + adapter.executionMode === "session" || adapter.executionMode === "playwright" + ? null + : acquireHotView(task.accountId || task.id); const result = await adapter.publish( { taskId: task.id, accountId: task.accountId || task.id, session: sessionHandle.session, - view: viewHandle.view, + view: viewHandle?.view ?? null, signal, - mode: task.mode, phase: "initial", article: await loadPublishArticle(task), reportProgress(stage: string) { @@ -1062,16 +965,18 @@ async function executeTaskAdapter( return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet"); } - const viewHandle = acquireHotView(task.accountId || task.id); + const viewHandle = + adapter.executionMode === "session" || adapter.executionMode === "playwright" + ? null + : acquireHotView(task.accountId || task.id); const result = await adapter.query( { taskId: task.id, accountId: task.accountId || task.id, session: sessionHandle.session, - view: viewHandle.view, + view: viewHandle?.view ?? null, signal, - mode: task.mode, phase: "initial", reportProgress(stage: string) { updateTaskProgress(task.id, `monitor adapter progress: ${stage}`); @@ -1107,7 +1012,7 @@ function buildScaffoldResult( async function loadPublishArticle( task: RuntimeTaskRecord, -): Promise { +): Promise { const articleId = resolveArticleId(task.payload); if (articleId === null) { throw new Error("desktop_publish_article_id_missing"); @@ -1117,7 +1022,6 @@ async function loadPublishArticle( return { ...article, title: article.title?.trim() || task.title, - publish_type: task.mode === "manual" ? "draft" : "publish", }; } @@ -1147,109 +1051,6 @@ function toPositiveInt(value: JsonValue | null): number | null { return null; } -async function openTaskReviewWindow(task: RuntimeTaskRecord, reviewURL: string): Promise { - const sessionHandle = createSessionHandle(task.accountId || task.id); - await openReviewWindow({ - taskId: task.id, - title: `${task.title} · 审核`, - url: reviewURL, - session: sessionHandle.session, - }); -} - -function resolveTaskReviewUrl(task: RuntimeTaskRecord): string | null { - const candidate = - firstString(task.result?.external_manage_url) ?? - firstString(task.result?.review_url) ?? - firstString(task.payload.external_manage_url) ?? - firstString(task.payload.review_url); - - if (candidate) { - return candidate; - } - - if (task.platform === "toutiaohao") { - return "https://mp.toutiao.com/profile_v4/graphic/publish"; - } - - return null; -} - -function firstString(value: JsonValue | undefined): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function buildManualResolutionPayload( - task: RuntimeTaskRecord, - status: "succeeded" | "failed" | "unknown", -): { - payload: Record; - error?: Record; - summary: string; -} { - const payload: Record = { - ...(task.result ?? {}), - manual_resolution: status, - resolved_at: new Date().toISOString(), - resolved_by: "desktop_operator", - }; - - if (status === "succeeded") { - return { - payload, - summary: "人工审核已确认完成,任务回写为 succeeded。", - }; - } - - if (status === "unknown") { - return { - payload, - error: { - code: "manual_review_unconfirmed", - message: "operator could not confirm final publish result", - }, - summary: "人工审核后仍无法确认最终状态,任务回写为 unknown。", - }; - } - - return { - payload, - error: { - code: "manual_review_rejected", - message: "operator marked parked task as failed", - }, - summary: "人工审核已拒绝该任务,任务回写为 failed。", - }; -} - -function rememberTaskLocalResult(taskId: string, payload: Record): void { - const existing = state.tasks.get(taskId); - if (!existing) { - return; - } - state.tasks.set(taskId, { - ...existing, - result: { - ...(existing.result ?? {}), - ...payload, - }, - }); -} - -function rememberTaskLocalError(taskId: string, error: Record): void { - const existing = state.tasks.get(taskId); - if (!existing) { - return; - } - state.tasks.set(taskId, { - ...existing, - error: { - ...(existing.error ?? {}), - ...error, - }, - }); -} - function updateTaskProgress(taskId: string, summary: string): void { const existing = state.tasks.get(taskId); if (!existing) { @@ -1265,7 +1066,6 @@ function upsertTaskFromInfo( task: DesktopTaskInfo, options: { routing?: RuntimeTaskRouting; - mode?: RuntimeTaskMode; summary?: string; attemptId?: string | null; leaseToken?: string | null; @@ -1273,7 +1073,6 @@ function upsertTaskFromInfo( ): RuntimeTaskRecord { const existing = state.tasks.get(task.id); const payload = normalizeJsonObject(task.payload); - const mode = options.mode ?? existing?.mode ?? resolveTaskMode(payload); const record: RuntimeTaskRecord = { id: task.id, jobId: task.job_id, @@ -1284,18 +1083,13 @@ function upsertTaskFromInfo( accountName: resolveAccountName(task.target_account_id, existing?.accountName), clientId: task.target_client_id, status: task.status, - mode, routing: options.routing ?? existing?.routing ?? "db-recovery", leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null, updatedAt: parseTimestamp(task.updated_at) ?? Date.now(), - summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status, mode), + summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status), payload, - error: - normalizeJsonObjectOrNull(task.error) - ?? (task.status === "waiting_user" ? existing?.error ?? null : null), - result: - normalizeJsonObjectOrNull(task.result) - ?? (task.status === "waiting_user" ? existing?.result ?? null : null), + error: normalizeJsonObjectOrNull(task.error), + result: normalizeJsonObjectOrNull(task.result), attemptId: task.status === "in_progress" ? options.attemptId ?? existing?.attemptId ?? task.active_attempt_id ?? null @@ -1422,20 +1216,14 @@ function defaultTaskTitle(kind: "publish" | "monitor", platform?: string): strin return `${kind === "publish" ? "发布任务" : "监控任务"}${platform ? ` · ${platform}` : ""}`; } -function resolveTaskMode(payload: Record): RuntimeTaskMode { - return payload.mode === "manual" ? "manual" : "auto"; -} - -function summaryFromTaskStatus(status: RuntimeTaskStatus, mode: RuntimeTaskMode): string { +function summaryFromTaskStatus(status: RuntimeTaskStatus): string { switch (status) { case "queued": return "任务已排队,等待客户端领取租约。"; case "in_progress": return "任务正在执行,结果回写需要携带有效 lease_token。"; - case "waiting_user": - return "任务已 parked,等待原客户端上的人工确认。"; case "succeeded": - return mode === "manual" ? "任务已完成人工审核并成功提交。" : "任务执行成功。"; + return "任务执行成功。"; case "failed": return "任务执行失败。"; case "aborted": @@ -1453,14 +1241,12 @@ function summaryFromEventType(type: DesktopTaskEventMessage["type"], status: Run return "服务端已确认租约归属。"; case "task_extended": return "任务租约已续期。"; - case "task_parked": - return "任务已 parked 到 waiting_user。"; case "task_canceled": return "任务已被取消。"; case "task_reconciled": return "任务已完成人工对账并回写。"; default: - return summaryFromTaskStatus(status, "auto"); + return summaryFromTaskStatus(status); } } @@ -1492,39 +1278,6 @@ function normalizeJsonObjectOrNull( return { ...value }; } -function normalizeUnknownResult(value: Record): Record { - const output: Record = {}; - for (const [key, item] of Object.entries(value)) { - output[key] = normalizeJsonValue(item); - } - return output; -} - -function normalizeJsonValue(value: unknown): JsonValue { - if ( - value === null - || typeof value === "string" - || typeof value === "number" - || typeof value === "boolean" - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeJsonValue(item)); - } - - if (typeof value === "object" && value) { - const output: Record = {}; - for (const [key, item] of Object.entries(value)) { - output[key] = normalizeJsonValue(item); - } - return output; - } - - return String(value); -} - function toStructuredError(error: unknown): Record { if (error instanceof ApiClientError) { return { diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts index 34b93b1..3ef5f2d 100644 --- a/apps/desktop-client/src/main/runtime-snapshot.ts +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -11,10 +11,7 @@ import { getSessionHandle, listSessionHandleSnapshots } from "./session-registry import { getTransportSnapshot } from "./transport/api-client"; import { describeVaultBackend } from "./vault"; import { listHotViews } from "./view-pool"; - -function minutesAgo(now: number, minutes: number): number { - return now - minutes * 60_000; -} +import { normalizeAccountPlatformUid } from "../shared/account-identity"; function minutesAhead(now: number, minutes: number): number { return now + minutes * 60_000; @@ -30,11 +27,7 @@ function parseTimestamp(value: string | null | undefined): number | null { } export function createRuntimeSnapshot() { - const controller = getRuntimeControllerSnapshot(); - if (!controller.session || controller.session.mode === "preview") { - return createPreviewRuntimeSnapshot(); - } - return createLiveRuntimeSnapshot(controller); + return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot()); } function createLiveRuntimeSnapshot(controller: ReturnType) { @@ -54,7 +47,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType - ["queued", "in_progress", "waiting_user"].includes(task.status), + ["queued", "in_progress"].includes(task.status), ).length; const clients = [ @@ -72,21 +65,21 @@ function createLiveRuntimeSnapshot(controller: ReturnType { - const profile = controller.accountProfiles[account.id] ?? null; const sessionHandle = getSessionHandle(account.id); const isHot = hotViews.some((item) => item.accountId === account.id); const accountQueueDepth = controller.tasks.filter((task) => - task.accountId === account.id && ["queued", "in_progress", "waiting_user"].includes(task.status), + task.accountId === account.id && ["queued", "in_progress"].includes(task.status), ).length; return { id: account.id, platform: account.platform, - displayName: profile?.displayName ?? account.display_name, - platformUid: profile?.platformUid ?? account.platform_uid, - avatarUrl: profile?.avatarUrl ?? null, + displayName: account.display_name, + platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid, + avatarUrl: account.avatar_url ?? null, health: account.health, tags: account.tags, + syncVersion: account.sync_version, clientId: account.client_id ?? primaryClientId, partition: sessionHandle?.partition ?? `persist:acc-${account.id}`, sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold", @@ -95,7 +88,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType item.status === "queued").length, issuesOpen: - tasks.filter((item) => ["waiting_user", "unknown", "failed"].includes(item.status)).length + tasks.filter((item) => ["unknown", "failed"].includes(item.status)).length + accounts.filter((item) => item.health !== "live").length, healthCounts, }, @@ -163,270 +155,3 @@ function createLiveRuntimeSnapshot(controller: ReturnType 0 ? "hot" : "warm", - lastSyncAt: minutesAgo(now, 3), - queueDepth: 1, - online: true, - }, - { - id: "acc-toutiao-brand", - platform: "toutiaohao", - displayName: "Toutiao Brand Primary", - platformUid: "tt-brand-01", - avatarUrl: null, - health: "live", - tags: ["发布", "品牌"], - clientId: clients[0].id, - partition: "persist:acc-toutiao-brand", - sessionState: "warm", - lastSyncAt: minutesAgo(now, 8), - queueDepth: 2, - online: true, - }, - { - id: "acc-toutiao-matrix", - platform: "toutiaohao", - displayName: "Toutiao Matrix B", - platformUid: "tt-matrix-b", - avatarUrl: null, - health: "captcha", - tags: ["发布", "待修复"], - clientId: clients[0].id, - partition: "persist:acc-toutiao-matrix", - sessionState: "cold", - lastSyncAt: minutesAgo(now, 42), - queueDepth: 1, - online: true, - }, - { - id: "acc-qwen-research", - platform: "qwen", - displayName: "Qwen Research Backup", - platformUid: "qwen-research-02", - avatarUrl: null, - health: "risk", - tags: ["AI监控", "备用"], - clientId: clients[1].id, - partition: "persist:acc-qwen-research", - sessionState: "cold", - lastSyncAt: minutesAgo(now, 96), - queueDepth: 1, - online: false, - }, - ] as const; - - const tasks = [ - { - id: "task-monitor-doubao-001", - jobId: "job-monitor-daily-001", - title: "品牌提及采样 · Doubao", - kind: "monitor", - platform: "doubao", - accountId: accounts[0].id, - accountName: accounts[0].displayName, - clientId: accounts[0].clientId, - status: "in_progress", - mode: "auto", - routing: "rabbitmq-primary", - leaseExpiresAt: leaseManager.startedAt ? minutesAhead(leaseManager.startedAt, 10) : minutesAhead(now, 8), - updatedAt: minutesAgo(now, 2), - summary: "当前由本机执行,租约续期正常。", - }, - { - id: "task-publish-toutiao-021", - jobId: "job-launch-q2-021", - title: "新品发布 · 头条号主号", - kind: "publish", - platform: "toutiaohao", - accountId: accounts[1].id, - accountName: accounts[1].displayName, - clientId: accounts[1].clientId, - status: "waiting_user", - mode: "manual", - routing: "rabbitmq-primary", - leaseExpiresAt: null, - updatedAt: minutesAgo(now, 6), - summary: "审核页已就绪,等待桌面端人工确认。", - }, - { - id: "task-publish-toutiao-022", - jobId: "job-launch-q2-021", - title: "新品发布 · 矩阵 B", - kind: "publish", - platform: "toutiaohao", - accountId: accounts[2].id, - accountName: accounts[2].displayName, - clientId: accounts[2].clientId, - status: "queued", - mode: "auto", - routing: "rabbitmq-primary", - leaseExpiresAt: null, - updatedAt: minutesAgo(now, 1), - summary: "已入 MQ 队列,等待目标客户端拉取租约。", - }, - { - id: "task-monitor-qwen-003", - jobId: "job-monitor-daily-003", - title: "竞品问答补采样 · Qwen", - kind: "monitor", - platform: "qwen", - accountId: accounts[3].id, - accountName: accounts[3].displayName, - clientId: accounts[3].clientId, - status: "unknown", - mode: "auto", - routing: "db-recovery", - leaseExpiresAt: null, - updatedAt: minutesAgo(now, 49), - summary: "原客户端离线,已切到 DB 补偿队列等待 reconcile。", - }, - { - id: "task-publish-archive-007", - jobId: "job-content-rollout-007", - title: "专题文章回填 · 归档批次", - kind: "publish", - platform: "toutiaohao", - accountId: accounts[1].id, - accountName: accounts[1].displayName, - clientId: accounts[1].clientId, - status: "succeeded", - mode: "auto", - routing: "rabbitmq-primary", - leaseExpiresAt: null, - updatedAt: minutesAgo(now, 34), - summary: "发布完成,回执已写回服务端。", - }, - ] as const; - - const activity = [ - { - id: "evt-01", - severity: "info", - title: "MQ fanout ready", - detail: "desktop.task.event 已完成广播初始化,客户端优先走消息唤醒。", - at: minutesAgo(now, 2), - }, - { - id: "evt-02", - severity: "warn", - title: "Captcha risk detected", - detail: "Toutiao Matrix B 在最近一次 resync 中被标记为 captcha。", - at: minutesAgo(now, 11), - }, - { - id: "evt-03", - severity: "danger", - title: "Offline recovery armed", - detail: "Qwen Research Backup 目标客户端离线,任务已进入 DB fallback 观察态。", - at: minutesAgo(now, 49), - }, - { - id: "evt-04", - severity: "success", - title: "Publish receipt committed", - detail: "归档批次回执已完成 result callback,Web 端可见成功 URL。", - at: minutesAgo(now, 34), - }, - ] as const; - - const healthCounts = accounts.reduce>((acc, item) => { - acc[item.health] = (acc[item.health] ?? 0) + 1; - return acc; - }, {}); - - return { - previewMode: true, - generatedAt: now, - app: { - name: "GEO Rankly Desktop", - version: app.getVersion(), - channel: process.env.NODE_ENV === "development" ? "dev" : "release", - platform: process.platform, - sessionCount: sessions.length, - hotViewCount: hotViews.length, - }, - workspace: { - id: "ws-default", - name: "Default Workspace", - strategy: "rabbitmq-first / db-fallback", - nextSweepAt: minutesAhead(now, 5), - lastFullSyncAt: minutesAgo(now, 4), - }, - summary: { - onlineClients: clients.filter((item) => item.status === "online").length, - accountsBound: accounts.length, - queuedTasks: tasks.filter((item) => item.status === "queued").length, - issuesOpen: - tasks.filter((item) => ["waiting_user", "unknown"].includes(item.status)).length - + accounts.filter((item) => item.health !== "live").length, - healthCounts, - }, - clients, - accounts, - tasks, - activity, - subsystems: { - transport: getTransportSnapshot(), - circuitBreaker: getCircuitBreakerState(), - keepAlive: getKeepAlivePlan(), - leaseManager: { - ...leaseManager, - activeLeaseId: leaseManager.activeLeaseId ?? tasks[0].id, - }, - rateLimiter: { - ...rateLimiter, - refreshedAt: rateLimiter.refreshedAt || minutesAgo(now, 7), - bucketCount: rateLimiter.bucketCount || 6, - }, - recovery: collectRecoverySnapshot(), - scheduler: getSchedulerState(), - sessions, - hotViews, - vault: describeVaultBackend(), - }, - }; -} diff --git a/apps/desktop-client/src/main/session-registry.ts b/apps/desktop-client/src/main/session-registry.ts index 76d62af..e2158b9 100644 --- a/apps/desktop-client/src/main/session-registry.ts +++ b/apps/desktop-client/src/main/session-registry.ts @@ -69,6 +69,19 @@ function rememberPersistedPartition(accountId: string, partition: string): void }); } +function forgetPersistedPartition(accountId: string): string | null { + const current = readPersistedPartitions(); + const partition = current[accountId] ?? null; + if (!partition) { + return null; + } + + const next = { ...current }; + delete next[accountId]; + writePersistedPartitions(next); + return partition; +} + function partitionFor(accountId: string): string { return `persist:acc-${accountId}`; } @@ -166,6 +179,37 @@ export function forgetSessionHandle(accountId: string): void { registry.delete(accountId); } +export async function clearSessionHandle(accountId: string): Promise { + const active = registry.get(accountId); + const persistedPartition = forgetPersistedPartition(accountId); + const partition = active?.partition ?? persistedPartition; + registry.delete(accountId); + + if (!partition) { + return; + } + + const target = prepareSession(session.fromPartition(partition)); + + try { + await target.clearStorageData(); + } catch (error) { + console.warn("[desktop-session] clearStorageData failed", { accountId, partition, error }); + } + + try { + await target.clearCache(); + } catch (error) { + console.warn("[desktop-session] clearCache failed", { accountId, partition, error }); + } + + try { + await target.cookies.flushStore(); + } catch (error) { + console.warn("[desktop-session] flushStore failed", { accountId, partition, error }); + } +} + export function getSessionHandle(accountId: string): SessionHandle | undefined { return registry.get(accountId); } diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index c2b690e..c9b8cb2 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -1,19 +1,25 @@ -import { createApiClient, type ApiClient } from "@geo/http-client"; +import { ApiClientError, createApiClient, type ApiClient } from "@geo/http-client"; import type { + ApiEnvelope, + ApiErrorEnvelope, CompleteDesktopTaskRequest, + CreatePublishJobResponse, DesktopArticleContent, DesktopAccountInfo, DesktopClientHeartbeatRequest, DesktopClientHeartbeatResponse, + DesktopPublishTaskListResponse, DesktopTaskInfo, DesktopRuntimeSessionSyncRequest, ExtendDesktopTaskRequest, LeaseDesktopTaskRequest, LeaseDesktopTaskResponse, - ParkDesktopTaskRequest, + ListDesktopPublishTasksParams, UpsertDesktopAccountRequest, } from "@geo/shared-types"; +import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from "../renderer-devtools-proxy"; + type TransportAuthState = "pending" | "registered" | "expired"; type TransportSseState = "idle" | "connecting" | "streaming"; @@ -32,6 +38,7 @@ const transportState = { baseURL: transportSession.baseURL, initializedAt: 0, mode: "rabbitmq-first" as const, + requestDebugMode: "main-process" as "main-process" | "renderer-devtools", auth: "pending" as TransportAuthState, sseState: "idle" as TransportSseState, lastHeartbeatAt: 0, @@ -57,6 +64,97 @@ function createDesktopClient(baseURL: string): ApiClient { return client; } +function currentRequestDebugMode(): "main-process" | "renderer-devtools" { + return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process"; +} + +function resolveDesktopURL(path: string): string { + return new URL(path, transportSession.baseURL).toString(); +} + +async function proxyDesktopRequest( + method: "GET" | "POST" | "DELETE", + path: string, + body?: B, +): Promise { + const headers: Record = { + Accept: "application/json", + }; + + let serializedBody: string | undefined; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + serializedBody = JSON.stringify(body); + } + if (transportSession.clientToken) { + headers.Authorization = `Bearer ${transportSession.clientToken}`; + } + + const response = await rendererDevtoolsFetch({ + url: resolveDesktopURL(path), + method, + headers, + body: serializedBody, + }); + + if (response.error) { + throw new ApiClientError({ message: response.error }); + } + + let payload: ApiEnvelope | Partial | null = null; + if (response.bodyText) { + try { + payload = JSON.parse(response.bodyText) as ApiEnvelope | Partial; + } catch { + payload = null; + } + } + + if (!response.ok) { + const errorPayload = payload as Partial | null; + throw new ApiClientError({ + message: errorPayload?.message ?? `desktop_http_${response.status}`, + code: errorPayload?.code ?? 50000, + status: response.status, + detail: errorPayload?.detail, + requestId: errorPayload?.request_id, + }); + } + + if (!payload || typeof payload !== "object" || !("data" in payload)) { + throw new ApiClientError({ + message: "desktop_invalid_api_envelope", + status: response.status, + }); + } + + return payload.data as T; +} + +async function desktopGet(path: string): Promise { + transportState.requestDebugMode = currentRequestDebugMode(); + if (transportState.requestDebugMode === "renderer-devtools") { + return proxyDesktopRequest("GET", path); + } + return getDesktopApiClient().get(path); +} + +async function desktopPost(path: string, body?: B): Promise { + transportState.requestDebugMode = currentRequestDebugMode(); + if (transportState.requestDebugMode === "renderer-devtools") { + return proxyDesktopRequest("POST", path, body); + } + return getDesktopApiClient().post(path, body); +} + +async function desktopDelete(path: string): Promise { + transportState.requestDebugMode = currentRequestDebugMode(); + if (transportState.requestDebugMode === "renderer-devtools") { + return proxyDesktopRequest("DELETE", path); + } + return getDesktopApiClient().remove(path); +} + export function initTransport(): ApiClient { if (desktopApiClient) { return desktopApiClient; @@ -64,6 +162,7 @@ export function initTransport(): ApiClient { transportState.initializedAt = transportState.initializedAt || Date.now(); transportState.baseURL = transportSession.baseURL; + transportState.requestDebugMode = currentRequestDebugMode(); desktopApiClient = createDesktopClient(transportSession.baseURL); return desktopApiClient; } @@ -75,6 +174,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n clientToken: null, }; transportState.baseURL = transportSession.baseURL; + transportState.requestDebugMode = currentRequestDebugMode(); transportState.auth = "pending"; desktopApiClient = null; return null; @@ -85,6 +185,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n clientToken: session.client_token, }; transportState.baseURL = transportSession.baseURL; + transportState.requestDebugMode = currentRequestDebugMode(); transportState.auth = session.client_token ? "registered" : "pending"; transportState.initializedAt = Date.now(); desktopApiClient = createDesktopClient(transportSession.baseURL); @@ -124,38 +225,86 @@ export function getTransportSnapshot() { export async function heartbeatDesktopClient( payload: DesktopClientHeartbeatRequest, ): Promise { - return getDesktopApiClient().post( + return desktopPost( "/api/desktop/clients/heartbeat", payload, ); } +export async function offlineDesktopClient(): Promise { + await desktopPost<{ server_time: string }, Record>( + "/api/desktop/clients/offline", + {}, + ); +} + +export async function revokeDesktopClient(): Promise { + await desktopPost<{ id: string }, Record>( + "/api/desktop/clients/revoke", + {}, + ); +} + export async function listDesktopAccounts(): Promise { - return getDesktopApiClient().get("/api/desktop/accounts"); + return desktopGet("/api/desktop/accounts"); } export async function getDesktopArticleContent(articleId: number): Promise { - return getDesktopApiClient().get(`/api/desktop/content/articles/${articleId}`); + return desktopGet(`/api/desktop/content/articles/${articleId}`); +} + +export async function listDesktopPublishTasks( + params: ListDesktopPublishTasksParams = {}, +): Promise { + const search = new URLSearchParams(); + if (params.page) { + search.set("page", String(params.page)); + } + if (params.page_size) { + search.set("page_size", String(params.page_size)); + } + if (params.title?.trim()) { + search.set("title", params.title.trim()); + } + + const query = search.toString(); + const suffix = query ? `?${query}` : ""; + return desktopGet(`/api/desktop/publish-tasks${suffix}`); +} + +export async function retryDesktopPublishTask(taskId: string): Promise { + return desktopPost>( + `/api/desktop/publish-tasks/${encodeURIComponent(taskId)}/retry`, + {}, + ); } export async function upsertDesktopAccount( payload: UpsertDesktopAccountRequest, ): Promise { - return getDesktopApiClient().post( + return desktopPost( "/api/desktop/accounts", payload, ); } +export async function deleteDesktopAccount( + desktopId: string, + ifSyncVersion: number, +): Promise { + const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) }); + return desktopDelete( + `/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`, + ); +} + export async function leaseDesktopTask( request: LeaseDesktopTaskRequest, - options: { fromParked?: boolean } = {}, ): Promise { const taskId = request.task_id?.trim(); const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : "/api/desktop/tasks/lease"; - const query = options.fromParked ? "?from_parked=true" : ""; - return getDesktopApiClient().post( - `${path}${query}`, + return desktopPost( + path, taskId ? { kind: request.kind } : request, ); } @@ -164,27 +313,17 @@ export async function extendDesktopTask( taskId: string, payload: ExtendDesktopTaskRequest, ): Promise { - return getDesktopApiClient().post( + return desktopPost( `/api/desktop/tasks/${taskId}/extend`, payload, ); } -export async function parkDesktopTask( - taskId: string, - payload: ParkDesktopTaskRequest, -): Promise { - return getDesktopApiClient().post( - `/api/desktop/tasks/${taskId}/park`, - payload, - ); -} - export async function completeDesktopTask( taskId: string, payload: CompleteDesktopTaskRequest, ): Promise { - return getDesktopApiClient().post( + return desktopPost( `/api/desktop/tasks/${taskId}/result`, payload, ); diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts index 5d21735..fac557a 100644 --- a/apps/desktop-client/src/main/tray.ts +++ b/apps/desktop-client/src/main/tray.ts @@ -1,11 +1,23 @@ import { nativeImage } from "electron/common"; -import { Menu, Tray } from "electron/main"; +import { Menu, Tray, app } from "electron/main"; import type { Tray as ElectronTray } from "electron/main"; let tray: ElectronTray | null = null; -function createFallbackIcon() { - return nativeImage.createEmpty(); +const TRAY_ICON_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAAnUlEQVR4nN2UwQmAMAxF360Dee8I" + + "LuNIxT2cpIv0oJcIEqJGMSIG/qX8fn5/msDfqgcKUIEmqHLW3xHsgAmYTzAJ1+2yOURXNI/77qLo" + + "VvzQufX8EchAEmQ5s2LZjUCThwMTg8E3IymG07PSzotFqoqUHcJZ3akWSTctOYST0cT3hMOiCGte" + + "2HcjakCIHGmiltDW+eNrU7t/dNF/txaahq+VoZ+VygAAAABJRU5ErkJggg=="; + +function createTrayIcon() { + const buffer = Buffer.from(TRAY_ICON_BASE64, "base64"); + const icon = nativeImage.createFromBuffer(buffer); + if (icon.isEmpty()) { + return nativeImage.createEmpty(); + } + icon.setTemplateImage(true); + return icon; } export function initTray(onOpen: () => void): ElectronTray { @@ -13,13 +25,13 @@ export function initTray(onOpen: () => void): ElectronTray { return tray; } - tray = new Tray(createFallbackIcon()); + tray = new Tray(createTrayIcon()); tray.setToolTip("GEO Rankly Desktop"); tray.setContextMenu( Menu.buildFromTemplate([ { label: "Open", click: () => onOpen() }, { type: "separator" }, - { role: "quit", label: "Quit" }, + { label: "Quit", click: () => app.quit() }, ]), ); tray.on("click", () => onOpen()); diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 241c270..eb8f9bc 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -1,5 +1,11 @@ import { contextBridge, ipcRenderer } from "electron/renderer"; -import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; +import type { + CreatePublishJobResponse, + DesktopAccountInfo, + DesktopPublishTaskListResponse, + DesktopRuntimeSessionSyncRequest, + ListDesktopPublishTasksParams, +} from "@geo/shared-types"; import type { DesktopRuntimeSnapshot } from "../renderer/types"; const desktopBridge = { @@ -9,6 +15,8 @@ const desktopBridge = { ipcRenderer.invoke("desktop:runtime-snapshot") as Promise, bindPublishAccount: (platformId: string) => ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise, + refreshRuntimeAccounts: () => + ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise, openPublishAccountConsole: (account: { id: string; platform: string; @@ -16,12 +24,16 @@ const desktopBridge = { displayName: string; }) => ipcRenderer.invoke("desktop:open-publish-account-console", account) as Promise, - openTaskReview: (taskId: string) => - ipcRenderer.invoke("desktop:task-open-review", taskId) as Promise, - resolveParkedTask: (taskId: string, status: "succeeded" | "failed" | "unknown") => - ipcRenderer.invoke("desktop:task-resolve-parked", taskId, status) as Promise, + unbindPublishAccount: (accountId: string, syncVersion: number) => + ipcRenderer.invoke("desktop:unbind-publish-account", accountId, syncVersion) as Promise, + listPublishTasks: (params?: ListDesktopPublishTasksParams) => + ipcRenderer.invoke("desktop:list-publish-tasks", params) as Promise, + retryPublishTask: (taskId: string) => + ipcRenderer.invoke("desktop:retry-publish-task", taskId) as Promise, syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise, + releaseRuntimeSession: (revoke?: boolean) => + ipcRenderer.invoke("desktop:runtime-session-release", revoke) as Promise, }, }; diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue index 02d9edf..1e7c5eb 100644 --- a/apps/desktop-client/src/renderer/components/DesktopShell.vue +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -2,15 +2,21 @@ import { computed } from "vue"; import { RouterLink, RouterView, useRoute } from "vue-router"; -import StatusBadge from "./StatusBadge.vue"; import { useDesktopRuntime } from "../composables/useDesktopRuntime"; import { useDesktopSession } from "../composables/useDesktopSession"; -import { formatRelativeTime } from "../lib/formatters"; import { desktopMonitoringMediaCatalog, desktopPublishMediaCatalog } from "../lib/media-catalog"; const route = useRoute(); -const { snapshot, error } = useDesktopRuntime(); -const { session, logout, isPreviewMode } = useDesktopSession(); +const { snapshot, error, refreshAccounts } = useDesktopRuntime(); +const { session, logout } = useDesktopSession(); + +const accountAwareRoutes = new Set(["/media-platforms", "/ai-platforms"]); + +function handleNavClick(target: string) { + if (accountAwareRoutes.has(target)) { + void refreshAccounts(); + } +} const navItems = computed(() => { const data = snapshot.value; @@ -26,10 +32,10 @@ const navItems = computed(() => { count: data?.summary.issuesOpen ?? 0, }, { - to: "/tasks", - title: "任务中心", - description: "MQ 投递、租约占用、人工审核与 fallback", - count: data?.tasks.length ?? 0, + to: "/publish-management", + title: "发布管理", + description: "查看待发布队列、历史发送结果,并对文章再次发送", + count: data?.summary.queuedTasks ?? 0, }, { to: "/media-platforms", @@ -53,11 +59,8 @@ const navItems = computed(() => { }); -const updatedLabel = computed(() => - snapshot.value ? formatRelativeTime(snapshot.value.generatedAt) : "等待首帧快照", -); const operatorName = computed(() => session.value?.user?.name ?? "未命名操作员"); -const operatorEmail = computed(() => session.value?.user?.email ?? "preview@geo-rankly.local"); +const operatorEmail = computed(() => session.value?.user?.email ?? ""); const clientLabel = computed( () => session.value?.desktopClient?.device_name ?? "当前设备尚未注册 client", ); @@ -66,53 +69,25 @@ const clientLabel = computed(