Files
geo/apps/admin-web/src/components/ArticleDetailDrawer.vue
T
root de30497f59 feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including:
  - Tenant creation and management with associated migrations.
  - User creation and management with associated migrations.
  - Tenant membership management with associated migrations.
  - Platform user roles management with associated migrations.
  - Quota management with associated migrations.
  - Article and template management with associated migrations.
- Added HTTP handlers for templates and workspaces.
- Created tests for protected and public routes.
- Introduced a script to check tenant scope in SQL queries.
- Documented task plan for backend completion and frontend foundation.
2026-04-01 00:58:42 +08:00

390 lines
9.8 KiB
Vue

<script setup lang="ts">
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import type { TableColumnsType } from "ant-design-vue";
import type { ArticleVersion } from "@geo/shared-types";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
articlesApi,
isGenerationStreamEnabled,
subscribeArticleGeneration,
type ArticleGenerationStreamEvent,
} from "@/lib/api";
import {
formatDateTime,
getGenerateStatusMeta,
getPublishStatusMeta,
getSourceTypeLabel,
} from "@/lib/display";
import { formatError } from "@/lib/errors";
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 streamError = ref("");
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 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 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 displayError = computed(() => streamError.value || detail.value?.generation_error_message || "");
const generateMeta = computed(() =>
getGenerateStatusMeta(streamStatus.value || detail.value?.generate_status),
);
const publishMeta = computed(() => getPublishStatusMeta(detail.value?.publish_status));
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((error) => {
if (controller.signal.aborted) {
return;
}
streamError.value = formatError(error);
});
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.error) {
streamError.value = event.error;
}
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;
streamError.value = "";
}
async function refreshArticleData(): Promise<void> {
await Promise.all([
detailQuery.refetch(),
versionsQuery.refetch(),
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
]);
}
</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) }} ·
{{ formatDateTime(detail.created_at) }}
</p>
</div>
<div class="article-drawer__tags">
<a-tag :color="generateMeta.color">{{ generateMeta.label }}</a-tag>
<a-tag :color="publishMeta.color">{{ publishMeta.label }}</a-tag>
</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>
</section>
<a-alert
v-if="displayError"
class="article-drawer__alert"
type="error"
show-icon
:message="t('common.generateStatus')"
:description="displayError"
/>
<a-tabs v-model:activeKey="activeTab" class="article-drawer__tabs">
<a-tab-pane key="content" :tab="t('article.preview')">
<div
v-if="detail.html_content"
class="article-drawer__content"
v-html="detail.html_content"
/>
<pre v-else-if="displayMarkdown" class="article-drawer__markdown">{{
displayMarkdown
}}</pre>
<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-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__alert {
margin-top: 24px;
}
.article-drawer__tabs {
margin-top: 24px;
}
.article-drawer__content,
.article-drawer__markdown {
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__markdown {
margin: 0;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
line-height: 1.6;
white-space: pre-wrap;
}
.article-drawer__version-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.article-drawer__version-title span {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 960px) {
.article-drawer__hero {
flex-direction: column;
}
.article-drawer__tags {
justify-content: flex-start;
}
}
</style>