Files
geo/apps/admin-web/src/components/ArticleDetailDrawer.vue
T
root 0f4310c345
Frontend CI / Frontend (push) Has been cancelled
Backend CI / Backend (push) Has been cancelled
fix(admin-web): prevent source link overflow in article drawer
将来源链接外层改成 grid wrapper 并固定图标 flex-shrink,
避免长 URL 把图标挤出布局或超过描述列宽。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:22:05 +08:00

590 lines
16 KiB
Vue

<script setup lang="ts">
import { CopyOutlined, LinkOutlined } from "@ant-design/icons-vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { TableColumnsType } from "ant-design-vue";
import type { ArticleVersion, PublishRecord } from "@geo/shared-types";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import ArticlePublishStatus from "@/components/ArticlePublishStatus.vue";
import MarkdownPreview from "@/components/MarkdownPreview.vue";
import {
articlesApi,
isGenerationStreamEnabled,
subscribeArticleGeneration,
type ArticleGenerationStreamEvent,
} from "@/lib/api";
import {
formatDateTime,
getGenerateStatusMeta,
getPublishStatusMeta,
getSourceTypeLabel,
} from "@/lib/display";
const props = defineProps<{
open: boolean;
articleId: number | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const activeTab = ref("content");
const enabled = computed(() => props.open && Boolean(props.articleId));
const streamTitle = ref("");
const streamMarkdown = ref("");
const streamStatus = ref<string | null>(null);
const streamFeatureEnabled = isGenerationStreamEnabled();
const detailQuery = useQuery({
queryKey: computed(() => ["articles", "detail", props.articleId]),
enabled,
queryFn: () => articlesApi.detail(props.articleId as number),
});
const versionsQuery = useQuery({
queryKey: computed(() => ["articles", "versions", props.articleId]),
enabled,
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"),
dataIndex: "version_no",
key: "version_no",
width: 88,
},
{
title: t("common.title"),
dataIndex: "title",
key: "title",
},
{
title: t("common.source"),
dataIndex: "source_label",
key: "source_label",
width: 120,
},
{
title: t("common.wordCount"),
dataIndex: "word_count",
key: "word_count",
width: 92,
},
{
title: t("common.createdAt"),
dataIndex: "created_at",
key: "created_at",
width: 168,
},
]);
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 || "");
const generateMeta = computed(() =>
getGenerateStatusMeta(streamStatus.value || detail.value?.generate_status),
);
const sourceArticleTitle = computed(() => detail.value?.source_title?.trim() || "");
const sourceArticleURL = computed(() => detail.value?.source_url?.trim() || "");
function handleClose(): void {
activeTab.value = "content";
emit("close");
}
watch(
[() => props.open, () => props.articleId],
([open, articleId], _prev, onCleanup) => {
resetStreamState();
if (!open || !articleId || !streamFeatureEnabled) {
return;
}
const controller = new AbortController();
void subscribeArticleGeneration(
articleId,
{
onEvent: handleStreamEvent,
onClose: () => {
if (!controller.signal.aborted) {
void refreshArticleData();
}
},
},
controller.signal,
).catch(() => {
if (controller.signal.aborted) {
return;
}
void refreshArticleData();
});
onCleanup(() => {
controller.abort();
});
},
{ immediate: true },
);
watch(
[() => props.open, () => props.articleId, () => detail.value?.generate_status],
([open, articleId, generateStatus], _prev, onCleanup) => {
if (!open || !articleId || streamFeatureEnabled || generateStatus !== "generating") {
return;
}
const timer = window.setInterval(() => {
void refreshArticleData();
}, 2000);
onCleanup(() => {
window.clearInterval(timer);
});
},
{ immediate: true },
);
function handleStreamEvent(event: ArticleGenerationStreamEvent): void {
if (event.type === "ping") {
return;
}
if (event.title) {
streamTitle.value = event.title;
}
if (event.status) {
streamStatus.value = event.status;
}
if (event.type === "snapshot" || event.type === "completed") {
if (event.content) {
streamMarkdown.value = event.content;
}
} else if (event.type === "delta") {
if (event.content) {
streamMarkdown.value = event.content;
} else if (event.delta) {
streamMarkdown.value += event.delta;
}
}
if (event.type === "completed" || event.type === "error") {
void refreshArticleData();
}
}
function resetStreamState(): void {
streamTitle.value = "";
streamMarkdown.value = "";
streamStatus.value = null;
}
async function refreshArticleData(): Promise<void> {
await Promise.all([
detailQuery.refetch(),
versionsQuery.refetch(),
publishRecordsQuery.refetch(),
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
]);
}
function resolvePublishLink(record: PublishRecord): string {
return record.external_article_url?.trim() || "";
}
function shouldShowClientManageHint(record: PublishRecord): boolean {
return !resolvePublishLink(record)
&& Boolean(record.external_manage_url?.trim())
&& ["weixin_gzh", "zol"].includes(record.platform_id);
}
async function copyPublishLink(record: PublishRecord): Promise<void> {
const target = resolvePublishLink(record);
if (!target) {
return;
}
await navigator.clipboard.writeText(target);
message.success(t("media.records.copySuccess"));
}
</script>
<template>
<a-drawer
:open="open"
width="840"
:title="t('article.drawerTitle')"
class="article-drawer"
@close="handleClose"
>
<div v-if="detailQuery.isPending.value" class="article-drawer__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<template v-else-if="detail">
<section class="article-drawer__hero">
<div>
<p class="eyebrow">{{ t("article.preview") }}</p>
<h2>{{ displayTitle }}</h2>
<p>
{{ getSourceTypeLabel(detail.source_type, detail.generation_mode) }} ·
{{ formatDateTime(detail.created_at) }}
</p>
</div>
<div class="article-drawer__tags">
<a-tag :color="generateMeta.color">{{ generateMeta.label }}</a-tag>
<ArticlePublishStatus
:status="detail.publish_status"
:article-id="detail.id"
:show-platforms="false"
/>
</div>
</section>
<section class="article-drawer__meta">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item :label="t('article.meta.templateType')">
{{ detail.template_name || "--" }}
</a-descriptions-item>
<a-descriptions-item :label="t('article.meta.currentVersion')">
{{ detail.version_no ?? "--" }}
</a-descriptions-item>
<a-descriptions-item :label="t('article.meta.source')">
{{ detail.source_label || "--" }}
</a-descriptions-item>
<a-descriptions-item :label="t('article.meta.wordCount')">
{{ detail.word_count || "--" }}
</a-descriptions-item>
<a-descriptions-item
v-if="sourceArticleURL"
:label="t('article.meta.sourceArticle')"
:span="2"
>
<div class="article-drawer__source-wrapper">
<a
class="article-drawer__source-link"
:href="sourceArticleURL"
target="_blank"
rel="noopener noreferrer"
:title="sourceArticleURL"
>
<LinkOutlined class="article-drawer__source-icon" />
<span class="article-drawer__source-copy">
<span v-if="sourceArticleTitle" class="article-drawer__source-title">
{{ sourceArticleTitle }}
</span>
<span class="article-drawer__source-url">{{ sourceArticleURL }}</span>
</span>
</a>
</div>
</a-descriptions-item>
</a-descriptions>
</section>
<a-tabs v-model:activeKey="activeTab" class="article-drawer__tabs">
<a-tab-pane key="content" :tab="t('article.preview')">
<div v-if="displayMarkdown" class="article-drawer__content">
<MarkdownPreview :content="displayMarkdown" />
</div>
<div
v-else-if="detail.html_content"
class="article-drawer__content"
v-html="detail.html_content"
/>
<a-empty v-else :description="t('article.noContent')" />
</a-tab-pane>
<a-tab-pane key="versions" :tab="t('article.versionHistory')">
<a-table
:columns="versionColumns"
:data-source="versionsQuery.data.value || []"
:loading="versionsQuery.isPending.value"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="article-drawer__version-title">
<strong>{{ record.title || t("article.versionUntitled") }}</strong>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</template>
<template v-else-if="column.key === 'source_label'">
{{ record.source_label || "--" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
</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 v-if="resolvePublishLink(record)" class="article-drawer__record-link-row">
<a
class="article-drawer__record-link"
:href="resolvePublishLink(record)"
target="_blank"
rel="noreferrer"
>
{{ resolvePublishLink(record) }}
</a>
<a-tooltip :title="t('media.records.copy')">
<a-button
type="text"
size="small"
class="article-drawer__record-copy"
@click="copyPublishLink(record)"
>
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</div>
<span v-else-if="shouldShowClientManageHint(record)" class="article-drawer__record-hint">
{{ t("media.records.clientManageHint") }}
</span>
<span v-else class="article-drawer__record-empty">--</span>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</template>
<a-empty v-else :description="t('common.noData')" />
</a-drawer>
</template>
<style scoped>
.article-drawer :deep(.ant-drawer-body) {
padding: 0 24px 24px;
}
.article-drawer__loading {
padding-top: 16px;
}
.article-drawer__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding-top: 12px;
}
.article-drawer__hero h2 {
margin: 0 0 8px;
font-size: 24px;
line-height: 1.2;
}
.article-drawer__hero p:last-child {
margin: 0;
color: var(--muted);
}
.article-drawer__tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.article-drawer__meta {
margin-top: 24px;
}
.article-drawer__source-wrapper {
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
}
.article-drawer__source-link {
display: flex;
width: 100%;
align-items: center;
gap: 6px;
color: #1677ff;
}
.article-drawer__source-icon {
flex-shrink: 0;
}
.article-drawer__source-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.article-drawer__source-title,
.article-drawer__source-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-drawer__source-title {
color: #1f2937;
font-weight: 500;
}
.article-drawer__source-url {
color: #1677ff;
}
.article-drawer__alert {
margin-top: 24px;
}
.article-drawer__tabs {
margin-top: 24px;
}
.article-drawer__content {
padding: 20px;
background: #f8fafc;
border: 1px solid #e5ecf5;
border-radius: 18px;
}
.article-drawer__content :deep(h1),
.article-drawer__content :deep(h2),
.article-drawer__content :deep(h3) {
margin-top: 0;
}
.article-drawer__version-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.article-drawer__version-title span {
color: var(--muted);
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-hint {
color: var(--muted);
font-size: 12px;
}
.article-drawer__record-link {
flex: 1;
min-width: 0;
overflow: hidden;
color: #1677ff;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-drawer__record-link-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.article-drawer__record-copy.ant-btn {
flex: none;
color: #8c8c8c;
}
.article-drawer__record-copy.ant-btn:hover:not(:disabled) {
color: #1677ff;
background: #e6f4ff;
}
@media (max-width: 960px) {
.article-drawer__hero {
flex-direction: column;
}
.article-drawer__tags {
justify-content: flex-start;
}
}
</style>