feat: add generation_mode to RecentArticle and related components

- Updated RecentArticle interface to include generation_mode.
- Modified workspace_service to map generation_mode from database.
- Adjusted domain model for RecentArticle to accommodate generation_mode.
- Enhanced SQL queries to retrieve generation_mode from generation_tasks.
- Created ArticleActionGroup component for article action buttons.
- Implemented ArticleGenerateStatus component to display generation status.
- Developed ArticlePublishStatus component to show publish status and related platforms.
- Introduced ArticleSourceMeta component to display article source information.
- Added utility functions for article actions and clipboard content generation.
This commit is contained in:
2026-04-07 16:07:21 +08:00
parent 9f721f6088
commit 98ebb12fa1
19 changed files with 1350 additions and 385 deletions
@@ -0,0 +1,205 @@
<script setup lang="ts">
import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
SendOutlined,
} from "@ant-design/icons-vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(defineProps<{
showPublish?: boolean;
canPublish?: boolean;
publishDisabledTitle?: string;
showEdit?: boolean;
canEdit?: boolean;
editDisabledTitle?: string;
showPreview?: boolean;
canPreview?: boolean;
previewDisabledTitle?: string;
showCopy?: boolean;
canCopy?: boolean;
copyDisabledTitle?: string;
showDelete?: boolean;
canDelete?: boolean;
deleteDisabledTitle?: string;
deleteConfirmTitle?: string;
deleteLoading?: boolean;
}>(), {
showPublish: false,
canPublish: true,
publishDisabledTitle: "",
showEdit: false,
canEdit: true,
editDisabledTitle: "",
showPreview: false,
canPreview: true,
previewDisabledTitle: "",
showCopy: false,
canCopy: true,
copyDisabledTitle: "",
showDelete: true,
canDelete: true,
deleteDisabledTitle: "",
deleteConfirmTitle: "",
deleteLoading: false,
});
const emit = defineEmits<{
publish: [];
edit: [];
preview: [];
copy: [];
delete: [];
}>();
const { t } = useI18n();
function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: string): string {
if (enabled) {
return enabledTitle;
}
return disabledTitle || enabledTitle;
}
</script>
<template>
<div class="table-actions-row">
<a-tooltip
v-if="showPublish"
:title="resolveTitle(canPublish, t('media.publish.title'), publishDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-publish"
:disabled="!canPublish"
@click="emit('publish')"
>
<SendOutlined />
</a-button>
</span>
</a-tooltip>
<a-tooltip
v-if="showEdit"
:title="resolveTitle(canEdit, t('common.edit'), editDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
:disabled="!canEdit"
@click="emit('edit')"
>
<EditOutlined />
</a-button>
</span>
</a-tooltip>
<a-tooltip
v-if="showPreview"
:title="resolveTitle(canPreview, t('common.preview'), previewDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-eye"
:disabled="!canPreview"
@click="emit('preview')"
>
<EyeOutlined />
</a-button>
</span>
</a-tooltip>
<a-tooltip
v-if="showCopy"
:title="resolveTitle(canCopy, t('common.copy'), copyDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-copy"
:disabled="!canCopy"
@click="emit('copy')"
>
<CopyOutlined />
</a-button>
</span>
</a-tooltip>
<a-tooltip
v-if="showDelete && !deleteConfirmTitle"
:title="resolveTitle(canDelete, t('common.delete'), deleteDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-delete"
:disabled="!canDelete"
:loading="deleteLoading"
@click="emit('delete')"
>
<DeleteOutlined />
</a-button>
</span>
</a-tooltip>
<a-popconfirm
v-else-if="showDelete"
:title="deleteConfirmTitle"
:disabled="!canDelete"
@confirm="emit('delete')"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-delete"
:disabled="!canDelete"
:loading="deleteLoading"
>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</template>
<style scoped>
.action-publish.ant-btn:hover:not(:disabled) {
color: #1677ff;
background: #e6f4ff;
}
.action-edit.ant-btn:hover:not(:disabled) {
color: #52c41a;
background: #f6ffed;
}
.action-eye.ant-btn:hover:not(:disabled) {
color: #13c2c2;
background: #e6fffb;
}
.action-copy.ant-btn:hover:not(:disabled) {
color: #1677ff;
background: #e6f4ff;
}
.action-delete.ant-btn:hover:not(:disabled) {
color: #ff4d4f;
background: #fff2f0;
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { LoadingOutlined } from "@ant-design/icons-vue";
import { computed } from "vue";
import { getGenerateStatusMeta } from "@/lib/display";
const props = defineProps<{
status?: string | null;
}>();
const statusMeta = computed(() => getGenerateStatusMeta(props.status));
const isLoading = computed(() => props.status === "queued" || props.status === "generating" || props.status === "running");
</script>
<template>
<a-tag :color="statusMeta.color">
<span class="article-generate-status">
<LoadingOutlined v-if="isLoading" spin />
<span>{{ statusMeta.label }}</span>
</span>
</a-tag>
</template>
<style scoped>
.article-generate-status {
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>
@@ -0,0 +1,604 @@
<script setup lang="ts">
import { QuestionCircleOutlined, UserOutlined } from "@ant-design/icons-vue";
import { useQuery } from "@tanstack/vue-query";
import type { MediaPlatform, PlatformAccount } from "@geo/shared-types";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { articlesApi, mediaApi, resolveApiURL } from "@/lib/api";
import { getPublishStatusMeta } from "@/lib/display";
import { getPublishPlatformMeta, normalizePublishPlatformId } from "@/lib/publish-platforms";
interface PublishPlatformEntry {
key: string;
name: string;
shortName: string;
accent: string;
accountName: string;
avatarUrl: string;
status: PublishRecordStatus;
}
type PublishRecordStatus = "success" | "failed" | "publishing" | "pending_review";
interface PublishPlatformSection {
key: PublishRecordStatus;
label: string;
tone: "success" | "failed" | "pending";
entries: PublishPlatformEntry[];
}
const props = withDefaults(defineProps<{
status?: string | null;
articleId?: number | null;
showPlatforms?: boolean;
}>(), {
status: null,
articleId: null,
showPlatforms: true,
});
const { t } = useI18n();
const popoverOpen = ref(false);
const shouldLoadPlatforms = computed(() => props.showPlatforms && Boolean(props.articleId) && props.status !== "unpublished");
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "publish-status"],
enabled: computed(() => popoverOpen.value && shouldLoadPlatforms.value),
queryFn: () => mediaApi.platforms(),
staleTime: 5 * 60 * 1000,
});
const accountsQuery = useQuery({
queryKey: ["media", "platform-accounts", "publish-status"],
enabled: computed(() => popoverOpen.value && shouldLoadPlatforms.value),
queryFn: () => mediaApi.accounts(),
staleTime: 5 * 60 * 1000,
});
const publishRecordsQuery = useQuery({
queryKey: computed(() => ["articles", "publish-records", props.articleId, "publish-status"]),
enabled: computed(() => popoverOpen.value && shouldLoadPlatforms.value),
queryFn: () => articlesApi.publishRecords(props.articleId as number),
});
const hasPopover = computed(() => shouldLoadPlatforms.value);
const platformMap = computed(() => {
return new Map(
(platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [
normalizePublishPlatformId(platform.platform_id),
platform,
]),
);
});
const accountMap = computed(() => {
return new Map(
(accountsQuery.data.value ?? []).map((account: PlatformAccount) => [account.id, account]),
);
});
const platformEntries = computed<PublishPlatformEntry[]>(() => {
const seen = new Set<string>();
const entries: PublishPlatformEntry[] = [];
for (const record of publishRecordsQuery.data.value ?? []) {
const key = `${record.platform_id}:${record.platform_account_id}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const normalizedId = normalizePublishPlatformId(record.platform_id);
const platform = platformMap.value.get(normalizedId);
const account = accountMap.value.get(record.platform_account_id);
const fallback = getPublishPlatformMeta(normalizedId);
entries.push({
key,
name: record.platform_name || platform?.name || fallback.name,
shortName: platform?.short_name || fallback.shortName,
accent: platform?.accent_color || fallback.accent,
accountName: account?.nickname || record.platform_nickname || "--",
avatarUrl: resolveApiURL(account?.avatar_url),
status: normalizePublishRecordStatus(record.status),
});
}
return entries;
});
const successEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === "success"),
);
const failedEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === "failed"),
);
const pendingEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === "publishing" || entry.status === "pending_review"),
);
const displayStatus = computed(() => {
if (successEntries.value.length > 0) {
return "success";
}
if (platformEntries.value.length > 0 && failedEntries.value.length === platformEntries.value.length) {
return "failed";
}
if (pendingEntries.value.some((entry) => entry.status === "publishing")) {
return "publishing";
}
if (pendingEntries.value.length > 0) {
return "pending_review";
}
if (props.status === "partial_success") {
return "success";
}
if (props.status === "publish_success" || props.status === "published") {
return "success";
}
if (props.status === "publish_failed") {
return "failed";
}
return props.status ?? null;
});
const statusMeta = computed(() => getPublishStatusMeta(displayStatus.value));
const statusTone = computed(() => {
switch (displayStatus.value) {
case "success":
return "success";
case "failed":
return "failed";
case "publishing":
return "processing";
case "pending_review":
return "warning";
default:
return "default";
}
});
const popoverSections = computed<PublishPlatformSection[]>(() => {
const sections: PublishPlatformSection[] = [];
if (successEntries.value.length > 0) {
sections.push({
key: "success",
label: `${getPublishStatusMeta("success").label}`,
tone: "success",
entries: successEntries.value,
});
}
if (failedEntries.value.length > 0) {
sections.push({
key: "failed",
label: `${getPublishStatusMeta("failed").label}`,
tone: "failed",
entries: failedEntries.value,
});
}
if (pendingEntries.value.length > 0) {
const pendingStatus = pendingEntries.value.some((entry) => entry.status === "publishing")
? "publishing"
: "pending_review";
sections.push({
key: pendingStatus,
label: `${getPublishStatusMeta(pendingStatus).label}`,
tone: "pending",
entries: pendingEntries.value,
});
}
return sections;
});
function handlePopoverChange(nextOpen: boolean): void {
popoverOpen.value = nextOpen;
}
function normalizePublishRecordStatus(status?: string | null): PublishRecordStatus {
switch (String(status ?? "").trim()) {
case "success":
case "published":
case "publish_success":
return "success";
case "publishing":
case "running":
case "queued":
return "publishing";
case "pending_review":
return "pending_review";
case "failed":
case "publish_failed":
default:
return "failed";
}
}
function getAccountInitial(accountName: string): string {
const trimmed = String(accountName ?? "").trim();
if (!trimmed || trimmed === "--") {
return "";
}
return trimmed.slice(0, 1).toUpperCase();
}
</script>
<template>
<a-popover
v-if="hasPopover"
placement="top"
trigger="hover"
overlay-class-name="article-publish-status__popover-overlay"
@openChange="handlePopoverChange"
>
<template #content>
<div class="article-publish-status__popover">
<div
v-if="publishRecordsQuery.isPending.value || platformsQuery.isPending.value || accountsQuery.isPending.value"
class="article-publish-status__loading"
>
<a-spin size="small" />
<span>{{ t("common.loading") }}</span>
</div>
<div v-else-if="popoverSections.length" class="article-publish-status__sections">
<section
v-for="section in popoverSections"
:key="section.key"
class="article-publish-status__section"
>
<header
class="article-publish-status__section-title"
:class="`article-publish-status__section-title--${section.tone}`"
>
<span class="article-publish-status__section-dot" />
<span>{{ section.label }}</span>
</header>
<div class="article-publish-status__platforms">
<a-popover
v-for="entry in section.entries"
:key="entry.key"
placement="top"
overlay-class-name="article-publish-status__account-popover"
>
<template #content>
<div class="article-publish-status__account-card">
<span class="article-publish-status__account-avatar">
<img
v-if="entry.avatarUrl"
:src="entry.avatarUrl"
:alt="entry.accountName"
class="article-publish-status__account-avatar-img"
/>
<template v-else-if="getAccountInitial(entry.accountName)">
{{ getAccountInitial(entry.accountName) }}
</template>
<UserOutlined v-else />
</span>
<span class="article-publish-status__account-name">{{ entry.accountName }}</span>
</div>
</template>
<div
class="article-publish-status__platform-chip"
:class="`article-publish-status__platform-chip--${section.tone}`"
>
<span
class="article-publish-status__platform-mark"
:style="{ background: entry.accent }"
>
{{ entry.shortName }}
</span>
<span class="article-publish-status__platform-name">{{ entry.name }}</span>
</div>
</a-popover>
</div>
</section>
</div>
<span v-else class="article-publish-status__empty">
{{ t("media.records.empty") }}
</span>
</div>
</template>
<span class="article-publish-status__trigger">
<span
class="article-publish-status__badge"
:class="`article-publish-status__badge--${statusTone}`"
>
<span class="article-publish-status__badge-dot" />
<span>{{ statusMeta.label }}</span>
<QuestionCircleOutlined class="article-publish-status__icon" />
</span>
</span>
</a-popover>
<span
v-else
class="article-publish-status__badge"
:class="`article-publish-status__badge--${statusTone}`"
>
<span class="article-publish-status__badge-dot" />
<span>{{ statusMeta.label }}</span>
</span>
</template>
<style scoped>
.article-publish-status__trigger {
display: inline-flex;
}
.article-publish-status__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 3px 12px;
border: 1px solid transparent;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.article-publish-status__badge--success {
background: #f4fbec;
border-color: #b7eb8f;
color: #4d6b18;
}
.article-publish-status__badge--failed {
background: #fff3f0;
border-color: #ffccc7;
color: #cf1322;
}
.article-publish-status__badge--processing {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
}
.article-publish-status__badge--warning {
background: #fff8e6;
border-color: #ffd591;
color: #ad6800;
}
.article-publish-status__badge--default {
background: #f7f8fa;
border-color: #d9d9d9;
color: #595959;
}
.article-publish-status__badge-dot {
width: 6px;
height:6px;
border-radius: 999px;
flex: 0 0 auto;
}
.article-publish-status__badge--success .article-publish-status__badge-dot,
.article-publish-status__section-title--success .article-publish-status__section-dot {
background: #52c41a;
}
.article-publish-status__badge--failed .article-publish-status__badge-dot,
.article-publish-status__section-title--failed .article-publish-status__section-dot {
background: #ff4d4f;
}
.article-publish-status__badge--processing .article-publish-status__badge-dot,
.article-publish-status__section-title--pending .article-publish-status__section-dot {
background: #1677ff;
}
.article-publish-status__badge--warning .article-publish-status__badge-dot {
background: #faad14;
}
.article-publish-status__badge--default .article-publish-status__badge-dot {
background: #98a2b3;
}
.article-publish-status__icon {
font-size: 13px;
opacity: 0.72;
}
.article-publish-status__popover {
min-width: 360px;
}
.article-publish-status__loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: #667085;
font-size: 13px;
}
.article-publish-status__sections {
display: flex;
flex-direction: column;
gap: 18px;
}
.article-publish-status__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.article-publish-status__section-title {
display: inline-flex;
align-items: center;
gap: 10px;
color: #344054;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
}
.article-publish-status__section-dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex: 0 0 auto;
}
.article-publish-status__platforms {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.article-publish-status__platform-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 4px 12px 4px 4px;
border-radius: 6px;
background: #f9f9f9;
border: 1px solid transparent;
color: #141414;
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
}
.article-publish-status__platform-chip:hover {
transform: translateY(-1px);
background: #f0f0f0;
}
.article-publish-status__platform-chip--success:hover {
background: #f0f0f0;
}
.article-publish-status__platform-chip--failed {
background: #fffafa;
border-color: #ffccc7;
}
.article-publish-status__platform-chip--failed:hover {
border-color: rgba(255, 77, 79, 0.24);
}
.article-publish-status__platform-chip--pending {
background: #f0f5ff;
border-color: #d6e4ff;
}
.article-publish-status__platform-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 4px;
color: #fff;
font-size: 12px;
font-weight: 500;
box-shadow: none;
}
.article-publish-status__platform-name {
letter-spacing: 0.01em;
}
.article-publish-status__empty {
color: #98a2b3;
font-size: 13px;
}
.article-publish-status__account-card {
display: flex;
align-items: center;
gap: 6px;
min-width: auto;
height: 30px;
padding: 0 10px;
background: #f8f9fa;
border-radius: 4px;
}
.article-publish-status__account-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
background: #f0f2f5;
color: #1f2937;
font-size: 12px;
font-weight: 600;
overflow: hidden;
}
.article-publish-status__account-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.article-publish-status__account-name {
color: #344054;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
:deep(.article-publish-status__popover-overlay .ant-popover-inner) {
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
:deep(.article-publish-status__popover-overlay .ant-popover-inner-content) {
padding: 0;
}
:deep(.article-publish-status__popover-overlay .ant-popover-arrow:before) {
background: #fff;
}
:deep(.article-publish-status__account-popover .ant-popover-inner) {
border-radius: 6px;
padding: 6px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:deep(.article-publish-status__account-popover .ant-popover-inner-content) {
color: #344054;
}
:deep(.article-publish-status__account-popover .ant-popover-arrow:before) {
background: #fff;
}
</style>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import {
AppstoreOutlined,
CalendarOutlined,
ClockCircleOutlined,
EditOutlined,
ThunderboltOutlined,
} from "@ant-design/icons-vue";
import { computed, type Component } from "vue";
import { formatDateTime, getSourceTypeLabel } from "@/lib/display";
const props = defineProps<{
sourceType?: string | null;
generationMode?: string | null;
createdAt?: string | null;
}>();
const sourceLabel = computed(() =>
getSourceTypeLabel(props.sourceType, props.generationMode),
);
const createdAtLabel = computed(() => formatDateTime(props.createdAt));
const sourceVariant = computed<
"template" | "custom" | "instant" | "schedule" | "free-create" | "default"
>(() => {
if (props.sourceType === "template") {
return "template";
}
if (props.sourceType === "free_create") {
return "free-create";
}
if (props.sourceType === "custom_generation") {
if (props.generationMode === "schedule") {
return "schedule";
}
if (props.generationMode === "instant") {
return "instant";
}
return "custom";
}
return "default";
});
const badgeIcon = computed<Component>(() => {
switch (sourceVariant.value) {
case "template":
return AppstoreOutlined;
case "instant":
return ThunderboltOutlined;
case "schedule":
return CalendarOutlined;
case "custom":
return ThunderboltOutlined;
case "free-create":
return EditOutlined;
default:
return AppstoreOutlined;
}
});
</script>
<template>
<div class="article-source-meta">
<span
class="article-source-meta__badge"
:class="`article-source-meta__badge--${sourceVariant}`"
>
<component :is="badgeIcon" />
<span>{{ sourceLabel }}</span>
</span>
<span class="article-source-meta__time">
<ClockCircleOutlined />
<span>{{ createdAtLabel }}</span>
</span>
</div>
</template>
<style scoped>
.article-source-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.article-source-meta__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.article-source-meta__badge--template {
border: 1px solid #d9e5ff;
background: linear-gradient(180deg, #fbfcff 0%, #f3f7ff 100%);
color: #225fd1;
}
.article-source-meta__badge--custom {
border: 1px solid #d9ccff;
background: linear-gradient(180deg, #f7f4ff 0%, #efe9ff 100%);
color: #6941c6;
}
.article-source-meta__badge--instant {
border: 1px solid #f3c796;
background: linear-gradient(180deg, #fff5e9 0%, #ffe9d2 100%);
color: #b54708;
}
.article-source-meta__badge--schedule {
border: 1px solid #a7ddd3;
background: linear-gradient(180deg, #eefaf7 0%, #def3ee 100%);
color: #0b6b61;
}
.article-source-meta__badge--free-create {
border: 1px solid #d4ead8;
background: linear-gradient(180deg, #f7fcf8 0%, #eef8f1 100%);
color: #18794e;
}
.article-source-meta__badge--default {
border: 1px solid #dde3ea;
background: linear-gradient(180deg, #fbfcfd 0%, #f4f6f8 100%);
color: #44546a;
}
.article-source-meta__time {
display: inline-flex;
align-items: center;
gap: 6px;
color: #667085;
font-size: 12px;
line-height: 1;
}
.article-source-meta :deep(.anticon) {
font-size: 12px;
}
</style>
@@ -1,12 +1,4 @@
<script setup lang="ts">
import {
LoadingOutlined,
AppstoreOutlined,
ClockCircleOutlined,
SendOutlined,
EditOutlined,
DeleteOutlined,
} from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, type TableColumnsType } from "ant-design-vue";
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
@@ -15,14 +7,17 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import ArticleActionGroup from "@/components/ArticleActionGroup.vue";
import ArticleGenerateStatus from "@/components/ArticleGenerateStatus.vue";
import ArticlePublishStatus from "@/components/ArticlePublishStatus.vue";
import ArticleSourceMeta from "@/components/ArticleSourceMeta.vue";
import ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
import PublishArticleModal from "@/components/PublishArticleModal.vue";
import { articlesApi, promptRulesApi } from "@/lib/api";
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
import {
formatDateTime,
getGenerateStatusMeta,
getPublishStatusMeta,
getSourceTypeLabel,
} from "@/lib/display";
import { formatError } from "@/lib/errors";
import { formatPublishPlatformList } from "@/lib/publish-platforms";
@@ -111,7 +106,10 @@ const deleteMutation = useMutation({
mutationFn: (id: number) => articlesApi.remove(id),
onSuccess: async () => {
message.success(t("templates.list.deleteSuccess"));
await queryClient.invalidateQueries({ queryKey: ["articles"] });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
]);
},
onError: (error) => message.error(formatError(error)),
});
@@ -159,14 +157,6 @@ function openPublish(article: ArticleListItem): void {
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");
@@ -192,6 +182,23 @@ function stopPolling(): void {
pollingTimer = null;
}
async function copyArticle(articleId: number): Promise<void> {
try {
const detail = await articlesApi.detail(articleId);
const content = buildArticleClipboardContent(detail);
if (!content) {
message.warning(t("common.noData"));
return;
}
await navigator.clipboard.writeText(content);
message.success(t("common.copySuccess"));
} catch (error) {
message.error(formatError(error) || t("common.noData"));
}
}
watch(
() => listQuery.data.value?.items ?? [],
(items) => {
@@ -303,16 +310,11 @@ onBeforeUnmount(() => {
<a class="custom-article-tab__title-link" @click="openDetail(record)">
{{ getDisplayTitle(record) }}
</a>
<div class="custom-article-tab__title-meta">
<span class="custom-article-tab__title-badge">
<AppstoreOutlined />
<span>{{ getSourceTypeLabel(record.source_type, record.generation_mode) }}</span>
</span>
<span class="custom-article-tab__title-time">
<ClockCircleOutlined />
<span>{{ formatDateTime(record.created_at) }}</span>
</span>
</div>
<ArticleSourceMeta
:source-type="record.source_type"
:generation-mode="record.generation_mode"
:created-at="record.created_at"
/>
</div>
</template>
<template v-else-if="column.key === 'prompt_rule_name'">
@@ -322,69 +324,28 @@ onBeforeUnmount(() => {
{{ formatPublishPlatformList(record.platforms) }}
</template>
<template v-else-if="column.key === 'generate_status'">
<a-tag :color="getGenerateStatusMeta(record.generate_status).color">
<span class="custom-article-tab__status-tag">
<LoadingOutlined
v-if="record.generate_status === 'generating' || record.generate_status === 'running'"
spin
/>
<span>{{ getGenerateStatusMeta(record.generate_status).label }}</span>
</span>
</a-tag>
<ArticleGenerateStatus :status="record.generate_status" />
</template>
<template v-else-if="column.key === 'publish_status'">
<a-tag :color="getPublishStatusMeta(record.publish_status).color">
{{ getPublishStatusMeta(record.publish_status).label }}
</a-tag>
<ArticlePublishStatus
:status="record.publish_status"
:article-id="record.id"
/>
</template>
<template v-else-if="column.key === 'word_count'">
{{ record.word_count || "--" }}
</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
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
:disabled="!canEditArticle(record.generate_status)"
@click="openEditor(record)"
>
<EditOutlined />
</a-button>
</span>
</a-tooltip>
<a-popconfirm
:title="t('templates.list.deleteConfirm')"
@confirm="deleteMutation.mutate(record.id)"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-delete"
:title="t('common.delete')"
>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openDetail(record)"
@copy="copyArticle(record.id)"
@delete="deleteMutation.mutate(record.id)"
/>
</template>
</template>
</a-table>
@@ -451,36 +412,8 @@ onBeforeUnmount(() => {
.custom-article-tab__title-cell {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
padding: 4px 0;
}
.custom-article-tab__title-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
font-size: 13px;
}
.custom-article-tab__title-badge {
display: inline-flex;
align-items: center;
gap: 6px;
color: #b45f06;
font-weight: 600;
}
.custom-article-tab__title-time {
display: inline-flex;
align-items: center;
gap: 6px;
color: #a3a3a3;
}
.custom-article-tab__status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>