Files
geo/apps/admin-web/src/components/PublishArticleModal.vue
T

657 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ReloadOutlined, SendOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
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";
import { formatError } from "@/lib/errors";
import { publishWithPublisherPlugin } from "@/lib/publisher-plugin";
import { loadPublisherRuntimeState } from "@/lib/publisher-runtime";
const props = defineProps<{
open: boolean;
articleId: number | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
published: [];
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const selectedAccountIds = ref<number[]>([]);
const coverEnabled = ref(true);
const coverAssetUrl = ref("");
const runtimeLoading = ref(false);
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"]),
enabled: computed(() => props.open && Boolean(props.articleId)),
queryFn: () => articlesApi.detail(props.articleId as number),
});
const accountsQuery = useQuery({
queryKey: ["media", "platform-accounts", "publish-modal"],
enabled: computed(() => props.open),
queryFn: () => mediaApi.accounts(),
});
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "publish-modal"],
enabled: computed(() => props.open),
queryFn: () => mediaApi.platforms(),
});
watch(
() => props.open,
async (open) => {
if (!open) {
selectedAccountIds.value = [];
coverAssetUrl.value = "";
fileList.value = [];
pluginInstallationId.value = null;
localPlatforms.value = [];
return;
}
await refreshRuntime();
},
{ immediate: true },
);
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]));
return (platformsQuery.data.value ?? []).map((platform) => {
const account = accountMap.get(platform.platform_id) ?? null;
const local = localPlatformMap.value.get(platform.platform_id);
const localConnected = Boolean(local?.connected);
const uidMatches = account
? localConnected && (!local?.platform_uid || local.platform_uid === account.platform_uid)
: false;
const selectable = Boolean(account && account.status === "active" && uidMatches && pluginInstalled.value);
return {
key: account?.id ?? platform.platform_id,
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"),
status: account?.status ?? "unbound",
local,
selectable,
statusText: resolveAccountStatusText(platform, account, local),
};
});
});
const modalTitle = computed(() => detailQuery.data.value?.title || t("article.untitled"));
const publishMutation = useMutation({
mutationFn: async () => {
if (!props.articleId || !detailQuery.data.value) {
throw new Error("missing_article");
}
if (!selectedAccountIds.value.length) {
throw new Error("no_accounts_selected");
}
const batch = await articlesApi.publishBatch(props.articleId, {
platform_account_ids: selectedAccountIds.value,
plugin_installation_id: pluginInstallationId.value,
publish_type: "publish",
cover_asset_url: coverEnabled.value ? coverAssetUrl.value.trim() || null : null,
});
return publishWithPublisherPlugin({
article_id: detailQuery.data.value.id,
title: detailQuery.data.value.title ?? t("article.untitled"),
markdown_content: detailQuery.data.value.markdown_content ?? "",
html_content: detailQuery.data.value.html_content ?? null,
cover_asset_url: coverEnabled.value ? coverAssetUrl.value.trim() || null : null,
callback_base_url: getApiBaseURL(),
publish_type: "publish",
tasks: batch.tasks,
});
},
onSuccess: async (result) => {
const successCount = result.results.filter((item) => item.success).length;
const failedCount = result.results.length - successCount;
if (failedCount === 0) {
notification.success({
message: t("media.publish.messages.successTitle"),
description: t("media.publish.messages.success", { count: successCount }),
placement: "topRight",
duration: 4.5,
});
} else {
showPublishFailures(result);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
queryClient.invalidateQueries({ queryKey: ["media"] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", props.articleId] }),
queryClient.invalidateQueries({ queryKey: ["articles", "publish-records", props.articleId] }),
]);
emit("published");
emit("update:open", false);
},
onError: (error) => {
const normalized = error instanceof Error ? error.message : "";
if (normalized === "no_accounts_selected") {
message.warning(t("media.publish.messages.selectPlatform"));
return;
}
message.error(formatError(error));
},
});
async function refreshRuntime(): Promise<void> {
runtimeLoading.value = true;
try {
const runtime = await loadPublisherRuntimeState();
pluginInstalled.value = runtime.ping.installed;
pluginVersion.value = runtime.ping.version;
pluginInstallationId.value = runtime.pluginInstallationId;
localPlatforms.value = runtime.localPlatforms;
} catch (error) {
pluginInstalled.value = false;
localPlatforms.value = [];
message.error(formatError(error));
} finally {
runtimeLoading.value = false;
}
}
function toggleAccount(accountId: number, selectable: boolean): void {
if (!selectable || publishMutation.isPending.value) {
return;
}
if (selectedAccountIds.value.includes(accountId)) {
selectedAccountIds.value = selectedAccountIds.value.filter((id) => id !== accountId);
return;
}
selectedAccountIds.value = [...selectedAccountIds.value, accountId];
}
function isSelected(accountId: number): boolean {
return selectedAccountIds.value.includes(accountId);
}
function resolveAccountStatusText(
platform: MediaPlatform,
account: PlatformAccount | null,
local?: PublisherLocalPlatformState,
): string {
if (!account) {
return t("media.card.unboundHint", { platform: platform.name });
}
if (!pluginInstalled.value) {
return t("media.plugin.notInstalled");
}
if (!local?.connected) {
return t("media.account.localMissing");
}
if (local.platform_uid && local.platform_uid !== account.platform_uid) {
return t("media.account.localMismatch");
}
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="650"
:confirm-loading="publishMutation.isPending.value"
@ok="publishMutation.mutate()"
@cancel="emit('update:open', false)"
>
<div class="publish-modal">
<section class="publish-modal__hero">
<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
v-if="!pluginInstalled"
type="warning"
show-icon
:message="t('media.plugin.installTitle')"
:description="t('media.plugin.installDesc')"
/>
<section class="publish-modal__section">
<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">
<a-skeleton active :paragraph="{ rows: 5 }" />
</div>
<div v-else-if="accountCards.length" class="publish-modal__grid">
<button
v-for="account in accountCards"
:key="account.key"
type="button"
class="publish-modal__card"
:class="{
'publish-modal__card--active': account.accountId ? isSelected(account.accountId) : false,
'publish-modal__card--disabled': !account.selectable,
}"
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
>
<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>
<a-empty v-else :description="t('media.empty.accounts')" />
</section>
<section class="publish-modal__section">
<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>
<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>
</a-modal>
</template>
<style scoped>
.publish-modal {
display: flex;
flex-direction: column;
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;
margin-bottom: 12px;
}
.publish-modal__hero h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
line-height: 1.5;
}
.eyebrow {
font-size: 16px;
text-transform: uppercase;
color: #000000;
margin: 0;
}
.publish-modal__hero-actions {
display: flex;
align-items: center;
gap: 10px;
}
.hero-tag {
margin: 0;
}
.publish-modal__section {
display: flex;
flex-direction: column;
}
.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: 12px;
margin-top: 4px;
}
.publish-modal__card {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
border-color: #1677ff;
}
.publish-modal__card--active {
border-color: #1677ff;
}
.publish-modal__card--disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #f7f7f7;
}
.publish-modal__card-left {
display: flex;
align-items: center;
gap: 12px;
}
.publish-modal__check {
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;
width: 24px;
height: 24px;
border-radius: 6px;
background: #f7f7f7;
border: 1px solid #e5e5e5;
font-size: 12px;
font-weight: 700;
}
.publish-modal__card-text {
display: flex;
flex-direction: column;
}
.publish-modal__card-name {
font-size: 14px;
font-weight: 500;
color: #262626;
line-height: 1.3;
}
.publish-modal__card-sub {
font-size: 12px;
font-weight: 400;
color: #8c8c8c;
margin-top: 2px;
}
.publish-modal__card-status {
font-size: 13px;
}
.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;
}
.publish-modal__grid {
grid-template-columns: 1fr;
}
}
</style>