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>
|
||||
Reference in New Issue
Block a user