feat: implement browser extension for media publishing and add backend support for media management
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { TableColumnsType } from "ant-design-vue";
|
||||
import type { ArticleVersion } from "@geo/shared-types";
|
||||
import type { ArticleVersion, PublishRecord } from "@geo/shared-types";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
@@ -48,6 +49,12 @@ const versionsQuery = useQuery({
|
||||
queryFn: () => articlesApi.versions(props.articleId as number),
|
||||
});
|
||||
|
||||
const publishRecordsQuery = useQuery({
|
||||
queryKey: computed(() => ["articles", "publish-records", props.articleId]),
|
||||
enabled,
|
||||
queryFn: () => articlesApi.publishRecords(props.articleId as number),
|
||||
});
|
||||
|
||||
const versionColumns = computed<TableColumnsType<ArticleVersion>>(() => [
|
||||
{
|
||||
title: t("common.versions"),
|
||||
@@ -80,6 +87,32 @@ const versionColumns = computed<TableColumnsType<ArticleVersion>>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const publishRecordColumns = computed<TableColumnsType<PublishRecord>>(() => [
|
||||
{
|
||||
title: t("media.records.platform"),
|
||||
dataIndex: "platform_name",
|
||||
key: "platform_name",
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: t("media.records.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: t("media.records.publishedAt"),
|
||||
dataIndex: "published_at",
|
||||
key: "published_at",
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
title: t("media.records.link"),
|
||||
dataIndex: "external_article_url",
|
||||
key: "external_article_url",
|
||||
},
|
||||
]);
|
||||
|
||||
const detail = computed(() => detailQuery.data.value);
|
||||
const displayTitle = computed(() => streamTitle.value || detail.value?.title || t("article.untitled"));
|
||||
const displayMarkdown = computed(() => streamMarkdown.value || detail.value?.markdown_content || "");
|
||||
@@ -185,10 +218,30 @@ async function refreshArticleData(): Promise<void> {
|
||||
await Promise.all([
|
||||
detailQuery.refetch(),
|
||||
versionsQuery.refetch(),
|
||||
publishRecordsQuery.refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function copyPublishLink(record: PublishRecord): Promise<void> {
|
||||
const target = record.external_article_url || record.external_manage_url;
|
||||
if (!target) {
|
||||
message.warning(t("media.records.linkEmpty"));
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(target);
|
||||
message.success(t("media.records.copySuccess"));
|
||||
}
|
||||
|
||||
function openPublishLink(record: PublishRecord): void {
|
||||
const target = record.external_article_url || record.external_manage_url;
|
||||
if (!target) {
|
||||
message.warning(t("media.records.linkEmpty"));
|
||||
return;
|
||||
}
|
||||
window.open(target, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -275,6 +328,57 @@ async function refreshArticleData(): Promise<void> {
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="published" :tab="t('media.records.tabTitle')">
|
||||
<a-table
|
||||
:columns="publishRecordColumns"
|
||||
:data-source="publishRecordsQuery.data.value || []"
|
||||
:loading="publishRecordsQuery.isPending.value"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #emptyText>{{ t("media.records.empty") }}</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'platform_name'">
|
||||
<div class="article-drawer__record-platform">
|
||||
<strong>{{ record.platform_name }}</strong>
|
||||
<span>{{ record.platform_nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getPublishStatusMeta(record.status).color">
|
||||
{{ getPublishStatusMeta(record.status).label }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'published_at'">
|
||||
{{ formatDateTime(record.published_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'external_article_url'">
|
||||
<div class="article-drawer__record-actions">
|
||||
<a
|
||||
v-if="record.external_article_url || record.external_manage_url"
|
||||
class="article-drawer__record-link"
|
||||
:href="record.external_article_url || record.external_manage_url || '#'"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ record.external_article_url || record.external_manage_url }}
|
||||
</a>
|
||||
<span v-else class="article-drawer__record-empty">--</span>
|
||||
<div class="article-drawer__record-buttons">
|
||||
<a-button size="small" type="link" @click="openPublishLink(record)">
|
||||
{{ t("media.records.open") }}
|
||||
</a-button>
|
||||
<a-button size="small" type="link" @click="copyPublishLink(record)">
|
||||
{{ t("media.records.copy") }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
|
||||
@@ -361,6 +465,37 @@ async function refreshArticleData(): Promise<void> {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-drawer__record-platform {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.article-drawer__record-platform span,
|
||||
.article-drawer__record-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-drawer__record-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-drawer__record-link {
|
||||
overflow: hidden;
|
||||
color: #1677ff;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-drawer__record-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.article-drawer__hero {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LoadingOutlined,
|
||||
AppstoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
SendOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
@@ -15,6 +16,7 @@ import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
|
||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||
import { articlesApi, promptRulesApi } from "@/lib/api";
|
||||
import {
|
||||
formatDateTime,
|
||||
@@ -55,6 +57,8 @@ const appliedFilters = reactive<{
|
||||
|
||||
const articleDrawerOpen = ref(false);
|
||||
const selectedArticleId = ref<number | null>(null);
|
||||
const publishModalOpen = ref(false);
|
||||
const selectedPublishArticleId = ref<number | null>(null);
|
||||
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ["promptRules", "simple"],
|
||||
@@ -75,8 +79,10 @@ const generateStatusOptions = computed(() => [
|
||||
const publishStatusOptions = computed(() => [
|
||||
{ label: getPublishStatusMeta("unpublished").label, value: "unpublished" },
|
||||
{ label: getPublishStatusMeta("publishing").label, value: "publishing" },
|
||||
{ label: getPublishStatusMeta("published").label, value: "published" },
|
||||
{ label: getPublishStatusMeta("publish_failed").label, value: "publish_failed" },
|
||||
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||
{ label: getPublishStatusMeta("pending_review").label, value: "pending_review" },
|
||||
]);
|
||||
|
||||
const articleParams = computed<ArticleListParams>(() => {
|
||||
@@ -117,7 +123,7 @@ const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||
{ title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 },
|
||||
{ title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
|
||||
{ title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 },
|
||||
{ title: t("common.actions"), key: "actions", width: 120, fixed: "right", align: "right" },
|
||||
{ title: t("common.actions"), key: "actions", width: 156, fixed: "right", align: "right" },
|
||||
]);
|
||||
|
||||
function applyFilters(): void {
|
||||
@@ -148,10 +154,19 @@ function openEditor(article: ArticleListItem): void {
|
||||
void router.push({ name: "article-editor", params: { id: String(article.id) } });
|
||||
}
|
||||
|
||||
function openPublish(article: ArticleListItem): void {
|
||||
selectedPublishArticleId.value = article.id;
|
||||
publishModalOpen.value = true;
|
||||
}
|
||||
|
||||
function canEditArticle(status: string): boolean {
|
||||
return status === "completed" || status === "draft";
|
||||
}
|
||||
|
||||
function canPublishArticle(status: string): boolean {
|
||||
return status === "completed";
|
||||
}
|
||||
|
||||
function getDisplayTitle(article: ArticleListItem): string {
|
||||
if ((article.generate_status === "generating" || article.generate_status === "running") && !article.title) {
|
||||
return t("custom.article.titleGenerating");
|
||||
@@ -327,6 +342,20 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip :title="canPublishArticle(record.generate_status) ? t('media.publish.title') : t('templates.list.publishDisabled')">
|
||||
<span>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn action-publish"
|
||||
:disabled="!canPublishArticle(record.generate_status)"
|
||||
@click="openPublish(record)"
|
||||
>
|
||||
<SendOutlined />
|
||||
</a-button>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('common.edit')">
|
||||
<span>
|
||||
<a-button
|
||||
@@ -365,6 +394,11 @@ onBeforeUnmount(() => {
|
||||
:article-id="selectedArticleId"
|
||||
@close="articleDrawerOpen = false"
|
||||
/>
|
||||
|
||||
<PublishArticleModal
|
||||
v-model:open="publishModalOpen"
|
||||
:article-id="selectedPublishArticleId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, SendOutlined } 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 { 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 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 = "";
|
||||
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 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,
|
||||
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) {
|
||||
message.success(t("media.publish.messages.success", { count: successCount }));
|
||||
} else {
|
||||
message.warning(t("media.publish.messages.partial", { success: successCount, failed: failedCount }));
|
||||
}
|
||||
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");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="t('media.publish.title')"
|
||||
width="920"
|
||||
: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>
|
||||
</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="section-heading">
|
||||
<div>
|
||||
<h3>{{ t("media.publish.platformsTitle") }}</h3>
|
||||
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-empty v-else :description="t('media.empty.accounts')" />
|
||||
</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>
|
||||
|
||||
<a-input
|
||||
v-if="coverEnabled"
|
||||
v-model:value="coverAssetUrl"
|
||||
:placeholder="t('media.publish.coverPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.publish-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.publish-modal__hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #fbfdff 0%, #f4f9ff 100%);
|
||||
}
|
||||
|
||||
.publish-modal__hero h3,
|
||||
.publish-modal__section h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.publish-modal__hero p:last-child {
|
||||
margin: 8px 0 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.publish-modal__hero-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.publish-modal__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.publish-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.publish-modal__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid #dbe5f0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 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--active {
|
||||
border-color: #1677ff;
|
||||
background: linear-gradient(135deg, #f7fbff 0%, #eef6ff 100%);
|
||||
}
|
||||
|
||||
.publish-modal__card--disabled {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.publish-modal__check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.publish-modal__check-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy strong {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.publish-modal__card-copy small {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.publish-modal__cover {
|
||||
padding: 18px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.publish-modal__cover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.publish-modal__loading {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.publish-modal__hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publish-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -112,7 +112,7 @@ const enUS = {
|
||||
},
|
||||
media: {
|
||||
title: "Media Management",
|
||||
description: "No matching backend APIs exist in this repository yet.",
|
||||
description: "Manage media bindings, local extension state, and one-click publishing from a single control plane.",
|
||||
},
|
||||
brands: {
|
||||
title: "Brand Library",
|
||||
@@ -196,6 +196,9 @@ const enUS = {
|
||||
publish: {
|
||||
unpublished: "Unpublished",
|
||||
publishing: "Publishing",
|
||||
success: "Published",
|
||||
failed: "Failed",
|
||||
partial_success: "Partial Success",
|
||||
published: "Published",
|
||||
publish_success: "Published",
|
||||
publish_failed: "Failed",
|
||||
@@ -230,6 +233,7 @@ const enUS = {
|
||||
preview: "Preview",
|
||||
edit: "Edit article",
|
||||
editDisabled: "Only draft, failed, or completed articles can be edited",
|
||||
publishDisabled: "Only completed articles can be published",
|
||||
versions: "Versions",
|
||||
deleteConfirm: "Delete this article?",
|
||||
deleteSuccess: "Article deleted.",
|
||||
@@ -384,6 +388,7 @@ const enUS = {
|
||||
drawerTitle: "Article details",
|
||||
preview: "Content preview",
|
||||
versionHistory: "Version history",
|
||||
publishedDetails: "Published details",
|
||||
untitled: "Untitled article",
|
||||
noContent: "This article does not have generated content yet.",
|
||||
editor: {
|
||||
@@ -405,7 +410,7 @@ const enUS = {
|
||||
saved: "Article saved.",
|
||||
generating: "This article is still generating. Try editing it later.",
|
||||
locked: "Only completed articles can be edited.",
|
||||
publishPending: "Publishing is not wired yet. The current action only saves content.",
|
||||
publishPending: "Save the article first, then choose the target platforms.",
|
||||
},
|
||||
platforms: {
|
||||
zhihu: "Zhihu",
|
||||
@@ -419,6 +424,90 @@ const enUS = {
|
||||
},
|
||||
versionUntitled: "Untitled version",
|
||||
},
|
||||
media: {
|
||||
hero: {
|
||||
eyebrow: "Publisher",
|
||||
},
|
||||
plugin: {
|
||||
title: "Browser Publisher",
|
||||
ready: "Extension connected",
|
||||
notInstalled: "Extension not connected",
|
||||
installTitle: "Install and open the GEO Publisher extension first",
|
||||
installDesc: "Binding and publishing depend on the browser's local platform sessions. The extension stores its own installation identity so future background checks can continue after the SaaS page is closed.",
|
||||
backgroundHint: "The extension should not reuse the web app's access token directly. This implementation already registers a dedicated installation identity for future background scanning and inclusion checks.",
|
||||
versionLabel: "Extension version",
|
||||
installationLabel: "Installation ID",
|
||||
connectedSummary: "Locally signed-in platforms",
|
||||
registerRequired: "The extension installation is not registered yet. Please re-detect first.",
|
||||
},
|
||||
actions: {
|
||||
redetect: "Re-detect",
|
||||
bind: "Bind",
|
||||
rebind: "Rebind",
|
||||
unbind: "Unbind",
|
||||
},
|
||||
card: {
|
||||
bound: "Bound",
|
||||
unbound: "Unbound",
|
||||
unboundHint: "{platform} has not been bound to this tenant yet.",
|
||||
needsAttention: "Needs attention",
|
||||
expiredHint: "This account is no longer healthy. Rebind it to restore publishing.",
|
||||
localMissing: "Local session missing",
|
||||
localMismatch: "Local account mismatch",
|
||||
readyHint: "The correct local account is available and ready for publish or future background checks.",
|
||||
accountLabel: "SaaS account",
|
||||
uidLabel: "Platform UID",
|
||||
localLabel: "Local state",
|
||||
localConnected: "Session detected",
|
||||
localDisconnected: "No session detected",
|
||||
updatedAt: "Last sync",
|
||||
multiAccount: "{count} accounts bound",
|
||||
openPlatform: "Open platform",
|
||||
},
|
||||
account: {
|
||||
localMissing: "The tenant account is bound, but this browser does not currently have the matching local session.",
|
||||
localMismatch: "This browser is signed in to a different account. Rebind or switch the local account first.",
|
||||
ready: "Ready",
|
||||
selectable: "Selectable",
|
||||
unavailable: "Unavailable",
|
||||
},
|
||||
empty: {
|
||||
accounts: "No publishable media accounts are bound yet.",
|
||||
},
|
||||
messages: {
|
||||
bindSuccess: "{platform} bound successfully.",
|
||||
bindPending: "The extension did not detect a local session for this platform yet. Sign in via the extension and retry.",
|
||||
unbindSuccess: "Account unbound.",
|
||||
unbindConfirm: "Unbind this platform account? Publishing will no longer be available until it is bound again.",
|
||||
},
|
||||
publish: {
|
||||
title: "Publish",
|
||||
subtitle: "Multi-platform Dispatch",
|
||||
description: "Choose tenant-bound accounts that are currently signed in locally. The extension executes the real publish flow and writes back the resulting links.",
|
||||
platformsTitle: "Target platforms",
|
||||
platformsHint: "Only tenant-bound accounts are listed here. Accounts with missing or mismatched local sessions are disabled automatically.",
|
||||
coverTitle: "Cover image",
|
||||
coverHint: "This version uses a cover asset URL. Upload and crop can be wired later.",
|
||||
coverPlaceholder: "Enter an optional cover asset URL",
|
||||
messages: {
|
||||
success: "{count} publish tasks submitted.",
|
||||
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
||||
selectPlatform: "Choose at least one available platform first.",
|
||||
},
|
||||
},
|
||||
records: {
|
||||
tabTitle: "Published details",
|
||||
platform: "Platform",
|
||||
status: "Status",
|
||||
publishedAt: "Published at",
|
||||
link: "Published link",
|
||||
open: "Open",
|
||||
copy: "Copy",
|
||||
empty: "No publish records yet",
|
||||
linkEmpty: "No link is available for this record yet.",
|
||||
copySuccess: "Link copied.",
|
||||
},
|
||||
},
|
||||
brands: {
|
||||
eyebrow: "Brand Library",
|
||||
title: "Brand Library",
|
||||
|
||||
@@ -112,7 +112,7 @@ const zhCN = {
|
||||
},
|
||||
media: {
|
||||
title: "媒体管理",
|
||||
description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。",
|
||||
description: "统一管理媒体账号绑定、本地插件状态和一键发布执行链路。",
|
||||
},
|
||||
brands: {
|
||||
title: "品牌词库",
|
||||
@@ -196,6 +196,9 @@ const zhCN = {
|
||||
publish: {
|
||||
unpublished: "未发布",
|
||||
publishing: "发布中",
|
||||
success: "发布成功",
|
||||
failed: "发布失败",
|
||||
partial_success: "部分成功",
|
||||
published: "发布成功",
|
||||
publish_success: "发布成功",
|
||||
publish_failed: "发布失败",
|
||||
@@ -230,6 +233,7 @@ const zhCN = {
|
||||
preview: "预览正文",
|
||||
edit: "编辑文章",
|
||||
editDisabled: "只有草稿、生成失败或已完成文章才可编辑",
|
||||
publishDisabled: "只有已完成文章才可发布",
|
||||
versions: "查看版本",
|
||||
deleteConfirm: "确认删除这篇文章吗?",
|
||||
deleteSuccess: "文章已删除",
|
||||
@@ -391,6 +395,7 @@ const zhCN = {
|
||||
drawerTitle: "文章详情",
|
||||
preview: "正文预览",
|
||||
versionHistory: "版本记录",
|
||||
publishedDetails: "已发布详情",
|
||||
untitled: "未命名文章",
|
||||
noContent: "当前文章还没有正文内容",
|
||||
editor: {
|
||||
@@ -412,7 +417,7 @@ const zhCN = {
|
||||
saved: "文章已保存",
|
||||
generating: "文章还在生成中,请稍后再编辑。",
|
||||
locked: "只有已完成文章才可编辑。",
|
||||
publishPending: "发布能力待接入,当前仅保存正文内容。",
|
||||
publishPending: "请先保存文章,再选择需要发布的平台。",
|
||||
},
|
||||
platforms: {
|
||||
zhihu: "知乎",
|
||||
@@ -426,6 +431,90 @@ const zhCN = {
|
||||
},
|
||||
versionUntitled: "未命名版本",
|
||||
},
|
||||
media: {
|
||||
hero: {
|
||||
eyebrow: "Publisher",
|
||||
},
|
||||
plugin: {
|
||||
title: "浏览器发布器",
|
||||
ready: "插件已连接",
|
||||
notInstalled: "插件未连接",
|
||||
installTitle: "请先安装并打开 GEO Publisher 插件",
|
||||
installDesc: "绑定和发布都依赖浏览器本地登录态执行。插件会保存独立安装实例身份,后续即使网页关闭,也能继续运行后台检测任务。",
|
||||
backgroundHint: "插件不会直接复用网页 access token。当前实现会为每个浏览器安装实例注册独立身份,为未来后台收录检测和定时扫描预留能力。",
|
||||
versionLabel: "插件版本",
|
||||
installationLabel: "安装实例 ID",
|
||||
connectedSummary: "本地已登录平台",
|
||||
registerRequired: "插件安装实例尚未注册,请先重新检测。",
|
||||
},
|
||||
actions: {
|
||||
redetect: "重新检测",
|
||||
bind: "去绑定",
|
||||
rebind: "重新绑定",
|
||||
unbind: "解绑",
|
||||
},
|
||||
card: {
|
||||
bound: "已绑定",
|
||||
unbound: "未绑定",
|
||||
unboundHint: "{platform} 还未绑定到当前租户,可以直接发起授权。",
|
||||
needsAttention: "需要处理",
|
||||
expiredHint: "该账号状态异常,建议重新绑定以恢复发布能力。",
|
||||
localMissing: "本地未登录",
|
||||
localMismatch: "本地账号不匹配",
|
||||
readyHint: "当前浏览器已检测到正确账号,可以直接用于发布和后续后台检测。",
|
||||
accountLabel: "SaaS 账号",
|
||||
uidLabel: "平台 UID",
|
||||
localLabel: "本地状态",
|
||||
localConnected: "已检测到登录态",
|
||||
localDisconnected: "未检测到登录态",
|
||||
updatedAt: "最近同步",
|
||||
multiAccount: "已绑定 {count} 个账号",
|
||||
openPlatform: "打开平台官网",
|
||||
},
|
||||
account: {
|
||||
localMissing: "SaaS 已绑定,但当前浏览器未检测到对应登录态。",
|
||||
localMismatch: "当前浏览器登录的是另一个账号,请重新授权或切换账号。",
|
||||
ready: "已就绪",
|
||||
selectable: "可发布",
|
||||
unavailable: "不可用",
|
||||
},
|
||||
empty: {
|
||||
accounts: "当前租户还没有绑定可发布账号。",
|
||||
},
|
||||
messages: {
|
||||
bindSuccess: "{platform} 绑定成功",
|
||||
bindPending: "插件尚未检测到该平台登录态,请先在插件中登录后重试。",
|
||||
unbindSuccess: "账号解绑成功",
|
||||
unbindConfirm: "确认解绑这个平台账号吗?解绑后将无法直接发布。",
|
||||
},
|
||||
publish: {
|
||||
title: "发布",
|
||||
subtitle: "Multi-platform Dispatch",
|
||||
description: "选择当前浏览器已登录且租户已绑定的平台,插件会逐个平台执行真实发布并回写链接。",
|
||||
platformsTitle: "发布平台",
|
||||
platformsHint: "仅展示当前租户已绑定的账号。插件本地登录态异常的平台会自动禁用。",
|
||||
coverTitle: "封面图",
|
||||
coverHint: "当前版本先使用封面素材 URL,后续可以接入上传与裁剪。",
|
||||
coverPlaceholder: "请输入封面素材 URL(可选)",
|
||||
messages: {
|
||||
success: "已提交 {count} 个平台发布任务",
|
||||
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
||||
selectPlatform: "请先选择至少一个可发布的平台",
|
||||
},
|
||||
},
|
||||
records: {
|
||||
tabTitle: "已发布详情",
|
||||
platform: "已发布平台",
|
||||
status: "发布状态",
|
||||
publishedAt: "发布时间",
|
||||
link: "已发布链接",
|
||||
open: "打开",
|
||||
copy: "复制",
|
||||
empty: "暂无发布记录",
|
||||
linkEmpty: "当前记录还没有可用链接",
|
||||
copySuccess: "链接已复制",
|
||||
},
|
||||
},
|
||||
brands: {
|
||||
eyebrow: "Brand Library",
|
||||
title: "品牌词库",
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
BrandRequest,
|
||||
Competitor,
|
||||
CompetitorRequest,
|
||||
CreatePluginSessionRequest,
|
||||
CreatePluginSessionResponse,
|
||||
CreatePublishBatchRequest,
|
||||
CreatePublishBatchResponse,
|
||||
GenerateFromRuleRequest,
|
||||
GenerateFromRuleResponse,
|
||||
InstantTaskListParams,
|
||||
@@ -18,6 +22,11 @@ import type {
|
||||
KeywordRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MediaPlatform,
|
||||
PlatformAccount,
|
||||
PublishRecord,
|
||||
RegisterPluginInstallationRequest,
|
||||
RegisterPluginInstallationResponse,
|
||||
PromptRule,
|
||||
PromptRuleGroup,
|
||||
PromptRuleGroupRequest,
|
||||
@@ -68,6 +77,16 @@ const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED =
|
||||
|
||||
const publicClient = createApiClient({ baseURL });
|
||||
|
||||
export function getApiBaseURL(): string {
|
||||
if (baseURL) {
|
||||
return baseURL;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient({
|
||||
baseURL,
|
||||
auth: {
|
||||
@@ -192,6 +211,15 @@ export const articlesApi = {
|
||||
versions(id: number) {
|
||||
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
||||
},
|
||||
publishBatch(id: number, payload: CreatePublishBatchRequest) {
|
||||
return apiClient.post<CreatePublishBatchResponse, CreatePublishBatchRequest>(
|
||||
`/api/tenant/articles/${id}/publish-batches`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
publishRecords(id: number) {
|
||||
return apiClient.get<PublishRecord[]>(`/api/tenant/articles/${id}/publish-records`);
|
||||
},
|
||||
remove(id: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/articles/${id}`);
|
||||
},
|
||||
@@ -340,6 +368,30 @@ export const brandsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const mediaApi = {
|
||||
platforms() {
|
||||
return apiClient.get<MediaPlatform[]>("/api/tenant/media/platforms");
|
||||
},
|
||||
accounts() {
|
||||
return apiClient.get<PlatformAccount[]>("/api/tenant/media/platform-accounts");
|
||||
},
|
||||
createPluginSession(payload: CreatePluginSessionRequest) {
|
||||
return apiClient.post<CreatePluginSessionResponse, CreatePluginSessionRequest>(
|
||||
"/api/tenant/media/plugin-sessions",
|
||||
payload,
|
||||
);
|
||||
},
|
||||
registerPluginInstallation(payload: RegisterPluginInstallationRequest) {
|
||||
return apiClient.post<RegisterPluginInstallationResponse, RegisterPluginInstallationRequest>(
|
||||
"/api/tenant/media/plugin-installations/register",
|
||||
payload,
|
||||
);
|
||||
},
|
||||
unbindAccount(id: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/media/platform-accounts/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const promptRulesApi = {
|
||||
list(params?: PromptRuleListParams) {
|
||||
return apiClient.get<PromptRuleListResponse>("/api/tenant/prompt-rules", { params });
|
||||
|
||||
@@ -14,6 +14,9 @@ const generateStatusMap: Record<string, { label: string; color: string }> = {
|
||||
const publishStatusMap: Record<string, { label: string; color: string }> = {
|
||||
unpublished: { label: "status.publish.unpublished", color: "default" },
|
||||
publishing: { label: "status.publish.publishing", color: "processing" },
|
||||
success: { label: "status.publish.success", color: "success" },
|
||||
failed: { label: "status.publish.failed", color: "error" },
|
||||
partial_success: { label: "status.publish.partial_success", color: "warning" },
|
||||
published: { label: "status.publish.published", color: "success" },
|
||||
publish_success: { label: "status.publish.publish_success", color: "success" },
|
||||
publish_failed: { label: "status.publish.publish_failed", color: "error" },
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
PublisherBindRequest,
|
||||
PublisherBindResponse,
|
||||
PublisherLocalPlatformState,
|
||||
PublisherPluginPingResponse,
|
||||
PublisherRegisterInstallationPayload,
|
||||
PublisherRegisterInstallationResult,
|
||||
PublisherPublishArticleRequest,
|
||||
PublisherPublishResponse,
|
||||
} from "@geo/shared-types";
|
||||
|
||||
type PluginAction = "ping" | "detectPlatforms" | "registerInstallation" | "bindAccount" | "publishArticle";
|
||||
|
||||
type PluginSuccessPayload<T> = {
|
||||
source: "geo-extension";
|
||||
type: "geo.publisher.response";
|
||||
requestId: string;
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type PluginErrorPayload = {
|
||||
source: "geo-extension";
|
||||
type: "geo.publisher.response";
|
||||
requestId: string;
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type PluginPayloadMap = {
|
||||
ping: PublisherPluginPingResponse;
|
||||
detectPlatforms: PublisherLocalPlatformState[];
|
||||
registerInstallation: PublisherRegisterInstallationResult;
|
||||
bindAccount: PublisherBindResponse;
|
||||
publishArticle: PublisherPublishResponse;
|
||||
};
|
||||
|
||||
function nextRequestId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `geo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
async function requestPlugin<T extends PluginAction>(
|
||||
action: T,
|
||||
payload?: T extends "bindAccount"
|
||||
? PublisherBindRequest
|
||||
: T extends "registerInstallation"
|
||||
? PublisherRegisterInstallationPayload
|
||||
: T extends "publishArticle"
|
||||
? PublisherPublishArticleRequest
|
||||
: undefined,
|
||||
timeoutMs = 4000,
|
||||
): Promise<PluginPayloadMap[T]> {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("plugin bridge is only available in browser contexts");
|
||||
}
|
||||
|
||||
const requestId = nextRequestId();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("publisher_plugin_timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
const handleMessage = (event: MessageEvent<PluginSuccessPayload<PluginPayloadMap[T]> | PluginErrorPayload>) => {
|
||||
if (event.source !== window || !event.data || event.data.type !== "geo.publisher.response") {
|
||||
return;
|
||||
}
|
||||
if (event.data.requestId !== requestId || event.data.source !== "geo-extension") {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (event.data.success) {
|
||||
resolve(event.data.data);
|
||||
return;
|
||||
}
|
||||
reject(new Error(event.data.error || "publisher_plugin_unknown_error"));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.postMessage(
|
||||
{
|
||||
source: "geo-admin",
|
||||
type: "geo.publisher.request",
|
||||
requestId,
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pingPublisherPlugin(): Promise<PublisherPluginPingResponse> {
|
||||
try {
|
||||
return await requestPlugin("ping", undefined, 1500);
|
||||
} catch {
|
||||
return {
|
||||
installed: false,
|
||||
capabilities: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPublisherPlatforms(): Promise<PublisherLocalPlatformState[]> {
|
||||
return requestPlugin("detectPlatforms", undefined, 2500);
|
||||
}
|
||||
|
||||
export async function registerPublisherInstallation(
|
||||
payload: PublisherRegisterInstallationPayload,
|
||||
): Promise<PublisherRegisterInstallationResult> {
|
||||
return requestPlugin("registerInstallation", payload, 5000);
|
||||
}
|
||||
|
||||
export async function bindPublisherPlatform(payload: PublisherBindRequest): Promise<PublisherBindResponse> {
|
||||
return requestPlugin("bindAccount", payload, 10000);
|
||||
}
|
||||
|
||||
export async function publishWithPublisherPlugin(
|
||||
payload: PublisherPublishArticleRequest,
|
||||
): Promise<PublisherPublishResponse> {
|
||||
return requestPlugin("publishArticle", payload, 25000);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PublisherLocalPlatformState, PublisherPluginPingResponse } from "@geo/shared-types";
|
||||
|
||||
import { getApiBaseURL, mediaApi } from "./api";
|
||||
import {
|
||||
detectPublisherPlatforms,
|
||||
pingPublisherPlugin,
|
||||
registerPublisherInstallation,
|
||||
} from "./publisher-plugin";
|
||||
|
||||
export interface PublisherRuntimeState {
|
||||
ping: PublisherPluginPingResponse;
|
||||
localPlatforms: PublisherLocalPlatformState[];
|
||||
pluginInstallationId: number | null;
|
||||
}
|
||||
|
||||
function detectBrowserName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Edg\//.test(ua)) {
|
||||
return "Edge";
|
||||
}
|
||||
if (/Chrome\//.test(ua) && !/Edg\//.test(ua)) {
|
||||
return "Chrome";
|
||||
}
|
||||
if (/Firefox\//.test(ua)) {
|
||||
return "Firefox";
|
||||
}
|
||||
if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) {
|
||||
return "Safari";
|
||||
}
|
||||
return navigator.platform || "Unknown";
|
||||
}
|
||||
|
||||
export async function loadPublisherRuntimeState(): Promise<PublisherRuntimeState> {
|
||||
const ping = await pingPublisherPlugin();
|
||||
if (!ping.installed || !ping.installation_key) {
|
||||
return {
|
||||
ping,
|
||||
localPlatforms: [],
|
||||
pluginInstallationId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const localPlatforms = await detectPublisherPlatforms();
|
||||
let pluginInstallationId = ping.plugin_installation_id ?? null;
|
||||
|
||||
try {
|
||||
const registration = await mediaApi.registerPluginInstallation({
|
||||
installation_key: ping.installation_key,
|
||||
installation_name: "GEO Publisher",
|
||||
browser_name: detectBrowserName(),
|
||||
client_version: ping.version,
|
||||
});
|
||||
|
||||
await registerPublisherInstallation({
|
||||
plugin_installation_id: registration.plugin_installation_id,
|
||||
installation_token: registration.installation_token,
|
||||
api_base_url: getApiBaseURL(),
|
||||
});
|
||||
|
||||
pluginInstallationId = registration.plugin_installation_id;
|
||||
} catch (error) {
|
||||
console.error("publisher installation registration failed", error);
|
||||
}
|
||||
|
||||
return {
|
||||
ping: {
|
||||
...ping,
|
||||
plugin_installation_id: pluginInstallationId,
|
||||
},
|
||||
localPlatforms,
|
||||
pluginInstallationId,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const router = createRouter({
|
||||
{
|
||||
path: "media",
|
||||
name: "media",
|
||||
component: () => import("@/views/FeatureStubView.vue"),
|
||||
component: () => import("@/views/MediaView.vue"),
|
||||
meta: {
|
||||
titleKey: "route.media.title",
|
||||
descriptionKey: "route.media.description",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { MilkdownProvider } from "@milkdown/vue";
|
||||
|
||||
import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue";
|
||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||
import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue";
|
||||
import { articlesApi } from "@/lib/api";
|
||||
import { formatError } from "@/lib/errors";
|
||||
@@ -29,6 +30,7 @@ const initialPublishPlatforms = ref<string[]>([]);
|
||||
const coverEnabled = ref(true);
|
||||
const coverPreviewUrl = ref("");
|
||||
const leaveModalOpen = ref(false);
|
||||
const publishModalOpen = ref(false);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: computed(() => ["articles", "detail", articleId.value]),
|
||||
@@ -133,7 +135,15 @@ async function handlePublish(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
message.warning(t("article.editor.messages.publishPending"));
|
||||
publishModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function handlePublished(): Promise<void> {
|
||||
await Promise.all([
|
||||
detailQuery.refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
@@ -342,6 +352,12 @@ function serializePlatformSelection(platformIds: string[]): string {
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<PublishArticleModal
|
||||
v-model:open="publishModalOpen"
|
||||
:article-id="detail?.id ?? null"
|
||||
@published="handlePublished"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiOutlined, DeleteOutlined, LinkOutlined, ReloadOutlined, EllipsisOutlined, ArrowRightOutlined, SearchOutlined } 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, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { getApiBaseURL, mediaApi } from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/display";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import { bindPublisherPlatform } from "@/lib/publisher-plugin";
|
||||
import { loadPublisherRuntimeState } from "@/lib/publisher-runtime";
|
||||
|
||||
type CardTone = "success" | "warning" | "error" | "default";
|
||||
|
||||
interface PlatformCardView {
|
||||
platform: MediaPlatform;
|
||||
account: PlatformAccount | null;
|
||||
accountCount: number;
|
||||
local: PublisherLocalPlatformState | undefined;
|
||||
tone: CardTone;
|
||||
statusText: string;
|
||||
helperText: string;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const runtimeLoading = ref(false);
|
||||
const bindingPlatformId = ref<string | null>(null);
|
||||
const unbindingAccountId = ref<number | null>(null);
|
||||
const pluginInstalled = ref(false);
|
||||
const pluginVersion = ref<string | undefined>();
|
||||
const pluginInstallationId = ref<number | null>(null);
|
||||
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
|
||||
|
||||
const platformsQuery = useQuery({
|
||||
queryKey: ["media", "platforms"],
|
||||
queryFn: () => mediaApi.platforms(),
|
||||
});
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ["media", "platform-accounts"],
|
||||
queryFn: () => mediaApi.accounts(),
|
||||
});
|
||||
|
||||
const localPlatformMap = computed(() => {
|
||||
return new Map(localPlatforms.value.map((item) => [item.platform_id, item]));
|
||||
});
|
||||
|
||||
const accountGroups = computed(() => {
|
||||
const groups = new Map<string, PlatformAccount[]>();
|
||||
for (const account of accountsQuery.data.value ?? []) {
|
||||
const list = groups.get(account.platform_id) ?? [];
|
||||
list.push(account);
|
||||
groups.set(account.platform_id, list);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const connectedCount = computed(() => localPlatforms.value.filter((item) => item.connected).length);
|
||||
|
||||
const platformCards = computed<PlatformCardView[]>(() => {
|
||||
return (platformsQuery.data.value ?? []).map((platform) => {
|
||||
const accounts = accountGroups.value.get(platform.platform_id) ?? [];
|
||||
const account = accounts[0] ?? null;
|
||||
const local = localPlatformMap.value.get(platform.platform_id);
|
||||
return {
|
||||
platform,
|
||||
account,
|
||||
accountCount: accounts.length,
|
||||
local,
|
||||
...resolveCardState(platform, account, local),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const searchQuery = ref("");
|
||||
const filterStatus = ref("all");
|
||||
const activeCategory = ref("ugc");
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
return platformCards.value.filter(card => {
|
||||
if (searchQuery.value && !card.platform.name.toLowerCase().includes(searchQuery.value.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (filterStatus.value === 'bound' && !card.account) {
|
||||
return false;
|
||||
}
|
||||
if (filterStatus.value === 'unbound' && card.account) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: async (platform: MediaPlatform) => {
|
||||
if (!pluginInstalled.value) {
|
||||
throw new Error("publisher_plugin_required");
|
||||
}
|
||||
if (!pluginInstallationId.value) {
|
||||
throw new Error("publisher_plugin_register_required");
|
||||
}
|
||||
|
||||
const session = await mediaApi.createPluginSession({
|
||||
action_type: "bind",
|
||||
platform_id: platform.platform_id,
|
||||
plugin_installation_id: pluginInstallationId.value,
|
||||
});
|
||||
|
||||
return bindPublisherPlatform({
|
||||
platform_id: platform.platform_id,
|
||||
login_url: platform.login_url,
|
||||
callback_base_url: getApiBaseURL(),
|
||||
plugin_session_id: session.plugin_session_id,
|
||||
session_token: session.session_token,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: (accountId: number) => mediaApi.unbindAccount(accountId),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshAll();
|
||||
});
|
||||
|
||||
function resolveCardState(
|
||||
platform: MediaPlatform,
|
||||
account: PlatformAccount | null,
|
||||
local: PublisherLocalPlatformState | undefined,
|
||||
): Pick<PlatformCardView, "tone" | "statusText" | "helperText"> {
|
||||
if (!account) {
|
||||
return {
|
||||
tone: "default",
|
||||
statusText: t("media.card.unbound"),
|
||||
helperText: t("media.card.unboundHint", { platform: platform.name }),
|
||||
};
|
||||
}
|
||||
|
||||
if (account.status !== "active") {
|
||||
return {
|
||||
tone: "warning",
|
||||
statusText: t("media.card.needsAttention"),
|
||||
helperText: t("media.card.expiredHint"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!pluginInstalled.value) {
|
||||
return {
|
||||
tone: "warning",
|
||||
statusText: t("media.plugin.notInstalled"),
|
||||
helperText: t("media.plugin.installDesc"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!local?.connected) {
|
||||
return {
|
||||
tone: "warning",
|
||||
statusText: t("media.card.localMissing"),
|
||||
helperText: t("media.account.localMissing"),
|
||||
};
|
||||
}
|
||||
|
||||
if (local.platform_uid && local.platform_uid !== account.platform_uid) {
|
||||
return {
|
||||
tone: "error",
|
||||
statusText: t("media.card.localMismatch"),
|
||||
helperText: t("media.account.localMismatch"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "success",
|
||||
statusText: t("media.account.ready"),
|
||||
helperText: t("media.card.readyHint"),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
pluginVersion.value = undefined;
|
||||
pluginInstallationId.value = null;
|
||||
localPlatforms.value = [];
|
||||
message.error(formatError(error));
|
||||
} finally {
|
||||
runtimeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
refreshRuntime(),
|
||||
platformsQuery.refetch(),
|
||||
accountsQuery.refetch(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleBind(platform: MediaPlatform): Promise<void> {
|
||||
bindingPlatformId.value = platform.platform_id;
|
||||
try {
|
||||
const result = await bindMutation.mutateAsync(platform);
|
||||
if (!result.success) {
|
||||
message.warning(result.message || t("media.messages.bindPending"));
|
||||
return;
|
||||
}
|
||||
message.success(t("media.messages.bindSuccess", { platform: platform.name }));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||
]);
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "publisher_plugin_required") {
|
||||
message.warning(t("media.plugin.notInstalled"));
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.message === "publisher_plugin_register_required") {
|
||||
message.warning(t("media.plugin.registerRequired"));
|
||||
return;
|
||||
}
|
||||
message.error(formatError(error));
|
||||
} finally {
|
||||
bindingPlatformId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnbind(accountId: number): Promise<void> {
|
||||
unbindingAccountId.value = accountId;
|
||||
try {
|
||||
await unbindMutation.mutateAsync(accountId);
|
||||
message.success(t("media.messages.unbindSuccess"));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||
]);
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
message.error(formatError(error));
|
||||
} finally {
|
||||
unbindingAccountId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toneClass(tone: CardTone): string {
|
||||
return `media-card--${tone}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-view">
|
||||
<section class="media-view__top-card">
|
||||
<div class="media-view__header">
|
||||
<div class="media-view__header-title">
|
||||
<h2>{{ t('route.media.title') }}</h2>
|
||||
<p>{{ t('route.media.description') }}</p>
|
||||
</div>
|
||||
<div class="media-view__header-actions">
|
||||
<a-button :loading="runtimeLoading" @click="refreshAll">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
{{ t("media.actions.redetect") }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="media-runtime-card panel">
|
||||
<div class="media-runtime-card__copy">
|
||||
<h3 class="panel-title">{{ t("media.plugin.title") }}</h3>
|
||||
<h2>{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}</h2>
|
||||
<p>{{ t("media.plugin.backgroundHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="media-runtime-card__stats">
|
||||
<article>
|
||||
<span>{{ t("media.plugin.versionLabel") }}</span>
|
||||
<strong>{{ pluginVersion || "--" }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>{{ t("media.plugin.installationLabel") }}</span>
|
||||
<strong>{{ pluginInstallationId ?? "--" }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>{{ t("media.plugin.connectedSummary") }}</span>
|
||||
<strong>{{ connectedCount }}/{{ platformCards.length }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a-alert
|
||||
v-if="!pluginInstalled"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="t('media.plugin.installTitle')"
|
||||
:description="t('media.plugin.installDesc')"
|
||||
/>
|
||||
|
||||
<section class="media-list-wrapper panel">
|
||||
<div class="custom-tabs">
|
||||
<div class="custom-tab active">
|
||||
UGC媒体平台
|
||||
<span class="custom-tab-badge">当前支持</span>
|
||||
</div>
|
||||
<div class="custom-tab">
|
||||
企业自建站点
|
||||
</div>
|
||||
<div class="custom-tab">
|
||||
第三方新闻源
|
||||
<span class="custom-tab-badge">当前支持</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-list-content">
|
||||
<p class="media-list-desc">管理您的媒体账号设定状态与发布配置</p>
|
||||
|
||||
<div class="media-list-toolbar">
|
||||
<a-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜寻媒体平台..."
|
||||
class="media-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined style="color: #bfbfbf;" />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-radio-group v-model:value="filterStatus" class="media-filter">
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
<a-radio-button value="bound">已绑定</a-radio-button>
|
||||
<a-radio-button value="unbound">未绑定</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<h4 class="media-list-title">选择媒体平台 ({{ filteredCards.length }})</h4>
|
||||
|
||||
<section class="media-grid">
|
||||
<article
|
||||
v-for="card in filteredCards"
|
||||
:key="card.platform.platform_id"
|
||||
class="media-card"
|
||||
:class="toneClass(card.tone)"
|
||||
>
|
||||
<div class="media-card__head">
|
||||
<div class="media-card__identity">
|
||||
<span class="media-card__badge" :style="{ color: card.platform.accent_color }">
|
||||
{{ card.platform.short_name }}
|
||||
</span>
|
||||
<div class="media-card__identity-text">
|
||||
<h3>{{ card.platform.name }}</h3>
|
||||
<p>Media</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-card__more">
|
||||
<a-dropdown placement="bottomRight">
|
||||
<span class="more-icon"><EllipsisOutlined /></span>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="card.account" @click="handleUnbind(card.account.id)">
|
||||
<span style="color: #ff4d4f;"><DeleteOutlined /> {{ t("media.actions.unbind") }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="card.platform.login_url">
|
||||
<a :href="card.platform.login_url" target="_blank" rel="noreferrer" style="color: #8c8c8c;">
|
||||
<LinkOutlined /> {{ t("media.card.openPlatform") }}
|
||||
</a>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="card.accountCount > 1" disabled>
|
||||
<span style="color: #bfbfbf;">{{ t("media.card.multiAccount", { count: card.accountCount }) }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-card__body">
|
||||
<template v-if="card.account">
|
||||
<div class="media-card__account-name">{{ card.account.nickname || card.local?.nickname || "--" }}</div>
|
||||
<div class="media-card__account-id">ID: {{ card.account.platform_uid || card.local?.platform_uid || "--" }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="media-card__account-unbound">{{ t("media.card.unbound") }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="media-card__footer">
|
||||
<div class="media-card__status">
|
||||
<span v-if="card.tone === 'success'" class="status-dot success"></span>
|
||||
<span v-else-if="card.tone === 'default'" class="status-dot default"></span>
|
||||
<span v-else class="status-dot warning"></span>
|
||||
<span class="status-text">{{ card.statusText }}</span>
|
||||
</div>
|
||||
|
||||
<div class="media-card__actions">
|
||||
<!-- Bound state action: Rebind -->
|
||||
<a-button
|
||||
v-if="card.account"
|
||||
class="action-btn-publish"
|
||||
:loading="bindingPlatformId === card.platform.platform_id"
|
||||
@click="handleBind(card.platform)"
|
||||
>
|
||||
{{ t("media.actions.rebind") }} <ApiOutlined />
|
||||
</a-button>
|
||||
|
||||
<!-- Unbound state action: Bind -->
|
||||
<a-button
|
||||
v-else
|
||||
class="action-btn-bind"
|
||||
:loading="bindingPlatformId === card.platform.platform_id"
|
||||
@click="handleBind(card.platform)"
|
||||
>
|
||||
{{ t("media.actions.bind") }} <ArrowRightOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.media-view__top-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-view__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.media-view__header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.media-view__header-title p {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.media-view__header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid #e6edf5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #1f5cff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.media-runtime-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.media-runtime-card__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-runtime-card__copy h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.media-runtime-card__copy p:last-child {
|
||||
margin: 10px 0 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.media-runtime-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-runtime-card__stats article {
|
||||
padding: 16px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.media-runtime-card__stats span {
|
||||
display: block;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.media-runtime-card__stats strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #1a1a1a;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.media-list-wrapper {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 24px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.custom-tab {
|
||||
position: relative;
|
||||
padding: 16px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-tab.active {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-tab.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.custom-tab-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: -8px;
|
||||
background: #f5f5f5;
|
||||
color: #bfbfbf;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.media-list-content {
|
||||
padding: 28px;
|
||||
background: #fff;
|
||||
|
||||
}
|
||||
|
||||
.media-list-desc {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.media-search {
|
||||
width: 320px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.media-search :deep(.ant-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.media-filter {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.media-list-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccd3da;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-card--default{
|
||||
background: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.media-card--success,
|
||||
.media-card--warning,
|
||||
.media-card--error {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.media-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-card__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.media-card__identity-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-card__identity-text h3 {
|
||||
margin: 0;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.media-card__identity-text p {
|
||||
margin: 2px 0 0;
|
||||
color: #bfbfbf;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
color: #bfbfbf;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.more-icon:hover {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.media-card__body {
|
||||
margin-top: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-card__account-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.media-card__account-id {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.media-card__account-unbound {
|
||||
font-size: 13px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.media-card__footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.media-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.success { background-color: #52c41a; }
|
||||
.status-dot.default { background-color: #d9d9d9; }
|
||||
.status-dot.warning, .status-dot.error { background-color: #faad14; }
|
||||
|
||||
.media-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn-bind {
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
border-color: #d9d9d9;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.action-btn-bind:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.action-btn-publish {
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
border: none;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.action-btn-publish:hover {
|
||||
background: #bae0ff;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.media-runtime-card,
|
||||
.media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.media-runtime-card__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
SendOutlined,
|
||||
PlusOutlined,
|
||||
BlockOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -22,6 +23,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||
import { articlesApi, templatesApi } from "@/lib/api";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import {
|
||||
@@ -37,6 +39,8 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
const publishModalOpen = ref(false);
|
||||
const selectedPublishArticleId = ref<number | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const draftGenerationRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
@@ -135,8 +139,10 @@ const generateStatusOptions = computed(() => [
|
||||
const publishStatusOptions = computed(() => [
|
||||
{ label: getPublishStatusMeta("unpublished").label, value: "unpublished" },
|
||||
{ label: getPublishStatusMeta("publishing").label, value: "publishing" },
|
||||
{ label: getPublishStatusMeta("published").label, value: "published" },
|
||||
{ label: getPublishStatusMeta("publish_failed").label, value: "publish_failed" },
|
||||
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||
{ label: getPublishStatusMeta("pending_review").label, value: "pending_review" },
|
||||
]);
|
||||
|
||||
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||
@@ -172,7 +178,7 @@ const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||
{
|
||||
title: t("common.actions"),
|
||||
key: "actions",
|
||||
width: 120,
|
||||
width: 156,
|
||||
align: "right",
|
||||
},
|
||||
]);
|
||||
@@ -243,10 +249,19 @@ function openEditor(article: ArticleListItem): void {
|
||||
void router.push({ name: "article-editor", params: { id: String(article.id) } });
|
||||
}
|
||||
|
||||
function openPublish(article: ArticleListItem): void {
|
||||
selectedPublishArticleId.value = article.id;
|
||||
publishModalOpen.value = true;
|
||||
}
|
||||
|
||||
function canEditArticle(status: string): boolean {
|
||||
return status === "completed" || status === "draft" || status === "failed";
|
||||
}
|
||||
|
||||
function canPublishArticle(status: string): boolean {
|
||||
return status === "completed";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => articleListQuery.data.value?.items ?? [],
|
||||
(items: ArticleListItem[]) => {
|
||||
@@ -449,6 +464,22 @@ async function handleDelete(articleId: number): Promise<void> {
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip
|
||||
:title="canPublishArticle(record.generate_status) ? t('media.publish.title') : t('templates.list.publishDisabled')"
|
||||
>
|
||||
<span>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn action-publish"
|
||||
:disabled="!canPublishArticle(record.generate_status)"
|
||||
@click="openPublish(record)"
|
||||
>
|
||||
<SendOutlined />
|
||||
</a-button>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip
|
||||
:title="canEditArticle(record.generate_status) ? t('templates.list.edit') : t('templates.list.editDisabled')"
|
||||
>
|
||||
@@ -516,6 +547,11 @@ async function handleDelete(articleId: number): Promise<void> {
|
||||
</article>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<PublishArticleModal
|
||||
v-model:open="publishModalOpen"
|
||||
:article-id="selectedPublishArticleId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -251,9 +251,12 @@ function refreshDashboard(): void {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'publish_status'">
|
||||
<div class="status-badge" :class="[record.publish_status === 'published' ? 'badge-green-filled' : 'badge-grey']">
|
||||
<span class="dot" v-if="record.publish_status !== 'published'"></span>
|
||||
<span>{{ record.publish_status === 'published' ? '发布成功' : '未发布' }}</span>
|
||||
<div
|
||||
class="status-badge"
|
||||
:class="[getPublishStatusMeta(record.publish_status).color === 'success' ? 'badge-green-filled' : 'badge-grey']"
|
||||
>
|
||||
<span class="dot" v-if="getPublishStatusMeta(record.publish_status).color !== 'success'"></span>
|
||||
<span>{{ getPublishStatusMeta(record.publish_status).label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user