Compare commits
4 Commits
052299e54f
...
0f4310c345
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f4310c345 | |||
| 764abf32ae | |||
| 7b6ae0f6d0 | |||
| 3436d9536d |
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user