feat: Add platform adapters for various content publishing platforms
- Implemented Dongchedi adapter for user detection and publishing. - Implemented Jianshu adapter for user detection and publishing. - Implemented Juejin adapter for user detection and publishing. - Implemented Qiehao adapter for user detection and publishing. - Implemented Smzdm adapter for user detection and publishing. - Implemented Sohuhao adapter for user detection and publishing. - Implemented Toutiaohao adapter for user detection and publishing. - Implemented Wangyihao adapter for user detection and publishing. - Implemented Weixin Gzh adapter for user detection and publishing. - Implemented Zol adapter for user detection and publishing. - Added documentation for manual testing of media publishing.
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, SendOutlined } from "@ant-design/icons-vue";
|
||||
import { ReloadOutlined, SendOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { MediaPlatform, PlatformAccount, PublisherLocalPlatformState } from "@geo/shared-types";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { message, notification } from "ant-design-vue";
|
||||
import type {
|
||||
MediaPlatform,
|
||||
PlatformAccount,
|
||||
PublisherLocalPlatformState,
|
||||
PublisherPublishResponse,
|
||||
PublisherPublishTaskResult,
|
||||
} from "@geo/shared-types";
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api";
|
||||
@@ -32,6 +38,24 @@ const pluginInstalled = ref(false);
|
||||
const pluginVersion = ref<string | undefined>();
|
||||
const pluginInstallationId = ref<number | null>(null);
|
||||
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
|
||||
const fileList = ref<any[]>([]);
|
||||
|
||||
function handleBeforeUpload(file: File) {
|
||||
const url = URL.createObjectURL(file);
|
||||
coverAssetUrl.value = url;
|
||||
fileList.value = [{
|
||||
uid: file.name,
|
||||
name: file.name,
|
||||
status: 'done',
|
||||
url: url,
|
||||
}];
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
coverAssetUrl.value = "";
|
||||
fileList.value = [];
|
||||
}
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
|
||||
@@ -57,6 +81,7 @@ watch(
|
||||
if (!open) {
|
||||
selectedAccountIds.value = [];
|
||||
coverAssetUrl.value = "";
|
||||
fileList.value = [];
|
||||
pluginInstallationId.value = null;
|
||||
localPlatforms.value = [];
|
||||
return;
|
||||
@@ -70,6 +95,10 @@ const localPlatformMap = computed(() => {
|
||||
return new Map(localPlatforms.value.map((item) => [item.platform_id, item]));
|
||||
});
|
||||
|
||||
const platformNameMap = computed(() => {
|
||||
return new Map((platformsQuery.data.value ?? []).map((platform) => [platform.platform_id, platform.name]));
|
||||
});
|
||||
|
||||
const accountCards = computed(() => {
|
||||
const accountMap = new Map((accountsQuery.data.value ?? []).map((account) => [account.platform_id, account]));
|
||||
|
||||
@@ -87,6 +116,8 @@ const accountCards = computed(() => {
|
||||
accountId: account?.id ?? null,
|
||||
platformId: platform.platform_id,
|
||||
platformName: platform.name,
|
||||
platformShortName: platform.short_name,
|
||||
platformAccentColor: platform.accent_color,
|
||||
platformCategory: platform.category,
|
||||
platformUid: account?.platform_uid ?? local?.platform_uid ?? "--",
|
||||
nickname: account?.nickname ?? local?.nickname ?? t("media.card.unbound"),
|
||||
@@ -131,9 +162,14 @@ const publishMutation = useMutation({
|
||||
const successCount = result.results.filter((item) => item.success).length;
|
||||
const failedCount = result.results.length - successCount;
|
||||
if (failedCount === 0) {
|
||||
message.success(t("media.publish.messages.success", { count: successCount }));
|
||||
notification.success({
|
||||
message: t("media.publish.messages.successTitle"),
|
||||
description: t("media.publish.messages.success", { count: successCount }),
|
||||
placement: "topRight",
|
||||
duration: 4.5,
|
||||
});
|
||||
} else {
|
||||
message.warning(t("media.publish.messages.partial", { success: successCount, failed: failedCount }));
|
||||
showPublishFailures(result);
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||
@@ -206,33 +242,72 @@ function resolveAccountStatusText(
|
||||
}
|
||||
return t("media.account.ready");
|
||||
}
|
||||
|
||||
function platformNameForResult(result: PublisherPublishTaskResult): string {
|
||||
return platformNameMap.value.get(result.platform_id) ?? result.platform_id;
|
||||
}
|
||||
|
||||
function publishFailureText(result: PublisherPublishTaskResult): string {
|
||||
return t("media.publish.messages.failureItem", {
|
||||
platform: platformNameForResult(result),
|
||||
reason: result.message?.trim() || t("media.publish.messages.unknownFailure"),
|
||||
});
|
||||
}
|
||||
|
||||
function showPublishFailures(result: PublisherPublishResponse): void {
|
||||
const failedResults = result.results.filter((item) => !item.success);
|
||||
if (!failedResults.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const successCount = result.results.filter((item) => item.success).length;
|
||||
const failedCount = failedResults.length;
|
||||
const summary = t("media.publish.messages.partial", {
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
});
|
||||
|
||||
notification.error({
|
||||
message: t("media.publish.messages.failureTitle"),
|
||||
description: h(
|
||||
"div",
|
||||
[
|
||||
h("div", { style: "line-height:1.75;font-weight:500;margin-bottom:8px;" }, summary),
|
||||
...failedResults.map((item) =>
|
||||
h("div", { style: "line-height:1.75;" }, publishFailureText(item)),
|
||||
),
|
||||
],
|
||||
),
|
||||
placement: "topRight",
|
||||
duration: 8,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="t('media.publish.title')"
|
||||
width="920"
|
||||
:width="650"
|
||||
:confirm-loading="publishMutation.isPending.value"
|
||||
@ok="publishMutation.mutate()"
|
||||
@cancel="emit('update:open', false)"
|
||||
>
|
||||
<div class="publish-modal">
|
||||
<section class="publish-modal__hero">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("media.publish.subtitle") }}</p>
|
||||
<h3>{{ modalTitle }}</h3>
|
||||
<p>{{ t("media.publish.description") }}</p>
|
||||
</div>
|
||||
<div class="publish-modal__hero-actions">
|
||||
<a-tag :color="pluginInstalled ? 'success' : 'error'">
|
||||
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
|
||||
</a-tag>
|
||||
<a-button :loading="runtimeLoading" @click="refreshRuntime">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
{{ t("media.actions.redetect") }}
|
||||
</a-button>
|
||||
<div class="publish-modal__hero-top">
|
||||
<span class="eyebrow">标题</span>
|
||||
<div class="publish-modal__hero-actions">
|
||||
<a-tag :color="pluginInstalled ? 'success' : 'error'" class="hero-tag">
|
||||
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
|
||||
</a-tag>
|
||||
<a-button :loading="runtimeLoading" @click="refreshRuntime" size="small" class="hero-btn">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
{{ t("media.actions.redetect") }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{{ modalTitle }}</h3>
|
||||
</section>
|
||||
|
||||
<a-alert
|
||||
@@ -244,11 +319,9 @@ function resolveAccountStatusText(
|
||||
/>
|
||||
|
||||
<section class="publish-modal__section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>{{ t("media.publish.platformsTitle") }}</h3>
|
||||
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
|
||||
</div>
|
||||
<div class="publish-modal__section-header">
|
||||
<h3><span class="required-star">*</span> {{ t("media.publish.platformsTitle") }}:</h3>
|
||||
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="accountsQuery.isPending.value || platformsQuery.isPending.value" class="publish-modal__loading">
|
||||
@@ -267,20 +340,31 @@ function resolveAccountStatusText(
|
||||
}"
|
||||
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
|
||||
>
|
||||
<span class="publish-modal__check">
|
||||
<span
|
||||
v-if="account.accountId && isSelected(account.accountId)"
|
||||
class="publish-modal__check-dot"
|
||||
></span>
|
||||
</span>
|
||||
<span class="publish-modal__card-copy">
|
||||
<strong>{{ account.platformName }}</strong>
|
||||
<small>{{ account.nickname }} · ID: {{ account.platformUid }}</small>
|
||||
<small>{{ account.statusText }}</small>
|
||||
</span>
|
||||
<a-tag :color="account.selectable ? 'success' : account.accountId ? 'warning' : 'default'">
|
||||
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
|
||||
</a-tag>
|
||||
<div class="publish-modal__card-left">
|
||||
<span class="publish-modal__check">
|
||||
<span
|
||||
v-if="account.accountId && isSelected(account.accountId)"
|
||||
class="publish-modal__check-inner"
|
||||
></span>
|
||||
</span>
|
||||
<span class="publish-modal__card-badge" :style="{ color: account.platformAccentColor }">
|
||||
{{ account.platformShortName }}
|
||||
</span>
|
||||
<div class="publish-modal__card-text">
|
||||
<span class="publish-modal__card-name">{{ account.platformName }}</span>
|
||||
<span class="publish-modal__card-sub" v-if="account.accountId">
|
||||
{{ account.nickname }}
|
||||
</span>
|
||||
<span class="publish-modal__card-sub" v-else>
|
||||
{{ t("media.card.unbound") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-modal__card-right">
|
||||
<span class="publish-modal__card-status" :class="account.selectable ? 'status-selectable' : 'status-disabled'">
|
||||
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -288,20 +372,29 @@ function resolveAccountStatusText(
|
||||
</section>
|
||||
|
||||
<section class="publish-modal__section">
|
||||
<div class="publish-modal__cover">
|
||||
<div class="publish-modal__cover-head">
|
||||
<div>
|
||||
<h3>{{ t("media.publish.coverTitle") }}</h3>
|
||||
<p class="muted">{{ t("media.publish.coverHint") }}</p>
|
||||
</div>
|
||||
<a-switch v-model:checked="coverEnabled" />
|
||||
</div>
|
||||
<div class="publish-modal__section-header inline-header">
|
||||
<h3>{{ t("media.publish.coverTitle") }}:</h3>
|
||||
<a-switch v-model:checked="coverEnabled" />
|
||||
</div>
|
||||
<p class="muted">请保证封面清晰、美观和完整</p>
|
||||
|
||||
<a-input
|
||||
v-if="coverEnabled"
|
||||
v-model:value="coverAssetUrl"
|
||||
:placeholder="t('media.publish.coverPlaceholder')"
|
||||
/>
|
||||
<div class="publish-modal__cover-body" v-if="coverEnabled">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
class="cover-uploader"
|
||||
:show-upload-list="true"
|
||||
:before-upload="handleBeforeUpload"
|
||||
@remove="handleRemove"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="fileList.length < 1">
|
||||
<div class="upload-icon-circle">
|
||||
<PlusOutlined />
|
||||
</div>
|
||||
<div class="upload-text">上传封面图</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -312,133 +405,245 @@ function resolveAccountStatusText(
|
||||
.publish-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.publish-modal__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.publish-modal__hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #fbfdff 0%, #f4f9ff 100%);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-modal__hero h3,
|
||||
.publish-modal__section h3 {
|
||||
.publish-modal__hero h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.publish-modal__hero p:last-child {
|
||||
margin: 8px 0 0;
|
||||
color: #6b7280;
|
||||
.eyebrow {
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.publish-modal__hero-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.publish-modal__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.publish-modal__section-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-modal__section-header.inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.publish-modal__section-header h3 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.publish-modal__section-header.inline-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.required-star {
|
||||
color: #ff4d4f;
|
||||
margin-right: 4px;
|
||||
font-family: SimSun, sans-serif;
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.publish-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.publish-modal__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid #dbe5f0;
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.publish-modal__card:hover {
|
||||
border-color: #adc6ff;
|
||||
box-shadow: 0 16px 28px rgba(22, 119, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.publish-modal__card--active {
|
||||
border-color: #1677ff;
|
||||
background: linear-gradient(135deg, #f7fbff 0%, #eef6ff 100%);
|
||||
}
|
||||
|
||||
.publish-modal__card--disabled {
|
||||
opacity: 0.72;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.publish-modal__card-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.publish-modal__check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #e5e5e5;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.publish-modal__check-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy {
|
||||
flex: 1;
|
||||
.publish-modal__card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy strong {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
.publish-modal__card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy small {
|
||||
color: #64748b;
|
||||
.publish-modal__card-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.publish-modal__cover {
|
||||
padding: 18px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
.publish-modal__card-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.publish-modal__cover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
.status-selectable {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.publish-modal__loading {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.publish-modal__cover-body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cover-uploader :deep(.ant-upload.ant-upload-select-picture-card) {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
background-color: #fff;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cover-uploader :deep(.ant-upload-list-picture-card-container) {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
}
|
||||
.cover-uploader :deep(.ant-upload-list-item) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.upload-icon-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: #2b303b;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.publish-modal__hero {
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user