feat: implement browser extension for media publishing and add backend support for media management

This commit is contained in:
2026-04-03 00:39:15 +08:00
parent 8958cb44c0
commit 32d6a462cd
60 changed files with 8268 additions and 26 deletions
+17 -1
View File
@@ -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>
+831
View File
@@ -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>
+39 -3
View File
@@ -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>
+6 -3
View File
@@ -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>