Compare commits

...

4 Commits

Author SHA1 Message Date
root 8300d12b20 chore(desktop-client): bump version to 0.1.9
Desktop Client Build / Resolve Build Metadata (push) Successful in 30s
Frontend CI / Frontend (push) Successful in 3m28s
Backend CI / Backend (push) Failing after 11m56s
Desktop Client Build / Build Desktop Client (push) Successful in 29m6s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:39:18 +08:00
root 1bc4df958f refactor(admin-web): drop recent-articles fallback from templates records
Remove the recentArticlesQuery fallback path so the templates record
table renders straight from the article list query, simplifying polling,
pagination and loading state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:39:18 +08:00
root e90d6a185f feat(tenant): derive recent article publish status from publish records
GetRecentArticlesByBrand now folds the latest publish record per target
into an aggregated publish_status (publishing / partial_success / success
/ failed), falling back to the stored status when no records exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:39:07 +08:00
root 58a9b379bc feat(tenant): dedup article publish status by latest record per target
Aggregate publish status from the latest record per publishing target
using a shared target-identity expression, so retries and multi-record
targets no longer inflate the status buckets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:38:59 +08:00
7 changed files with 150 additions and 106 deletions
+13 -91
View File
@@ -14,7 +14,6 @@ import type {
ArticleListItem,
ArticleListParams,
KolWorkspaceCard,
RecentArticle,
TemplateListItem,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -94,17 +93,6 @@ const kolCardsQuery = useQuery({
queryFn: () => workspaceApi.kolCards(),
})
const recentArticlesQuery = useQuery({
queryKey: computed(() => [
'workspace',
'recent-articles',
'templates',
companyStore.currentBrandId,
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => workspaceApi.recentArticles(),
})
const articleParams = computed<ArticleListParams>(() => {
const params: ArticleListParams = {
page: page.value,
@@ -147,65 +135,11 @@ const articleListQuery = useQuery({
queryFn: () => articlesApi.list(articleParams.value),
})
const hasActiveRecordFilters = computed(() =>
Boolean(
appliedFilters.template_filter ||
appliedFilters.publish_status ||
appliedFilters.generate_status ||
appliedFilters.keyword?.trim() ||
appliedFilters.created_from ||
appliedFilters.created_to,
),
)
const recentTemplateArticles = computed<ArticleListItem[]>(() =>
(recentArticlesQuery.data.value || [])
.filter(
(article: RecentArticle) =>
article.source_type === 'template' || article.source_type === 'kol',
)
.map((article: RecentArticle) => ({
id: article.id,
source_type: article.source_type,
template_id: null,
kol_prompt_id: null,
template_name:
article.template_name ||
(article.source_type === 'kol'
? t('templates.sections.refinedTemplates')
: t('templates.sections.generalTemplates')),
prompt_rule_id: null,
prompt_rule_name: null,
generation_mode: article.generation_mode,
auto_publish_platforms: [],
generate_status: article.generate_status,
publish_status: article.publish_status,
title: article.title,
generation_error_message: null,
word_count: article.word_count,
source_label: article.source_label,
created_at: article.created_at,
})),
)
const shouldUseRecentFallback = computed(
() =>
!hasActiveRecordFilters.value &&
(articleListQuery.data.value?.total ?? 0) === 0 &&
recentTemplateArticles.value.length > 0,
)
const displayedArticles = computed<ArticleListItem[]>(() =>
shouldUseRecentFallback.value
? recentTemplateArticles.value
: articleListQuery.data.value?.items || [],
articleListQuery.data.value?.items || [],
)
const displayedArticleTotal = computed(() =>
shouldUseRecentFallback.value
? recentTemplateArticles.value.length
: articleListQuery.data.value?.total || 0,
)
const displayedArticleTotal = computed(() => articleListQuery.data.value?.total || 0)
let articlePollingTimer: number | null = null
const ARTICLE_LIST_POLL_INTERVAL_MS = 5000
@@ -287,18 +221,14 @@ const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
},
])
const articlePagination = computed(() =>
shouldUseRecentFallback.value
? false
: {
current: page.value,
pageSize: pageSize.value,
total: displayedArticleTotal.value,
showSizeChanger: true,
onChange: handleTableChange,
onShowSizeChange: handleTableChange,
},
)
const articlePagination = computed(() => ({
current: page.value,
pageSize: pageSize.value,
total: displayedArticleTotal.value,
showSizeChanger: true,
onChange: handleTableChange,
onShowSizeChange: handleTableChange,
}))
// removed wizard watch effect
@@ -449,13 +379,6 @@ function startArticlePolling(): void {
}
articlePollingTimer = window.setInterval(() => {
if (shouldUseRecentFallback.value) {
if (!recentArticlesQuery.isFetching.value) {
void recentArticlesQuery.refetch()
}
return
}
if (!articleListQuery.isFetching.value) {
void articleListQuery.refetch()
}
@@ -493,7 +416,7 @@ async function copyArticle(articleId: number): Promise<void> {
}
function refreshRecords(): void {
void Promise.all([articleListQuery.refetch(), recentArticlesQuery.refetch()])
void articleListQuery.refetch()
}
</script>
@@ -602,7 +525,7 @@ function refreshRecords(): void {
class="templates-view__desktop-table"
:columns="articleColumns"
:data-source="displayedArticles"
:loading="articleListQuery.isPending.value || recentArticlesQuery.isPending.value"
:loading="articleListQuery.isPending.value"
row-key="id"
:pagination="articlePagination"
:scroll="{ x: 1068 }"
@@ -657,7 +580,7 @@ function refreshRecords(): void {
<div class="templates-view__mobile-records">
<a-skeleton
v-if="articleListQuery.isPending.value || recentArticlesQuery.isPending.value"
v-if="articleListQuery.isPending.value"
active
:paragraph="{ rows: 6 }"
/>
@@ -720,7 +643,6 @@ function refreshRecords(): void {
</article>
<a-pagination
v-if="!shouldUseRecentFallback"
class="templates-view__mobile-pagination"
:current="page"
:page-size="pageSize"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@geo/desktop-client",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": {
@@ -110,24 +110,18 @@ const articlePublishStatusAggregateJoin = `
BOOL_OR(latest.status_bucket = 'success') AS has_success,
BOOL_OR(latest.status_bucket = 'failed') AS has_failed
FROM (
SELECT DISTINCT ON (
COALESCE(pr.target_type, 'platform_account'),
COALESCE(pr.platform_account_id, pr.target_connection_id)
)
SELECT DISTINCT ON (` + publishRecordTargetIdentityExprSQL + `)
CASE
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
ELSE 'failed'
END AS status_bucket
FROM publish_records pr
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
WHERE pr.tenant_id = a.tenant_id
AND pr.article_id = a.id
AND pr.deleted_at IS NULL
ORDER BY
COALESCE(pr.target_type, 'platform_account'),
COALESCE(pr.platform_account_id, pr.target_connection_id),
pr.created_at DESC,
pr.id DESC
ORDER BY ` + publishRecordTargetIdentityExprSQL + `, pr.created_at DESC, pr.id DESC
) latest
) publish_status_summary ON true`
@@ -45,6 +45,12 @@ type monitoringArticleAliasInput struct {
ExternalManageURL *string
}
const publishRecordTargetIdentityExprSQL = `CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN 'enterprise_site:' || COALESCE(pr.target_connection_id::TEXT, '')
ELSE 'platform_account:' || pr.platform_id || ':' || COALESCE(NULLIF(pa.platform_uid, ''), pr.platform_account_id::TEXT, '')
END`
func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, brandID, articleID int64) (*publishableArticleMeta, error) {
var generateStatus string
var wizardStateJSON []byte
@@ -664,9 +670,14 @@ func recalculatePublishBatchStatus(ctx context.Context, tx pgx.Tx, publishBatchI
func recalculateArticlePublishStatus(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (string, error) {
rows, err := tx.Query(ctx, `
SELECT status
FROM publish_records
WHERE tenant_id = $1 AND article_id = $2
AND deleted_at IS NULL
FROM (
SELECT DISTINCT ON (`+publishRecordTargetIdentityExprSQL+`) pr.status
FROM publish_records pr
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2
AND pr.deleted_at IS NULL
ORDER BY `+publishRecordTargetIdentityExprSQL+`, pr.created_at DESC, pr.id DESC
) latest
`, tenantID, articleID)
if err != nil {
return "", response.ErrInternal(50055, "article_publish_status_query_failed", "failed to inspect article publish status")
@@ -231,3 +231,40 @@ func TestRecalculateArticlePublishStatusDefaultsToUnpublished(t *testing.T) {
t.Fatalf("recalculateArticlePublishStatus() = %q, want unpublished", got)
}
}
func TestRecalculateArticlePublishStatusUsesLatestRecordPerPublishTarget(t *testing.T) {
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
_, err := recalculateArticlePublishStatus(context.Background(), tx, 1, 2)
if err != nil {
t.Fatalf("recalculateArticlePublishStatus() error = %v", err)
}
normalized := normalizeSQLWhitespace(tx.sql)
for _, want := range []string{
"SELECT DISTINCT ON",
"LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id",
"pa.platform_uid",
"pr.platform_id",
"pr.created_at DESC, pr.id DESC",
} {
if !strings.Contains(normalized, want) {
t.Fatalf("recalculate article publish status SQL missing %q: %s", want, normalized)
}
}
}
func TestArticlePublishStatusAggregateUsesLatestRecordPerPublishTarget(t *testing.T) {
normalized := normalizeSQLWhitespace(articlePublishStatusAggregateJoin)
for _, want := range []string{
"SELECT DISTINCT ON",
"LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id",
"pa.platform_uid",
"pr.platform_id",
"pr.created_at DESC, pr.id DESC",
} {
if !strings.Contains(normalized, want) {
t.Fatalf("article publish aggregate SQL missing %q: %s", want, normalized)
}
}
}
@@ -104,7 +104,16 @@ func (q *Queries) GetQuotaSummary(ctx context.Context, tenantID int64) (int32, e
}
const getRecentArticlesByBrand = `-- name: GetRecentArticlesByBrand :many
SELECT a.id, a.brand_id, a.generate_status, a.publish_status, a.source_type, a.created_at,
SELECT a.id, a.brand_id, a.generate_status,
CASE
WHEN COALESCE(publish_status_summary.record_count, 0) = 0 THEN a.publish_status
WHEN COALESCE(publish_status_summary.has_publishing, false) THEN 'publishing'
WHEN COALESCE(publish_status_summary.has_success, false)
AND COALESCE(publish_status_summary.has_failed, false) THEN 'partial_success'
WHEN COALESCE(publish_status_summary.has_success, false) THEN 'success'
ELSE 'failed'
END AS publish_status,
a.source_type, a.created_at,
av.title, av.markdown_content, av.word_count, av.source_label,
t.template_name,
COALESCE(
@@ -114,6 +123,37 @@ SELECT a.id, a.brand_id, a.generate_status, a.publish_status, a.source_type, a.c
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS record_count,
BOOL_OR(latest.status_bucket = 'publishing') AS has_publishing,
BOOL_OR(latest.status_bucket = 'success') AS has_success,
BOOL_OR(latest.status_bucket = 'failed') AS has_failed
FROM (
SELECT DISTINCT ON (
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN 'enterprise_site:' || COALESCE(pr.target_connection_id::TEXT, '')
ELSE 'platform_account:' || pr.platform_id || ':' || COALESCE(NULLIF(pa.platform_uid, ''), pr.platform_account_id::TEXT, '')
END
)
CASE
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
ELSE 'failed'
END AS status_bucket,
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN 'enterprise_site:' || COALESCE(pr.target_connection_id::TEXT, '')
ELSE 'platform_account:' || pr.platform_id || ':' || COALESCE(NULLIF(pa.platform_uid, ''), pr.platform_account_id::TEXT, '')
END AS target_identity
FROM publish_records pr
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
WHERE pr.tenant_id = a.tenant_id
AND pr.article_id = a.id
AND pr.deleted_at IS NULL
ORDER BY target_identity, pr.created_at DESC, pr.id DESC
) latest
) publish_status_summary ON true
LEFT JOIN LATERAL (
SELECT input_params_json
FROM generation_tasks
@@ -11,7 +11,16 @@ SELECT COUNT(*) FROM brands
WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
-- name: GetRecentArticlesByBrand :many
SELECT a.id, a.brand_id, a.generate_status, a.publish_status, a.source_type, a.created_at,
SELECT a.id, a.brand_id, a.generate_status,
CASE
WHEN COALESCE(publish_status_summary.record_count, 0) = 0 THEN a.publish_status
WHEN COALESCE(publish_status_summary.has_publishing, false) THEN 'publishing'
WHEN COALESCE(publish_status_summary.has_success, false)
AND COALESCE(publish_status_summary.has_failed, false) THEN 'partial_success'
WHEN COALESCE(publish_status_summary.has_success, false) THEN 'success'
ELSE 'failed'
END AS publish_status,
a.source_type, a.created_at,
av.title, av.markdown_content, av.word_count, av.source_label,
t.template_name,
COALESCE(
@@ -21,6 +30,37 @@ SELECT a.id, a.brand_id, a.generate_status, a.publish_status, a.source_type, a.c
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS record_count,
BOOL_OR(latest.status_bucket = 'publishing') AS has_publishing,
BOOL_OR(latest.status_bucket = 'success') AS has_success,
BOOL_OR(latest.status_bucket = 'failed') AS has_failed
FROM (
SELECT DISTINCT ON (
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN 'enterprise_site:' || COALESCE(pr.target_connection_id::TEXT, '')
ELSE 'platform_account:' || pr.platform_id || ':' || COALESCE(NULLIF(pa.platform_uid, ''), pr.platform_account_id::TEXT, '')
END
)
CASE
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
ELSE 'failed'
END AS status_bucket,
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN 'enterprise_site:' || COALESCE(pr.target_connection_id::TEXT, '')
ELSE 'platform_account:' || pr.platform_id || ':' || COALESCE(NULLIF(pa.platform_uid, ''), pr.platform_account_id::TEXT, '')
END AS target_identity
FROM publish_records pr
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
WHERE pr.tenant_id = a.tenant_id
AND pr.article_id = a.id
AND pr.deleted_at IS NULL
ORDER BY target_identity, pr.created_at DESC, pr.id DESC
) latest
) publish_status_summary ON true
LEFT JOIN LATERAL (
SELECT input_params_json
FROM generation_tasks