Compare commits

...

6 Commits

Author SHA1 Message Date
root c26da5cf72 feat(installer): add custom install and uninstall macros for Shengxintui protocol
Desktop Client Build / Resolve Build Metadata (push) Successful in 30s
Frontend CI / Frontend (push) Successful in 3m53s
Backend CI / Backend (push) Failing after 11m8s
Desktop Client Build / Build Desktop Client (push) Successful in 26m32s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 54s
2026-05-07 13:54:04 +08:00
root d17db41108 docs(plan): record publish management research and phase plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:24 +08:00
root 21fee01688 chore: scope desktop client build to apps/desktop-client and document regenerate route
Drop the broad path filters that triggered the desktop client build on
unrelated workspace changes, and add a swagger description for the
existing article regenerate endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:15 +08:00
root 0a7b216513 fix(knowledge): refresh inflight items and skip empty chunked groups
Poll the knowledge item list every 5s while any item is still pending
or processing so the UI reflects ingestion progress without manual
refresh, and exclude items that have no active chunks from group
counters so groups with no usable content do not appear populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:03 +08:00
root 7443bb3260 feat(templates): confirm before leaving wizard with draft content
Intercept route changes and tab close when the template wizard has
unsubmitted content so users can save a draft, discard, or stay instead
of silently losing their progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:07:51 +08:00
root c1e7c5e90c feat(publish): add tenant publish management with desktop deep-link
Expose tenant-authenticated publish task list and retry endpoints so the
admin web can show desktop publish state without an Electron bridge, and
register a `shengxintui://` deep-link so the page can hand off to the
desktop client workbench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:07:28 +08:00
24 changed files with 1647 additions and 75 deletions
@@ -6,11 +6,6 @@ on:
- main
paths:
- "apps/desktop-client/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".gitea/workflows/desktop-client-build.yml"
workflow_dispatch:
inputs:
platforms:
+13
View File
@@ -77,6 +77,7 @@ const enUS = {
freeCreate: 'Free Create',
imitation: 'Imitation Create',
media: 'Media',
publishManagement: 'Publish Management',
brandManagement: 'Brand Management',
tracking: 'Tracking',
trackingDetail: 'Data Details',
@@ -202,6 +203,11 @@ const enUS = {
description:
'Review desktop-managed media bindings, authorization state, and sync results in one place.',
},
publishManagement: {
title: 'Publish Management',
description:
'Review desktop publish tasks, external links, account workbench entry points, and retry state.',
},
brands: {
title: 'Brand Library',
description: 'Manage keywords, question sets, and competitors around each brand.',
@@ -933,6 +939,13 @@ const enUS = {
queued: 'Task submitted. The article is now waiting in the generation queue.',
submitted: 'Generation task submitted. Refreshing article data.',
},
leaveConfirm: {
title: 'Save as draft?',
content: 'This template wizard has content. Save it as a draft before leaving.',
stay: 'Keep editing',
discard: 'Leave without saving',
save: 'Save draft',
},
},
},
article: {
+12
View File
@@ -77,6 +77,7 @@ const zhCN = {
freeCreate: "自由创作",
imitation: "仿写创作",
media: "媒体管理",
publishManagement: "发文管理",
brandManagement: "品牌管理",
brands: "品牌词库",
tracking: "数据追踪",
@@ -195,6 +196,10 @@ const zhCN = {
title: "媒体管理",
description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。",
},
publishManagement: {
title: "发文管理",
description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。",
},
brands: {
title: "品牌词库",
description: "围绕品牌维护关键词、问题集和竞品信息。",
@@ -900,6 +905,13 @@ const zhCN = {
queued: "任务已提交,正在排队生成",
submitted: "生成任务已提交,正在刷新文章列表。",
},
leaveConfirm: {
title: "是否保存为草稿?",
content: "当前模版向导已有填写内容。离开前可以保存为草稿,稍后继续编辑。",
stay: "继续编辑",
discard: "不保存离开",
save: "保存草稿",
},
},
},
article: {
+2
View File
@@ -9,6 +9,7 @@ import {
PictureOutlined,
RadarChartOutlined,
SearchOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { useQuery } from '@tanstack/vue-query'
import { computed, type Component } from 'vue'
@@ -130,6 +131,7 @@ const navSections = computed<NavSection[]>(() => {
title: t('nav.contentManagement'),
items: [
{ key: '/media', label: t('nav.media'), icon: GlobalOutlined },
{ key: '/publish-management', label: t('nav.publishManagement'), icon: SendOutlined },
{ key: '/knowledge', label: t('nav.knowledge'), icon: BookOutlined },
{ key: '/images', label: t('nav.images'), icon: PictureOutlined },
],
+15
View File
@@ -69,6 +69,7 @@ import type {
KolSubscriptionPromptCard,
KolSubscriptionPromptSchema,
KolWorkspaceCard,
ListDesktopPublishTasksParams,
LoginRequest,
LoginResponse,
MediaPlatform,
@@ -114,6 +115,7 @@ import type {
TemplateSaveDraftResponse,
TemplateTitleTaskRequest,
TemplateTitleTaskResultResponse,
TenantPublishTaskListResponse,
UpdateArticleRequest,
UpdateKolPackageRequest,
UpdateKolPromptRequest,
@@ -1105,6 +1107,19 @@ export const publishJobsApi = {
},
}
export const publishTasksApi = {
list(params: ListDesktopPublishTasksParams = {}) {
return apiClient.get<TenantPublishTaskListResponse>('/api/tenant/publish-tasks', {
params,
})
},
retry(taskId: string) {
return apiClient.post<CreatePublishJobResponse>(
`/api/tenant/publish-tasks/${encodeURIComponent(taskId)}/retry`,
)
},
}
export const complianceApi = {
runtimeStatus() {
return apiClient.get<ComplianceRuntimeStatus>('/api/tenant/compliance/runtime-status')
+10
View File
@@ -124,6 +124,16 @@ const router = createRouter({
navKey: '/media',
},
},
{
path: 'publish-management',
name: 'publish-management',
component: () => import('@/views/PublishManagementView.vue'),
meta: {
titleKey: 'route.publishManagement.title',
descriptionKey: 'route.publishManagement.description',
navKey: '/publish-management',
},
},
{
path: 'brands',
name: 'brands',
@@ -62,6 +62,14 @@ const groupsQuery = useQuery({
const itemsQuery = useQuery({
queryKey: computed(() => ['knowledge', 'items', selectedGroupId.value]),
queryFn: () => knowledgeApi.listItems(selectedGroupId.value ?? undefined),
refetchInterval: (query) => {
const items = query.state.data as KnowledgeItem[] | undefined
if (!items || items.length === 0) return false
const hasInflight = items.some(
(item) => item.status !== 'completed' && item.status !== 'failed',
)
return hasInflight ? 5000 : false
},
})
const columns: TableColumnsType<KnowledgeItem> = [
File diff suppressed because it is too large Load Diff
+142 -10
View File
@@ -15,9 +15,9 @@ import type {
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
@@ -151,6 +151,9 @@ const router = useRouter()
let competitorKeySeed = 0
let outlineNodeSeed = 0
let allowWizardLeave = false
let pendingLeavePromise: Promise<boolean> | null = null
let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null
const CUSTOM_OUTLINE_MAX_LENGTH = 15
const currentStep = ref(0)
@@ -158,6 +161,8 @@ const articleLocale = ref('zh-CN')
const currentArticleId = ref<number | null>(null)
const restoringDraft = ref(false)
const savingDraft = ref(false)
const leaveConfirmOpen = ref(false)
const leaveConfirmBusy = ref(false)
const selectedBrandId = ref<number | null>(null)
const brandName = ref('')
@@ -248,6 +253,7 @@ const mutation = useMutation({
queryClient.invalidateQueries({ queryKey: ['templates'] }),
])
message.info(t('templates.wizard.messages.queued'))
allowWizardLeave = true
await router.replace('/articles/templates')
},
onError: (error) => {
@@ -410,6 +416,22 @@ const isPageLoading = computed(
templateDetailQuery.isPending.value ||
(draftQueryEnabled.value && draftArticleQuery.isPending.value),
)
const hasWizardDraftContent = computed(
() =>
Boolean(selectedBrandId.value) ||
Boolean(brandName.value.trim()) ||
Boolean(officialWebsite.value.trim()) ||
Boolean(brandSummary.value.trim()) ||
keywordDrafts.value.some((item) => item.trim()) ||
competitorDrafts.value.some(hasCompetitorDraftContent) ||
assistTitles.value.length > 0 ||
Boolean(customTitle.value.trim()) ||
Boolean(keyPoints.value.trim()) ||
selectedKnowledgeGroupIds.value.length > 0 ||
customOutlineSections.value.some((item) => item.label.trim()) ||
Boolean(customOutlineInput.value.trim()) ||
generatedOutline.value.some(hasOutlineDraftContent),
)
const assistStages = computed(() =>
assistTaskKind.value === 'analyze'
? [
@@ -621,6 +643,87 @@ watch(selectedBrandId, async (brandId) => {
}))
})
onBeforeRouteLeave(async () => {
if (!shouldConfirmBeforeLeavingWizard()) {
return true
}
return requestLeaveConfirmation()
})
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
function shouldConfirmBeforeLeavingWizard(): boolean {
return enabled.value && !allowWizardLeave && hasWizardDraftContent.value
}
function requestLeaveConfirmation(): Promise<boolean> {
if (pendingLeavePromise) {
return pendingLeavePromise
}
leaveConfirmOpen.value = true
pendingLeavePromise = new Promise<boolean>((resolve) => {
pendingLeaveResolve = resolve
})
return pendingLeavePromise
}
function resolveLeaveConfirmation(canLeave: boolean): void {
leaveConfirmOpen.value = false
leaveConfirmBusy.value = false
const resolve = pendingLeaveResolve
pendingLeavePromise = null
pendingLeaveResolve = null
resolve?.(canLeave)
}
async function handleSaveDraftAndLeave(): Promise<void> {
if (leaveConfirmBusy.value) {
return
}
leaveConfirmBusy.value = true
const saved = await saveWizardDraft()
if (saved) {
allowWizardLeave = true
resolveLeaveConfirmation(true)
return
}
leaveConfirmBusy.value = false
}
function handleDiscardAndLeave(): void {
allowWizardLeave = true
resolveLeaveConfirmation(true)
}
function handleStayOnWizard(): void {
resolveLeaveConfirmation(false)
}
function handleBeforeUnload(event: BeforeUnloadEvent): void {
if (!shouldConfirmBeforeLeavingWizard()) {
return
}
event.preventDefault()
event.returnValue = ''
}
function hasCompetitorDraftContent(item: DraftCompetitor): boolean {
return Boolean(item.name.trim() || item.website.trim() || item.description.trim())
}
function hasOutlineDraftContent(item: OutlineDraftNode): boolean {
return Boolean(item.outline.trim()) || item.children.some(hasOutlineDraftContent)
}
function nextCompetitorKey(): string {
competitorKeySeed += 1
return `competitor-${competitorKeySeed}`
@@ -1086,14 +1189,9 @@ function handleGenerate(): void {
})
}
async function handleSaveDraftAndExit(): Promise<void> {
async function saveWizardDraft(): Promise<boolean> {
if (!enabled.value || savingDraft.value) {
return
}
if (currentStep.value === 0) {
await router.replace('/articles/templates')
return
return false
}
savingDraft.value = true
@@ -1109,14 +1207,19 @@ async function handleSaveDraftAndExit(): Promise<void> {
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
message.success(t('templates.wizard.messages.draftSaved'))
await router.replace('/articles/templates')
return true
} catch (error) {
message.error(formatError(error) || t('templates.wizard.messages.draftSaveFailed'))
return false
} finally {
savingDraft.value = false
}
}
async function handleSaveDraftAndExit(): Promise<void> {
await router.replace('/articles/templates')
}
function dedupeStrings(values: string[]): string[] {
const seen = new Set<string>()
return values
@@ -2658,6 +2761,29 @@ function onStructureDragEnd(): void {
<p>{{ assistStages[assistStageIndex] }}</p>
</div>
</a-modal>
<a-modal
:open="leaveConfirmOpen"
:title="t('templates.wizard.leaveConfirm.title')"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
width="460px"
>
<p class="leave-confirm-text">{{ t('templates.wizard.leaveConfirm.content') }}</p>
<template #footer>
<a-button :disabled="leaveConfirmBusy" @click="handleStayOnWizard">
{{ t('templates.wizard.leaveConfirm.stay') }}
</a-button>
<a-button danger :disabled="leaveConfirmBusy" @click="handleDiscardAndLeave">
{{ t('templates.wizard.leaveConfirm.discard') }}
</a-button>
<a-button type="primary" :loading="leaveConfirmBusy" @click="handleSaveDraftAndLeave">
{{ t('templates.wizard.leaveConfirm.save') }}
</a-button>
</template>
</a-modal>
</div>
</template>
@@ -2669,6 +2795,12 @@ function onStructureDragEnd(): void {
background: #fff;
}
.leave-confirm-text {
margin: 0;
color: #52627a;
line-height: 1.7;
}
.wizard-page__loading {
padding: 40px;
}
+12
View File
@@ -0,0 +1,12 @@
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui" "" "URL:Shengxintui Protocol"
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui\shell\open" "" "Open"
WriteRegStr SHELL_CONTEXT "Software\Classes\shengxintui\shell\open\command" "" `"$appExe" "%1"`
!macroend
!macro customUnInstall
DeleteRegKey SHELL_CONTEXT "Software\Classes\shengxintui"
!macroend
+5
View File
@@ -46,6 +46,10 @@ mac:
electronLanguages:
- en
- zh_CN
protocols:
- name: Shengxintui URL
schemes:
- shengxintui
extendInfo:
NSCameraUsageDescription: 用于视频会议和录屏功能
NSMicrophoneUsageDescription: 用于音频采集
@@ -72,6 +76,7 @@ nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
include: build/installer.nsh
linux:
category: Office
target:
+94 -8
View File
@@ -76,6 +76,7 @@ if (process.platform === 'win32') {
}
const isDevelopmentRuntime = !app.isPackaged
const desktopDeepLinkScheme = 'shengxintui'
function silencePackagedConsole(): void {
if (isDevelopmentRuntime) {
@@ -119,6 +120,8 @@ let mainWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let loginWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let windowModeSwitchQueue: Promise<void> = Promise.resolve()
let canHandleDesktopDeepLinks = false
const pendingDesktopDeepLinks: string[] = []
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>()
const appWindowModes = new WeakMap<ElectronBrowserWindow, RendererWindowMode>()
@@ -489,19 +492,19 @@ function queueWindowModeSwitch(
}
async function openSettingsWindow(): Promise<void> {
const window = await ensureSettingsWindow();
const parent = settingsWindowParent();
const window = await ensureSettingsWindow()
const parent = settingsWindowParent()
if (parent && window.getParentWindow() !== parent) {
window.setParentWindow(parent);
window.setParentWindow(parent)
}
await revealWindow(window);
await revealWindow(window)
}
function settingsWindowParent(): ElectronBrowserWindow | undefined {
if (process.platform !== "win32") {
return undefined;
if (process.platform !== 'win32') {
return undefined
}
return currentMainWindow() ?? undefined;
return currentMainWindow() ?? undefined
}
function rendererURL(): string | null {
@@ -713,6 +716,79 @@ function isSafeExternalUrl(url: string): boolean {
}
}
function registerDesktopProtocolClient(): void {
if (app.isDefaultProtocolClient(desktopDeepLinkScheme)) {
return
}
if (process.defaultApp && process.execPath) {
app.setAsDefaultProtocolClient(desktopDeepLinkScheme, process.execPath, [
process.argv[1] ?? '',
])
return
}
app.setAsDefaultProtocolClient(desktopDeepLinkScheme)
}
function extractDesktopDeepLink(argv: string[]): string | null {
return argv.find((item) => item.startsWith(`${desktopDeepLinkScheme}://`)) ?? null
}
function queueDesktopDeepLink(rawUrl: string | null | undefined): void {
if (!rawUrl) {
return
}
if (!canHandleDesktopDeepLinks) {
pendingDesktopDeepLinks.push(rawUrl)
return
}
handleDesktopDeepLink(rawUrl)
}
function flushPendingDesktopDeepLinks(): void {
canHandleDesktopDeepLinks = true
const links = pendingDesktopDeepLinks.splice(0)
for (const link of links) {
handleDesktopDeepLink(link)
}
}
function handleDesktopDeepLink(rawUrl: string): void {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return
}
if (parsed.protocol !== `${desktopDeepLinkScheme}:` || parsed.hostname !== 'desktop') {
return
}
if (
parsed.pathname === '/workbench' ||
parsed.searchParams.get('action') === 'open-publish-account-console'
) {
const account = {
id: parsed.searchParams.get('account_id') ?? '',
platform: parsed.searchParams.get('platform') ?? '',
platformUid: parsed.searchParams.get('platform_uid') ?? '',
displayName: parsed.searchParams.get('display_name') ?? '',
}
if (!account.id || !account.platform) {
return
}
void openPublishAccountConsole(account).catch((error) => {
console.warn('[desktop-deeplink] open publish account console failed', {
accountId: account.id,
platform: account.platform,
message: error instanceof Error ? error.message : String(error),
})
})
}
}
function registerBridgeHandlers(): void {
ipcMain.handle('desktop:ping', () => 'pong')
ipcMain.handle('desktop:device-info', () => resolveDesktopDeviceInfo())
@@ -830,8 +906,9 @@ function registerBridgeHandlers(): void {
})
}
const hasSingleInstanceLock = initSingleInstance(() => {
const hasSingleInstanceLock = initSingleInstance((argv) => {
revealActiveWindowSafely('single-instance')
queueDesktopDeepLink(extractDesktopDeepLink(argv))
})
if (!hasSingleInstanceLock) {
@@ -842,6 +919,7 @@ if (!hasSingleInstanceLock) {
.whenReady()
.then(async () => {
suppressNativeWindowMenu()
registerDesktopProtocolClient()
currentWindowMode = readPersistedWindowMode()
initDesktopAppSettings()
registerBridgeHandlers()
@@ -864,6 +942,8 @@ if (!hasSingleInstanceLock) {
revealActiveWindowSafely('tray')
})
startDesktopHealthIndicator(() => currentMainWindow())
queueDesktopDeepLink(extractDesktopDeepLink(process.argv))
flushPendingDesktopDeepLinks()
})
.catch((error) => {
console.error('[desktop-main] app.whenReady failed', error)
@@ -873,6 +953,12 @@ if (!hasSingleInstanceLock) {
revealActiveWindowSafely('activate')
})
app.on('open-url', (event, url) => {
event.preventDefault()
revealActiveWindowSafely('deep-link')
queueDesktopDeepLink(url)
})
app.on('window-all-closed', () => {
if (getDesktopAppSettings().keepRunningInBackground) {
return
@@ -2,7 +2,7 @@ import { app } from 'electron/main'
let initialized = false
export function initSingleInstance(onSecondInstance?: () => void): boolean {
export function initSingleInstance(onSecondInstance?: (argv: string[]) => void): boolean {
if (initialized) {
return true
}
@@ -13,8 +13,8 @@ export function initSingleInstance(onSecondInstance?: () => void): boolean {
return false
}
app.on('second-instance', () => {
onSecondInstance?.()
app.on('second-instance', (_event, argv) => {
onSecondInstance?.(argv)
})
return true
+7
View File
@@ -7,6 +7,13 @@
- For desktop AI monitoring, put scheduling authority on the client, keep scraping silent/background-only, allow stale cross-day tasks to be dropped, and avoid fanning one question to all models at once.
## Research Findings
- Current 2026-05-07 publish-management request targets `apps/admin-web`: the user wants a new content-management page showing publish title, account/platform, status, task info, time, and actions for external link, opening the desktop client workbench, and retrying.
- Desktop client already has `PublishManagementView.vue` with the desired table shape and action semantics, but it calls Electron IPC (`window.desktopBridge`). Admin-web needs tenant-authenticated HTTP endpoints instead.
- Existing backend desktop endpoints are `/api/desktop/publish-tasks` and `/api/desktop/publish-tasks/:id/retry`, but they require desktop client token auth. Tenant API currently exposes publish-job creation only, not publish task list/retry.
- `DesktopTaskService.ListPublishTasks` already has the correct pagination/title-search/status ordering behavior, but accepts a desktop client context. A tenant variant should scope by request actor/current workspace and created-by user.
- `PublishJobService.RetryByDesktopTask` already contains retry reconstruction logic, but accepts desktop client context. A tenant variant can reuse the same core flow with actor workspace/user ownership checks.
- Admin-web content-management nav currently includes media, knowledge, and images under `AppShell.vue`; publish management should join that group as `/publish-management`.
- Web "open workbench" cannot call Electron IPC directly. A desktop deep-link URL can be used as a link-style entry; if the desktop app does not register that scheme yet, the link is still a front-end contract for the desktop client to adopt.
- Current review scope: determine whether the monitoring data detail path and the daily scheduled generation of question collection tasks are working.
- `cmd/scheduler` starts `ScheduleDispatchWorker`, monitoring result recovery, lease recovery, received inspection, and knowledge cleanup; it does not start a worker that generates daily `monitoring_collect_tasks`.
- The only production code path found so far that inserts `monitoring_collect_tasks` is `MonitoringService.CollectNow` via `ensureCollectNowTasks`; this writes `trigger_source = 'manual'`.
+2
View File
@@ -241,6 +241,8 @@ export interface DesktopPublishTaskListResponse {
history_total: number
}
export type TenantPublishTaskListResponse = DesktopPublishTaskListResponse
export interface LeaseDesktopTaskRequest {
task_id?: string
kind?: 'publish' | 'monitor'
+7
View File
@@ -807,3 +807,10 @@
- `git diff --check`
- Caveat:
- `pnpm --filter @geo/desktop-client test -- src/main/device-id.test.ts` ran unrelated existing tests and failed in `deepseek.test.ts` due to a pre-existing missing `electron/main` mock; the new device-id test passes when run directly with vitest.
## 2026-05-07T11:26:26+08:00 - Admin-web publish management start
- Started Phase 48/49 for the user's content-management "发文管理" request.
- Confirmed desktop-client already has a publish management table with the requested columns/actions, but admin-web needs tenant HTTP APIs because Electron IPC is unavailable in the browser.
- Scoped implementation to tenant publish task list/retry endpoints plus a new admin-web `/publish-management` page under 内容管理.
- Noted existing dirty files in i18n/Knowledge/TemplateWizard/config/swagger/knowledge service and will avoid reverting or overwriting unrelated changes.
@@ -150,6 +150,7 @@ var routeDocs = map[string]routeDoc{
"POST /api/tenant/articles": {"创建文章", "手动创建一篇空文章草稿。"},
"POST /api/tenant/articles/generate-from-rule": {"根据 Prompt 规则生成文章", "基于已配置的 Prompt 规则生成一篇文章。"},
"POST /api/tenant/articles/imitations/generate": {"仿写生成文章", "基于参考文链接/原文进行仿写并生成新文章。"},
"POST /api/tenant/articles/:id/regenerate": {"重新生成文章", "基于文章原始生成参数重新入队生成,并返回新的生成任务。"},
"POST /api/tenant/articles/:id/images": {"上传文章配图", "Multipart 上传文章正文使用的图片。"},
"POST /api/tenant/articles/:id/selection-optimize/stream": {"选区优化(SSE 流式)", "针对用户选中的段落做润色/改写,结果流式返回。"},
"GET /api/tenant/articles/:id": {"文章详情", "返回文章的完整结构(标题、大纲、正文、版本号等)。"},
@@ -141,6 +141,12 @@ type DesktopPublishTaskList struct {
HistoryTotal int `json:"history_total"`
}
type publishTaskListOwner struct {
TenantID int64
WorkspaceID int64
UserID int64
}
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
@@ -858,6 +864,26 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
return s.listPublishTasksForOwner(ctx, publishTaskListOwner{
TenantID: client.TenantID,
WorkspaceID: client.WorkspaceID,
UserID: client.UserID,
}, req)
}
func (s *DesktopTaskService) ListTenantPublishTasks(ctx context.Context, actor auth.Actor, req ListPublishTasksRequest) (*DesktopPublishTaskList, error) {
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
return s.listPublishTasksForOwner(ctx, publishTaskListOwner{
TenantID: actor.TenantID,
WorkspaceID: workspaceID,
UserID: actor.UserID,
}, req)
}
func (s *DesktopTaskService) listPublishTasksForOwner(ctx context.Context, owner publishTaskListOwner, req ListPublishTasksRequest) (*DesktopPublishTaskList, error) {
if s.pool == nil {
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
}
@@ -877,7 +903,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos
pendingItems, err := s.listPublishTasksByStatuses(
ctx,
client,
owner,
pendingStatuses,
title,
0,
@@ -888,7 +914,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos
return nil, err
}
historyTotal, err := s.countPublishTasksByStatuses(ctx, client, historyStatuses, title)
historyTotal, err := s.countPublishTasksByStatuses(ctx, owner, historyStatuses, title)
if err != nil {
return nil, err
}
@@ -914,7 +940,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos
if remaining > 0 && historyOffset < historyTotal {
historyItems, listErr := s.listPublishTasksByStatuses(
ctx,
client,
owner,
historyStatuses,
title,
remaining,
@@ -939,7 +965,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos
func (s *DesktopTaskService) listPublishTasksByStatuses(
ctx context.Context,
client *repository.DesktopClient,
owner publishTaskListOwner,
statuses []string,
title string,
limit int,
@@ -974,22 +1000,24 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
JOIN desktop_publish_jobs AS j
ON j.desktop_id = t.job_id
AND j.workspace_id = t.workspace_id
WHERE t.workspace_id = $1
AND j.created_by_user_id = $2
WHERE t.tenant_id = $1
AND t.workspace_id = $2
AND j.tenant_id = $1
AND j.created_by_user_id = $3
AND t.kind = 'publish'
AND t.status = ANY($3)
AND t.status = ANY($4)
AND (
$4 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $4 || '%'
$5 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
)
`
args := []any{client.WorkspaceID, client.UserID, statuses, title}
args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
if strings.TrimSpace(orderBy) != "" {
query += "\nORDER BY " + orderBy
}
if limit > 0 {
query += "\nLIMIT $5 OFFSET $6"
query += "\nLIMIT $6 OFFSET $7"
args = append(args, limit, offset)
}
@@ -1004,7 +1032,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
func (s *DesktopTaskService) countPublishTasksByStatuses(
ctx context.Context,
client *repository.DesktopClient,
owner publishTaskListOwner,
statuses []string,
title string,
) (int, error) {
@@ -1015,15 +1043,17 @@ func (s *DesktopTaskService) countPublishTasksByStatuses(
JOIN desktop_publish_jobs AS j
ON j.desktop_id = t.job_id
AND j.workspace_id = t.workspace_id
WHERE t.workspace_id = $1
AND j.created_by_user_id = $2
WHERE t.tenant_id = $1
AND t.workspace_id = $2
AND j.tenant_id = $1
AND j.created_by_user_id = $3
AND t.kind = 'publish'
AND t.status = ANY($3)
AND t.status = ANY($4)
AND (
$4 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $4 || '%'
$5 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
)
`, client.WorkspaceID, client.UserID, statuses, title).Scan(&count)
`, owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title).Scan(&count)
if err != nil {
return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
}
@@ -240,10 +240,20 @@ func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResp
}
countRows, err := s.pool.Query(ctx, `
SELECT group_id, COUNT(*)::bigint
FROM knowledge_items
WHERE tenant_id = $1 AND deleted_at IS NULL
GROUP BY group_id
SELECT ki.group_id, COUNT(DISTINCT ki.id)::bigint
FROM knowledge_items ki
WHERE ki.tenant_id = $1
AND ki.deleted_at IS NULL
AND ki.status = 'completed'
AND EXISTS (
SELECT 1
FROM knowledge_chunks_meta kcm
WHERE kcm.tenant_id = ki.tenant_id
AND kcm.knowledge_item_id = ki.id
AND kcm.item_version = ki.item_version
AND kcm.status = 'active'
)
GROUP BY ki.group_id
`, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50050, "knowledge_group_counts_query_failed", "failed to list knowledge group counts")
@@ -414,6 +414,64 @@ func (s *PublishJobService) RetryByDesktopTask(
})
}
func (s *PublishJobService) RetryTenantDesktopTask(
ctx context.Context,
actor auth.Actor,
taskID uuid.UUID,
) (*CreatePublishJobResponse, error) {
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
if s.pool == nil {
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
}
taskRepo := repository.NewDesktopTaskRepository(s.pool)
task, err := taskRepo.GetByDesktopID(ctx, taskID, workspaceID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
}
return nil, response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state")
}
if task.Kind != "publish" {
return nil, response.ErrBadRequest(40095, "desktop_task_not_publish", "only publish tasks can be retried")
}
if err := s.authorizePublishTaskForActor(ctx, actor, workspaceID, task); err != nil {
return nil, err
}
if task.Status == "queued" || task.Status == "in_progress" {
return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running")
}
payload := unmarshalJSONObject(task.Payload)
articleID, err := s.resolveRetryArticleID(ctx, actor.TenantID, payload)
if err != nil {
return nil, err
}
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
if title == "" {
title = "文章发布"
}
return s.Create(ctx, auth.Actor{
TenantID: actor.TenantID,
UserID: actor.UserID,
PrimaryWorkspaceID: workspaceID,
Role: actor.Role,
}, CreatePublishJobRequest{
Title: title,
ContentRef: map[string]any{
"article_id": articleID,
},
Accounts: []CreatePublishJobAccountRequest{
{AccountID: task.TargetAccountID.String()},
},
})
}
func (s *PublishJobService) authorizePublishTaskForClientUser(
ctx context.Context,
client *repository.DesktopClient,
@@ -442,6 +500,36 @@ func (s *PublishJobService) authorizePublishTaskForClientUser(
return nil
}
func (s *PublishJobService) authorizePublishTaskForActor(
ctx context.Context,
actor auth.Actor,
workspaceID int64,
task *repository.DesktopTask,
) error {
if task == nil || actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
return response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
var ownsTask bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM desktop_publish_jobs
WHERE tenant_id = $1
AND desktop_id = $2
AND workspace_id = $3
AND created_by_user_id = $4
)
`, actor.TenantID, task.JobID, workspaceID, actor.UserID).Scan(&ownsTask)
if err != nil {
return response.ErrInternal(50115, "desktop_publish_job_lookup_failed", "failed to validate desktop publish task ownership")
}
if !ownsTask {
return response.ErrForbidden(40384, "desktop_publish_task_not_owned", "desktop publish task belongs to another user")
}
return nil
}
func (s *PublishJobService) resolveRetryArticleID(ctx context.Context, tenantID int64, payload map[string]any) (int64, error) {
if articleID, ok := resolveRetryArticleIDFromPayload(payload); ok {
return articleID, nil
@@ -65,34 +65,10 @@ func (h *DesktopTaskHandler) Lease(c *gin.Context) {
}
func (h *DesktopTaskHandler) ListPublish(c *gin.Context) {
req := app.ListPublishTasksRequest{
Page: 1,
PageSize: 10,
Title: strings.TrimSpace(c.Query("title")),
req, ok := parseListPublishTasksRequest(c)
if !ok {
return
}
if rawPage := c.Query("page"); rawPage != "" {
parsed, err := strconv.Atoi(rawPage)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
return
}
req.Page = parsed
}
rawPageSize := c.Query("page_size")
if rawPageSize == "" {
rawPageSize = c.Query("limit")
}
if rawPageSize != "" {
parsed, err := strconv.Atoi(rawPageSize)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
return
}
req.PageSize = parsed
}
data, err := h.svc.ListPublishTasks(c.Request.Context(), MustDesktopClient(c.Request.Context()), req)
if err != nil {
response.Error(c, err)
@@ -101,6 +77,19 @@ func (h *DesktopTaskHandler) ListPublish(c *gin.Context) {
response.Success(c, data)
}
func (h *DesktopTaskHandler) TenantListPublish(c *gin.Context) {
req, ok := parseListPublishTasksRequest(c)
if !ok {
return
}
data, err := h.svc.ListTenantPublishTasks(c.Request.Context(), auth.MustActor(c.Request.Context()), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Extend(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -158,6 +147,21 @@ func (h *DesktopTaskHandler) RetryPublish(c *gin.Context) {
response.Success(c, data)
}
func (h *DesktopTaskHandler) TenantRetryPublish(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
data, err := h.publishSvc.RetryTenantDesktopTask(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Cancel(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -179,6 +183,38 @@ func (h *DesktopTaskHandler) Cancel(c *gin.Context) {
response.Success(c, data)
}
func parseListPublishTasksRequest(c *gin.Context) (app.ListPublishTasksRequest, bool) {
req := app.ListPublishTasksRequest{
Page: 1,
PageSize: 10,
Title: strings.TrimSpace(c.Query("title")),
}
if rawPage := c.Query("page"); rawPage != "" {
parsed, err := strconv.Atoi(rawPage)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
return req, false
}
req.Page = parsed
}
rawPageSize := c.Query("page_size")
if rawPageSize == "" {
rawPageSize = c.Query("limit")
}
if rawPageSize != "" {
parsed, err := strconv.Atoi(rawPageSize)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
return req, false
}
req.PageSize = parsed
}
return req, true
}
func (h *DesktopTaskHandler) TenantCancel(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -74,6 +74,8 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
tenantProtected.POST("/publish-tasks/:id/retry", desktopTaskHandler.TenantRetryPublish)
tenantProtected.POST("/publish-jobs", publishJobHandler.Create)
complianceHandler := NewComplianceHandler(app)
@@ -30,6 +30,8 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
{http.MethodPost, "/api/tenant/accounts/:id/request-delete"},
{http.MethodPost, "/api/tenant/tasks/:id/reconcile"},
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
{http.MethodGet, "/api/tenant/publish-tasks"},
{http.MethodPost, "/api/tenant/publish-tasks/:id/retry"},
{http.MethodPost, "/api/tenant/publish-jobs"},
{http.MethodGet, "/api/tenant/media/platforms"},
{http.MethodGet, "/api/tenant/workspace/overview"},
@@ -77,10 +79,12 @@ func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) {
}{
{http.MethodGet, "/api/desktop/dispatch"},
{http.MethodGet, "/api/desktop/accounts"},
{http.MethodGet, "/api/desktop/publish-tasks"},
{http.MethodGet, "/api/desktop/content/assets/:token"},
{http.MethodPost, "/api/desktop/clients/offline"},
{http.MethodPost, "/api/desktop/tasks/lease"},
{http.MethodPost, "/api/desktop/tasks/:id/result"},
{http.MethodPost, "/api/desktop/publish-tasks/:id/retry"},
{http.MethodPost, "/api/desktop/monitoring/tasks/lease"},
{http.MethodPost, "/api/desktop/monitoring/tasks/resume"},
{http.MethodPost, "/api/desktop/monitoring/tasks/:id/result"},
+12
View File
@@ -329,6 +329,18 @@ Phase 47
- [x] Record residual test caveats
- **Status:** complete
### Phase 48: Admin-Web Publish Management
- [ ] Expose tenant-authenticated publish task listing and retry APIs
- [ ] Add admin-web publish-management API client, route, nav entry, and i18n labels
- [ ] Build the content-management publish management page with title, account/platform, status, task info, time, and action controls
- **Status:** in_progress
### Phase 49: Publish Management Verification
- [ ] Run targeted backend and frontend checks
- [ ] Record changed files, test results, and remaining desktop deep-link caveats
- [ ] Deliver the completed scope to the user
- **Status:** pending
## Key Questions
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?