Compare commits

...

4 Commits

Author SHA1 Message Date
root 0f4310c345 fix(admin-web): prevent source link overflow in article drawer
Frontend CI / Frontend (push) Has been cancelled
Backend CI / Backend (push) Has been cancelled
将来源链接外层改成 grid wrapper 并固定图标 flex-shrink,
避免长 URL 把图标挤出布局或超过描述列宽。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:22:05 +08:00
root 764abf32ae refactor(desktop-client): tidy PublishManagementView copy and errors
将队列提示与英文 eyebrow 全部本地化为中文,并改为通过
unwrapClientErrorMessage + normalizeTaskErrorMessage 统一兜底,
避免不同操作回退到原始 Error.message 时漏掉发布器归一化结果。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:22:03 +08:00
root 7b6ae0f6d0 feat(desktop-client): expand publisher error normalization
新增登录态失效与微信公众号外链缺失两类发布错误归一化,
将原本散落在适配器里的中文提示集中到 shared/publisher-errors,
让渲染层与 main 进程共用同一份用户可读文案。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:21:54 +08:00
root 3436d9536d feat(publish): require cover image for WeChat Official Account
WeChat 公众号在管理员/运营者授权发布场景下需要 2.35:1 封面,
新增共享 publishPlatformRequiresCover 校验并同步前端封面要求与文案,
避免在调度与即时发布两条路径上重复维护平台名单。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:21:46 +08:00
12 changed files with 179 additions and 33 deletions
@@ -301,21 +301,23 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
:label="t('article.meta.sourceArticle')"
:span="2"
>
<a
class="article-drawer__source-link"
:href="sourceArticleURL"
target="_blank"
rel="noopener noreferrer"
:title="sourceArticleURL"
>
<LinkOutlined />
<span class="article-drawer__source-copy">
<span v-if="sourceArticleTitle" class="article-drawer__source-title">
{{ sourceArticleTitle }}
<div class="article-drawer__source-wrapper">
<a
class="article-drawer__source-link"
:href="sourceArticleURL"
target="_blank"
rel="noopener noreferrer"
:title="sourceArticleURL"
>
<LinkOutlined class="article-drawer__source-icon" />
<span class="article-drawer__source-copy">
<span v-if="sourceArticleTitle" class="article-drawer__source-title">
{{ sourceArticleTitle }}
</span>
<span class="article-drawer__source-url">{{ sourceArticleURL }}</span>
</span>
<span class="article-drawer__source-url">{{ sourceArticleURL }}</span>
</span>
</a>
</a>
</div>
</a-descriptions-item>
</a-descriptions>
</section>
@@ -459,14 +461,24 @@ async function copyPublishLink(record: PublishRecord): Promise<void> {
margin-top: 24px;
}
.article-drawer__source-wrapper {
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
}
.article-drawer__source-link {
display: inline-flex;
max-width: 100%;
display: flex;
width: 100%;
align-items: center;
gap: 6px;
color: #1677ff;
}
.article-drawer__source-icon {
flex-shrink: 0;
}
.article-drawer__source-copy {
display: flex;
min-width: 0;
+1 -1
View File
@@ -1101,7 +1101,7 @@ const enUS = {
previewHintLocked: "A selected platform requires a fixed cover ratio, so the cover will be exported using the current platform requirement.",
previewHintFree: "There is no forced cover ratio right now. You can upload the original image directly or adjust framing before confirming.",
requirementsTitle: "Platform requirements",
requirementsHint: "Requirements switch automatically based on the selected publish platforms. Baijiahao and Dongchedi require a cover image.",
requirementsHint: "Requirements switch automatically based on the selected publish platforms. WeChat Official Account, Baijiahao, and Dongchedi require a cover image.",
recommendedRatio: "Recommended ratio",
recommendedSize: "Recommended size",
required: "Required",
+1 -1
View File
@@ -1110,7 +1110,7 @@ const zhCN = {
previewHintLocked: "已选择需要固定封面比例的平台,确认时会按当前平台要求输出封面。",
previewHintFree: "当前不限制封面比例,可直接上传原图;如需调整也可拖拽缩放后再确认。",
requirementsTitle: "平台封面要求",
requirementsHint: "会根据当前选中的发布平台自动切换要求,百家号和懂车帝为强制上传。",
requirementsHint: "会根据当前选中的发布平台自动切换要求,微信公众号、百家号和懂车帝为强制上传。",
recommendedRatio: "推荐比例",
recommendedSize: "推荐尺寸",
required: "必传",
@@ -24,6 +24,15 @@ const defaultRequirement = {
};
const requirementCatalog: Record<string, Partial<PlatformCoverRequirement>> = {
weixin_gzh: {
required: true,
supportMode: "native",
aspectRatio: 2.35,
aspectLabel: "2.35:1",
outputWidth: 1200,
outputHeight: 511,
priority: 110,
},
baijiahao: {
required: true,
supportMode: "native",
@@ -8,6 +8,10 @@ import {
sessionFetchText,
uploadHtmlImages,
} from "./common";
import {
WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
normalizeWeixinGzhPublishErrorMessage,
} from "../../shared/publisher-errors";
import {
fetchImageAssetBlob,
getCenteredCropRect,
@@ -1932,10 +1936,10 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
if (message.startsWith("weixin_gzh_public_url_missing")) {
return {
status: "failed",
summary: "微信公众号已提交发布,但未能获取外部文章链接,请在公众号后台发表记录确认。",
summary: WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
error: {
code: "weixin_gzh_public_url_missing",
message,
message: normalizeWeixinGzhPublishErrorMessage(message) ?? WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
},
};
}
@@ -14,6 +14,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import StatusBadge from "../components/StatusBadge.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { unwrapClientErrorMessage } from "../lib/client-errors";
import { formatDateTime, formatRelativeTime } from "../lib/formatters";
import { desktopPlatformShortName, desktopPublishMediaCatalog, translateDesktopPlatform } from "../lib/media-catalog";
import { normalizePublisherErrorMessage } from "../../shared/publisher-errors";
@@ -199,7 +200,7 @@ function summaryForTask(
): string {
switch (status) {
case "queued":
return "任务已进入队列,前面的 PublishTasks 翻完后再继续展示历史记录。";
return "任务已进入队列,前面的发布任务完成后继续执行。";
case "in_progress":
return "当前正在由桌面端执行发送,请避免重复触发。";
case "succeeded":
@@ -277,7 +278,7 @@ async function refreshTasks(page = currentPage.value) {
taskPage.value = response;
currentPage.value = response.page;
} catch (err) {
error.value = err instanceof Error ? err.message : "desktop publish tasks unavailable";
error.value = normalizeTaskErrorMessage(unwrapClientErrorMessage(err, "发布任务暂时不可用"));
} finally {
loading.value = false;
}
@@ -291,7 +292,7 @@ async function retryTask(taskId: string) {
notifySuccess("已重新加入发布队列");
await refreshTasks();
} catch (err) {
notifyActionError(err instanceof Error ? err.message : "重新发送失败");
notifyActionError(normalizeTaskErrorMessage(unwrapClientErrorMessage(err, "重新发送失败")) ?? "重新发送失败");
} finally {
actionPendingTaskId.value = null;
}
@@ -305,7 +306,7 @@ async function openExternalUrl(url: string | null) {
try {
await window.desktopBridge.app.openExternalUrl(url);
} catch (err) {
notifyActionError(err instanceof Error ? err.message : "打开外链失败");
notifyActionError(normalizeTaskErrorMessage(unwrapClientErrorMessage(err, "打开外链失败")) ?? "打开外链失败");
}
}
@@ -321,7 +322,7 @@ async function openAccountWorkbench(record: PublishTaskItem) {
displayName: account?.displayName ?? record.accountName,
});
} catch (err) {
notifyActionError(err instanceof Error ? err.message : "打开账号工作台失败");
notifyActionError(normalizeTaskErrorMessage(unwrapClientErrorMessage(err, "打开账号工作台失败")) ?? "打开账号工作台失败");
} finally {
consolePendingTaskId.value = null;
}
@@ -359,7 +360,7 @@ async function copyErrorMessage(record: PublishTaskItem) {
}
}, 1_500);
} catch (err) {
notifyActionError(err instanceof Error ? err.message : "复制错误详情失败");
notifyActionError(normalizeTaskErrorMessage(unwrapClientErrorMessage(err, "复制错误详情失败")) ?? "复制错误详情失败");
}
}
@@ -470,7 +471,7 @@ const tableScroll = { x: 1080 } as const;
<section class="hero-card">
<div class="hero-header">
<div class="hero-title">
<p class="eyebrow">PUBLISH EXECUTION DESK</p>
<p class="eyebrow">发布执行台</p>
<h2>发布管理</h2>
</div>
<div class="hero-actions">
@@ -505,7 +506,7 @@ const tableScroll = { x: 1080 } as const;
<div class="hero-header table-toolbar">
<div class="toolbar-meta">
<h3 class="section-title">待发布与已发送记录</h3>
<p class="section-desc">待发布任务始终排在最前每页 10 分页与总数以服务端返回的 PublishTasks 为准</p>
<p class="section-desc">待发布任务始终排在最前每页 10 分页与总数以服务端返回的发布任务为准</p>
</div>
<div class="toolbar-filters">
<a-input
@@ -1,14 +1,18 @@
import { describe, expect, it } from "vitest";
import {
PUBLISH_ACCOUNT_NOT_LOGGED_IN_MESSAGE,
QIEHAO_REAL_NAME_AUTH_MESSAGE,
WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE,
WANGYIHAO_PUBLISH_CLICK_MESSAGE,
WANGYIHAO_TOKEN_MISSING_MESSAGE,
WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
isQiehaoRealNameAuthError,
normalizePublisherErrorMessage,
normalizePublisherLoginErrorMessage,
normalizeQiehaoPublishErrorMessage,
normalizeWangyihaoPublishErrorMessage,
normalizeWeixinGzhPublishErrorMessage,
} from "./publisher-errors";
describe("publisher error normalization", () => {
@@ -23,6 +27,24 @@ describe("publisher error normalization", () => {
expect(normalizeQiehaoPublishErrorMessage("publish rejected", -5022)).toBe(QIEHAO_REAL_NAME_AUTH_MESSAGE);
});
it("normalizes publisher login-state failures", () => {
expect(normalizePublisherErrorMessage("wangyihao_not_logged_in")).toBe(
"网易号账号登录态已失效,请重新登录该平台后再重试。",
);
expect(normalizePublisherErrorMessage("Error: weixin_gzh_not_logged_in")).toBe(
"微信公众号账号登录态已失效,请重新登录该平台后再重试。",
);
expect(normalizePublisherErrorMessage("qiehao_cookie_missing")).toBe(
"企鹅号账号登录态已失效,请重新登录该平台后再重试。",
);
});
it("uses a generic Chinese message for unknown publisher login-state failures", () => {
expect(normalizePublisherLoginErrorMessage("unknown_platform_not_logged_in")).toBe(
PUBLISH_ACCOUNT_NOT_LOGGED_IN_MESSAGE,
);
});
it("normalizes Wangyihao token missing failures", () => {
expect(normalizePublisherErrorMessage("wangyihao_token_missing")).toBe(WANGYIHAO_TOKEN_MISSING_MESSAGE);
expect(normalizeWangyihaoPublishErrorMessage("Error: wangyihao_token_missing")).toBe(
@@ -48,6 +70,15 @@ describe("publisher error normalization", () => {
);
});
it("normalizes Weixin GZH missing public URL failures", () => {
expect(normalizePublisherErrorMessage("weixin_gzh_public_url_missing:100000093")).toBe(
WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
);
expect(normalizeWeixinGzhPublishErrorMessage("Error: weixin_gzh_public_url_missing")).toBe(
WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE,
);
});
it("leaves unknown publisher errors unchanged", () => {
expect(normalizePublisherErrorMessage("qiehao_publish_failed")).toBe("qiehao_publish_failed");
});
@@ -1,9 +1,28 @@
export const QIEHAO_REAL_NAME_AUTH_MESSAGE = "企鹅号账号未完成实名认证,请先到企鹅号后台完成实名认证后再重试。";
export const PUBLISH_ACCOUNT_NOT_LOGGED_IN_MESSAGE = "发布账号登录态已失效,请重新打开平台后台完成登录后再重试。";
export const WANGYIHAO_TOKEN_MISSING_MESSAGE = "网易号发布凭证获取失败,请重新打开网易号后台确认登录状态。";
export const WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE =
"网易号草稿保存失败:参数错误。请检查标题、正文、封面或账号状态后重试。";
export const WANGYIHAO_PUBLISH_CLICK_MESSAGE =
"网易号草稿已保存,但自动发布未完成。请打开网易号后台确认草稿状态后重试。";
export const WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE =
"微信公众号可能已触发管理员/运营者授权发布,桌面端未能获取到文章外链。请打开公众号后台完成授权发布,并在发表记录中确认文章链接。";
const PUBLISH_PLATFORM_LABELS: Record<string, string> = {
baijiahao: "百家号",
bilibili: "bilibili",
dongchedi: "懂车帝",
jianshu: "简书",
juejin: "稀土掘金",
qiehao: "企鹅号",
smzdm: "什么值得买",
sohuhao: "搜狐号",
toutiaohao: "头条号",
wangyihao: "网易号",
weixin_gzh: "微信公众号",
zhihu: "知乎",
zol: "中关村在线",
};
function normalizeErrorText(value: string | null | undefined): string | null {
const trimmed = value?.trim();
@@ -42,6 +61,23 @@ export function normalizeQiehaoPublishErrorMessage(
return isQiehaoRealNameAuthError(normalized, code) ? QIEHAO_REAL_NAME_AUTH_MESSAGE : normalized;
}
export function normalizePublisherLoginErrorMessage(message: string | null | undefined): string | null {
const normalized = normalizeErrorText(message);
if (!normalized) {
return null;
}
const match = normalized.match(
/\b([a-z0-9_]+?)_(not_logged_in|token_missing|cookie_missing|csrf_missing)\b/i,
);
if (!match) {
return normalized;
}
const platformLabel = PUBLISH_PLATFORM_LABELS[match[1]?.toLowerCase() ?? ""];
return platformLabel ? `${platformLabel}账号登录态已失效,请重新登录该平台后再重试。` : PUBLISH_ACCOUNT_NOT_LOGGED_IN_MESSAGE;
}
export function normalizeWangyihaoPublishErrorMessage(message: string | null | undefined): string | null {
const normalized = normalizeErrorText(message);
if (!normalized) {
@@ -63,7 +99,21 @@ export function normalizeWangyihaoPublishErrorMessage(message: string | null | u
return normalized;
}
export function normalizeWeixinGzhPublishErrorMessage(message: string | null | undefined): string | null {
const normalized = normalizeErrorText(message);
if (!normalized) {
return null;
}
if (/\bweixin_gzh_public_url_missing\b/i.test(normalized)) {
return WEIXIN_GZH_PUBLIC_URL_MISSING_MESSAGE;
}
return normalized;
}
export function normalizePublisherErrorMessage(message: string | null | undefined): string | null {
const qiehaoMessage = normalizeQiehaoPublishErrorMessage(message);
return normalizeWangyihaoPublishErrorMessage(qiehaoMessage);
const wangyihaoMessage = normalizeWangyihaoPublishErrorMessage(qiehaoMessage);
const loginMessage = normalizePublisherLoginErrorMessage(wangyihaoMessage);
return normalizeWeixinGzhPublishErrorMessage(loginMessage);
}
+11 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -136,13 +137,22 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
for _, account := range accounts {
if account.PlatformID == "baijiahao" || account.PlatformID == "dongchedi" {
if publishPlatformRequiresCover(account.PlatformID) {
return true
}
}
return false
}
func publishPlatformRequiresCover(platformID string) bool {
switch strings.TrimSpace(platformID) {
case "weixin_gzh", "baijiahao", "dongchedi":
return true
default:
return false
}
}
func pointerStringValue(value *string) string {
if value == nil {
return ""
@@ -162,7 +162,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
accountSeeds = append(accountSeeds, *target.accountSeed)
}
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(articleMeta.CoverAssetURL)) == "" {
return nil, response.ErrBadRequest(40044, "publish_cover_required", "baijiahao publishing requires a cover image")
return nil, response.ErrBadRequest(40044, "publish_cover_required", "selected publishing platform requires a cover image")
}
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
@@ -0,0 +1,30 @@
package app
import "testing"
func TestPublishPlatformRequiresCover(t *testing.T) {
t.Parallel()
for _, platformID := range []string{"weixin_gzh", "baijiahao", "dongchedi"} {
if !publishPlatformRequiresCover(platformID) {
t.Fatalf("publishPlatformRequiresCover(%q) = false, want true", platformID)
}
}
if publishPlatformRequiresCover("zhihu") {
t.Fatal("publishPlatformRequiresCover(\"zhihu\") = true, want false")
}
}
func TestPublishCoverRequirementCallersIncludeWeixinGzh(t *testing.T) {
t.Parallel()
accounts := []platformAccountSeed{{PlatformID: "weixin_gzh"}}
if !publishBatchRequiresCover(accounts) {
t.Fatal("publishBatchRequiresCover() = false for weixin_gzh, want true")
}
if !schedulePublishPlatformsRequireCover([]string{"weixin_gzh"}) {
t.Fatal("schedulePublishPlatformsRequireCover() = false for weixin_gzh, want true")
}
}
@@ -564,8 +564,7 @@ func (s *ScheduleTaskService) loadPublishAccountPlatformIDs(ctx context.Context,
func schedulePublishPlatformsRequireCover(platformIDs []string) bool {
for _, platformID := range platformIDs {
switch strings.TrimSpace(platformID) {
case "baijiahao", "dongchedi":
if publishPlatformRequiresCover(platformID) {
return true
}
}