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:
@@ -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">
|
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message, type TableColumnsType } from "ant-design-vue";
|
import { message, type TableColumnsType } from "ant-design-vue";
|
||||||
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
|
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 { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
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 ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
|
||||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
import { articlesApi, promptRulesApi } from "@/lib/api";
|
import { articlesApi, promptRulesApi } from "@/lib/api";
|
||||||
|
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
|
||||||
getGenerateStatusMeta,
|
getGenerateStatusMeta,
|
||||||
getPublishStatusMeta,
|
getPublishStatusMeta,
|
||||||
getSourceTypeLabel,
|
|
||||||
} from "@/lib/display";
|
} from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
import { formatPublishPlatformList } from "@/lib/publish-platforms";
|
import { formatPublishPlatformList } from "@/lib/publish-platforms";
|
||||||
@@ -111,7 +106,10 @@ const deleteMutation = useMutation({
|
|||||||
mutationFn: (id: number) => articlesApi.remove(id),
|
mutationFn: (id: number) => articlesApi.remove(id),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
message.success(t("templates.list.deleteSuccess"));
|
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)),
|
onError: (error) => message.error(formatError(error)),
|
||||||
});
|
});
|
||||||
@@ -159,14 +157,6 @@ function openPublish(article: ArticleListItem): void {
|
|||||||
publishModalOpen.value = true;
|
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 {
|
function getDisplayTitle(article: ArticleListItem): string {
|
||||||
if ((article.generate_status === "generating" || article.generate_status === "running") && !article.title) {
|
if ((article.generate_status === "generating" || article.generate_status === "running") && !article.title) {
|
||||||
return t("custom.article.titleGenerating");
|
return t("custom.article.titleGenerating");
|
||||||
@@ -192,6 +182,23 @@ function stopPolling(): void {
|
|||||||
pollingTimer = null;
|
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(
|
watch(
|
||||||
() => listQuery.data.value?.items ?? [],
|
() => listQuery.data.value?.items ?? [],
|
||||||
(items) => {
|
(items) => {
|
||||||
@@ -303,16 +310,11 @@ onBeforeUnmount(() => {
|
|||||||
<a class="custom-article-tab__title-link" @click="openDetail(record)">
|
<a class="custom-article-tab__title-link" @click="openDetail(record)">
|
||||||
{{ getDisplayTitle(record) }}
|
{{ getDisplayTitle(record) }}
|
||||||
</a>
|
</a>
|
||||||
<div class="custom-article-tab__title-meta">
|
<ArticleSourceMeta
|
||||||
<span class="custom-article-tab__title-badge">
|
:source-type="record.source_type"
|
||||||
<AppstoreOutlined />
|
:generation-mode="record.generation_mode"
|
||||||
<span>{{ getSourceTypeLabel(record.source_type, record.generation_mode) }}</span>
|
:created-at="record.created_at"
|
||||||
</span>
|
/>
|
||||||
<span class="custom-article-tab__title-time">
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<span>{{ formatDateTime(record.created_at) }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'prompt_rule_name'">
|
<template v-else-if="column.key === 'prompt_rule_name'">
|
||||||
@@ -322,69 +324,28 @@ onBeforeUnmount(() => {
|
|||||||
{{ formatPublishPlatformList(record.platforms) }}
|
{{ formatPublishPlatformList(record.platforms) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'generate_status'">
|
<template v-else-if="column.key === 'generate_status'">
|
||||||
<a-tag :color="getGenerateStatusMeta(record.generate_status).color">
|
<ArticleGenerateStatus :status="record.generate_status" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'publish_status'">
|
<template v-else-if="column.key === 'publish_status'">
|
||||||
<a-tag :color="getPublishStatusMeta(record.publish_status).color">
|
<ArticlePublishStatus
|
||||||
{{ getPublishStatusMeta(record.publish_status).label }}
|
:status="record.publish_status"
|
||||||
</a-tag>
|
:article-id="record.id"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'word_count'">
|
<template v-else-if="column.key === 'word_count'">
|
||||||
{{ record.word_count || "--" }}
|
{{ record.word_count || "--" }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<div class="table-actions-row">
|
<ArticleActionGroup
|
||||||
<a-tooltip :title="canPublishArticle(record.generate_status) ? t('media.publish.title') : t('templates.list.publishDisabled')">
|
v-bind="resolveArticleActionState(record.generate_status)"
|
||||||
<span>
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
<a-button
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
type="text"
|
@publish="openPublish(record)"
|
||||||
shape="circle"
|
@edit="openEditor(record)"
|
||||||
size="small"
|
@preview="openDetail(record)"
|
||||||
class="action-btn action-publish"
|
@copy="copyArticle(record.id)"
|
||||||
:disabled="!canPublishArticle(record.generate_status)"
|
@delete="deleteMutation.mutate(record.id)"
|
||||||
@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>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -451,36 +412,8 @@ onBeforeUnmount(() => {
|
|||||||
.custom-article-tab__title-cell {
|
.custom-article-tab__title-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 4px 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const enUS = {
|
|||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
reset: "Reset",
|
reset: "Reset",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
|
loading: "Loading",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
back: "Back",
|
back: "Back",
|
||||||
@@ -47,6 +48,8 @@ const enUS = {
|
|||||||
selectPlease: "Please select",
|
selectPlease: "Please select",
|
||||||
inputPlease: "Please input",
|
inputPlease: "Please input",
|
||||||
upgrade: "Upgrade",
|
upgrade: "Upgrade",
|
||||||
|
copy: "Copy",
|
||||||
|
copySuccess: "Content copied",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
workspace: "Workspace",
|
workspace: "Workspace",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const zhCN = {
|
|||||||
delete: "删除",
|
delete: "删除",
|
||||||
reset: "重置",
|
reset: "重置",
|
||||||
refresh: "刷新数据",
|
refresh: "刷新数据",
|
||||||
|
loading: "加载中",
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
next: "下一步",
|
next: "下一步",
|
||||||
@@ -47,6 +48,8 @@ const zhCN = {
|
|||||||
selectPlease: "请选择",
|
selectPlease: "请选择",
|
||||||
inputPlease: "请输入",
|
inputPlease: "请输入",
|
||||||
upgrade: "去升级",
|
upgrade: "去升级",
|
||||||
|
copy: "复制",
|
||||||
|
copySuccess: "内容已复制",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
workspace: "工作台",
|
workspace: "工作台",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ArticleDetail } from "@geo/shared-types";
|
||||||
|
|
||||||
|
export interface ArticleActionState {
|
||||||
|
showPublish: boolean;
|
||||||
|
showEdit: boolean;
|
||||||
|
showPreview: boolean;
|
||||||
|
showCopy: boolean;
|
||||||
|
showDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveArticleActionState(generateStatus?: string | null): ArticleActionState {
|
||||||
|
if (generateStatus === "completed") {
|
||||||
|
return {
|
||||||
|
showPublish: true,
|
||||||
|
showEdit: true,
|
||||||
|
showPreview: true,
|
||||||
|
showCopy: true,
|
||||||
|
showDelete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generateStatus === "draft" || generateStatus === "failed") {
|
||||||
|
return {
|
||||||
|
showPublish: false,
|
||||||
|
showEdit: true,
|
||||||
|
showPreview: false,
|
||||||
|
showCopy: false,
|
||||||
|
showDelete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showPublish: false,
|
||||||
|
showEdit: false,
|
||||||
|
showPreview: true,
|
||||||
|
showCopy: false,
|
||||||
|
showDelete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArticleClipboardContent(detail: Pick<ArticleDetail, "title" | "markdown_content">): string {
|
||||||
|
return [
|
||||||
|
detail.title ? `# ${detail.title}` : "",
|
||||||
|
detail.markdown_content ?? "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -25,6 +25,18 @@ const platformCatalog: Record<string, Omit<PublishPlatformOption, "accountLabel"
|
|||||||
dongchedi: { id: "dongchedi", name: "懂车帝", shortName: "懂", accent: "#fadb14" },
|
dongchedi: { id: "dongchedi", name: "懂车帝", shortName: "懂", accent: "#fadb14" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getPublishPlatformMeta(platformId: string): Omit<PublishPlatformOption, "accountLabel" | "bound"> {
|
||||||
|
const normalized = normalizePublishPlatformId(platformId);
|
||||||
|
const fallbackShortName = normalized.slice(0, 1).toUpperCase() || "?";
|
||||||
|
|
||||||
|
return platformCatalog[normalized] ?? {
|
||||||
|
id: normalized,
|
||||||
|
name: normalized || "--",
|
||||||
|
shortName: fallbackShortName,
|
||||||
|
accent: "#667085",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePublishPlatformId(platformId?: string | null): string {
|
export function normalizePublishPlatformId(platformId?: string | null): string {
|
||||||
return String(platformId ?? "").trim();
|
return String(platformId ?? "").trim();
|
||||||
}
|
}
|
||||||
@@ -73,8 +85,7 @@ export function parseTargetPlatforms(value?: string | null): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPublishPlatformLabel(platformId: string): string {
|
export function getPublishPlatformLabel(platformId: string): string {
|
||||||
const normalized = normalizePublishPlatformId(platformId);
|
return getPublishPlatformMeta(platformId).name;
|
||||||
return platformCatalog[normalized]?.name ?? normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPublishPlatformSummary(value?: string | null): string {
|
export function formatPublishPlatformSummary(value?: string | null): string {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Upload,
|
Upload,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
|
Popover,
|
||||||
|
Slider,
|
||||||
} from "ant-design-vue";
|
} from "ant-design-vue";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
@@ -99,6 +101,7 @@ app.component("ATextarea", Input.TextArea);
|
|||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Popover,
|
||||||
Progress,
|
Progress,
|
||||||
Radio,
|
Radio,
|
||||||
Result,
|
Result,
|
||||||
@@ -109,6 +112,7 @@ app.component("ATextarea", Input.TextArea);
|
|||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
Switch,
|
Switch,
|
||||||
|
Slider,
|
||||||
Steps,
|
Steps,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
SendOutlined,
|
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
LoadingOutlined,
|
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
|
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
|
||||||
import type { TableColumnsType } from "ant-design-vue";
|
import type { TableColumnsType } from "ant-design-vue";
|
||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
import { computed, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import ArticleActionGroup from "@/components/ArticleActionGroup.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 PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
import { articlesApi } from "@/lib/api";
|
import { articlesApi } from "@/lib/api";
|
||||||
|
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
|
||||||
getGenerateStatusMeta,
|
|
||||||
getPublishStatusMeta,
|
getPublishStatusMeta,
|
||||||
getSourceTypeLabel,
|
|
||||||
} from "@/lib/display";
|
} from "@/lib/display";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -31,6 +29,8 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const publishModalOpen = ref(false);
|
const publishModalOpen = ref(false);
|
||||||
const selectedPublishArticleId = ref<number | null>(null);
|
const selectedPublishArticleId = ref<number | null>(null);
|
||||||
|
const articleDrawerOpen = ref(false);
|
||||||
|
const selectedArticleId = ref<number | null>(null);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const createdRange = ref<[Dayjs, Dayjs] | null>(null);
|
const createdRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||||
@@ -164,8 +164,9 @@ function openPublish(article: ArticleListItem): void {
|
|||||||
publishModalOpen.value = true;
|
publishModalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canPublishArticle(status: string): boolean {
|
function openPreview(article: ArticleListItem): void {
|
||||||
return status === "completed";
|
selectedArticleId.value = article.id;
|
||||||
|
articleDrawerOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTableChange(nextPage: number, nextPageSize: number): void {
|
function handleTableChange(nextPage: number, nextPageSize: number): void {
|
||||||
@@ -180,6 +181,23 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
function handleCreate(): void {
|
function handleCreate(): void {
|
||||||
createMutation.mutate();
|
createMutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -274,16 +292,19 @@ function handleCreate(): void {
|
|||||||
<template v-if="column.key === 'title'">
|
<template v-if="column.key === 'title'">
|
||||||
<div class="free-create-view__title-cell">
|
<div class="free-create-view__title-cell">
|
||||||
<strong>{{ record.title || t("article.untitled") }}</strong>
|
<strong>{{ record.title || t("article.untitled") }}</strong>
|
||||||
<span>
|
<ArticleSourceMeta
|
||||||
{{ getSourceTypeLabel(record.source_type) }} · {{ formatDateTime(record.created_at) }}
|
:source-type="record.source_type"
|
||||||
</span>
|
:generation-mode="record.generation_mode"
|
||||||
|
:created-at="record.created_at"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'publish_status'">
|
<template v-else-if="column.key === 'publish_status'">
|
||||||
<a-tag :color="getPublishStatusMeta(record.publish_status).color">
|
<ArticlePublishStatus
|
||||||
{{ getPublishStatusMeta(record.publish_status).label }}
|
:status="record.publish_status"
|
||||||
</a-tag>
|
:article-id="record.id"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'word_count'">
|
<template v-else-if="column.key === 'word_count'">
|
||||||
@@ -291,52 +312,16 @@ function handleCreate(): void {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<div class="table-actions-row">
|
<ArticleActionGroup
|
||||||
<a-tooltip
|
v-bind="resolveArticleActionState(record.generate_status)"
|
||||||
:title="canPublishArticle(record.generate_status) ? t('media.publish.title') : t('templates.list.publishDisabled')"
|
:delete-confirm-title="t('freeCreate.list.deleteConfirm')"
|
||||||
>
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
<span>
|
@publish="openPublish(record)"
|
||||||
<a-button
|
@edit="openEditor(record)"
|
||||||
type="text"
|
@preview="openPreview(record)"
|
||||||
shape="circle"
|
@copy="copyArticle(record.id)"
|
||||||
size="small"
|
@delete="handleDelete(record.id)"
|
||||||
class="action-btn action-publish"
|
/>
|
||||||
:disabled="!canPublishArticle(record.generate_status)"
|
|
||||||
@click="openPublish(record)"
|
|
||||||
>
|
|
||||||
<SendOutlined />
|
|
||||||
</a-button>
|
|
||||||
</span>
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('freeCreate.list.edit')">
|
|
||||||
<span>
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
class="action-btn action-edit"
|
|
||||||
@click="openEditor(record)"
|
|
||||||
>
|
|
||||||
<EditOutlined />
|
|
||||||
</a-button>
|
|
||||||
</span>
|
|
||||||
</a-tooltip>
|
|
||||||
<a-popconfirm
|
|
||||||
:title="t('freeCreate.list.deleteConfirm')"
|
|
||||||
@confirm="handleDelete(record.id)"
|
|
||||||
>
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
class="action-btn action-delete"
|
|
||||||
:loading="deleteMutation.isPending.value"
|
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -346,6 +331,12 @@ function handleCreate(): void {
|
|||||||
v-model:open="publishModalOpen"
|
v-model:open="publishModalOpen"
|
||||||
:article-id="selectedPublishArticleId"
|
:article-id="selectedPublishArticleId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ArticleDetailDrawer
|
||||||
|
:open="articleDrawerOpen"
|
||||||
|
:article-id="selectedArticleId"
|
||||||
|
@close="articleDrawerOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -454,12 +445,7 @@ function handleCreate(): void {
|
|||||||
.free-create-view__title-cell {
|
.free-create-view__title-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.free-create-view__title-cell span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
SendOutlined,
|
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
@@ -23,14 +18,18 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
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 PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
import { articlesApi, templatesApi } from "@/lib/api";
|
import { articlesApi, templatesApi } from "@/lib/api";
|
||||||
|
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
|
||||||
getGenerateStatusMeta,
|
getGenerateStatusMeta,
|
||||||
getPublishStatusMeta,
|
getPublishStatusMeta,
|
||||||
getSourceTypeLabel,
|
|
||||||
getTemplateMeta,
|
getTemplateMeta,
|
||||||
} from "@/lib/display";
|
} from "@/lib/display";
|
||||||
|
|
||||||
@@ -41,6 +40,8 @@ const { t } = useI18n();
|
|||||||
const pickerOpen = ref(false);
|
const pickerOpen = ref(false);
|
||||||
const publishModalOpen = ref(false);
|
const publishModalOpen = ref(false);
|
||||||
const selectedPublishArticleId = ref<number | null>(null);
|
const selectedPublishArticleId = ref<number | null>(null);
|
||||||
|
const articleDrawerOpen = ref(false);
|
||||||
|
const selectedArticleId = ref<number | null>(null);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const draftGenerationRange = ref<[Dayjs, Dayjs] | null>(null);
|
const draftGenerationRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||||
@@ -254,12 +255,9 @@ function openPublish(article: ArticleListItem): void {
|
|||||||
publishModalOpen.value = true;
|
publishModalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canEditArticle(status: string): boolean {
|
function openPreview(article: ArticleListItem): void {
|
||||||
return status === "completed" || status === "draft" || status === "failed";
|
selectedArticleId.value = article.id;
|
||||||
}
|
articleDrawerOpen.value = true;
|
||||||
|
|
||||||
function canPublishArticle(status: string): boolean {
|
|
||||||
return status === "completed";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -310,6 +308,23 @@ function stopArticlePolling(): void {
|
|||||||
async function handleDelete(articleId: number): Promise<void> {
|
async function handleDelete(articleId: number): Promise<void> {
|
||||||
await deleteMutation.mutateAsync(articleId);
|
await deleteMutation.mutateAsync(articleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -430,9 +445,11 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
<template v-if="column.key === 'title'">
|
<template v-if="column.key === 'title'">
|
||||||
<div class="templates-view__title-cell">
|
<div class="templates-view__title-cell">
|
||||||
<strong>{{ record.title || t("article.untitled") }}</strong>
|
<strong>{{ record.title || t("article.untitled") }}</strong>
|
||||||
<span>
|
<ArticleSourceMeta
|
||||||
{{ getSourceTypeLabel(record.source_type) }} · {{ formatDateTime(record.created_at) }}
|
:source-type="record.source_type"
|
||||||
</span>
|
:generation-mode="record.generation_mode"
|
||||||
|
:created-at="record.created_at"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -441,21 +458,14 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'generate_status'">
|
<template v-else-if="column.key === 'generate_status'">
|
||||||
<a-tag :color="getGenerateStatusMeta(record.generate_status).color">
|
<ArticleGenerateStatus :status="record.generate_status" />
|
||||||
<span class="templates-view__status-tag">
|
|
||||||
<LoadingOutlined
|
|
||||||
v-if="record.generate_status === 'generating' || record.generate_status === 'running'"
|
|
||||||
spin
|
|
||||||
/>
|
|
||||||
<span>{{ getGenerateStatusMeta(record.generate_status).label }}</span>
|
|
||||||
</span>
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'publish_status'">
|
<template v-else-if="column.key === 'publish_status'">
|
||||||
<a-tag :color="getPublishStatusMeta(record.publish_status).color">
|
<ArticlePublishStatus
|
||||||
{{ getPublishStatusMeta(record.publish_status).label }}
|
:status="record.publish_status"
|
||||||
</a-tag>
|
:article-id="record.id"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'word_count'">
|
<template v-else-if="column.key === 'word_count'">
|
||||||
@@ -463,55 +473,16 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<div class="table-actions-row">
|
<ArticleActionGroup
|
||||||
<a-tooltip
|
v-bind="resolveArticleActionState(record.generate_status)"
|
||||||
:title="canPublishArticle(record.generate_status) ? t('media.publish.title') : t('templates.list.publishDisabled')"
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
>
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
<span>
|
@publish="openPublish(record)"
|
||||||
<a-button
|
@edit="openEditor(record)"
|
||||||
type="text"
|
@preview="openPreview(record)"
|
||||||
shape="circle"
|
@copy="copyArticle(record.id)"
|
||||||
size="small"
|
@delete="handleDelete(record.id)"
|
||||||
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')"
|
|
||||||
>
|
|
||||||
<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="handleDelete(record.id)"
|
|
||||||
>
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
class="action-btn action-delete"
|
|
||||||
:loading="deleteMutation.isPending.value"
|
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -552,6 +523,12 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
v-model:open="publishModalOpen"
|
v-model:open="publishModalOpen"
|
||||||
:article-id="selectedPublishArticleId"
|
:article-id="selectedPublishArticleId"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ArticleDetailDrawer
|
||||||
|
:open="articleDrawerOpen"
|
||||||
|
:article-id="selectedArticleId"
|
||||||
|
@close="articleDrawerOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -665,18 +642,7 @@ async function handleDelete(articleId: number): Promise<void> {
|
|||||||
.templates-view__title-cell {
|
.templates-view__title-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.templates-view__title-cell span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.templates-view__status-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-cards-container {
|
.template-cards-container {
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import {
|
|||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
RocketOutlined,
|
RocketOutlined,
|
||||||
TrophyOutlined,
|
TrophyOutlined,
|
||||||
SendOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
@@ -17,26 +12,32 @@ import {
|
|||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
PlaySquareOutlined,
|
PlaySquareOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import type { RecentArticle, TemplateCard } from "@geo/shared-types";
|
import type { RecentArticle, TemplateCard } from "@geo/shared-types";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
import type { TableColumnsType } from "ant-design-vue";
|
import type { TableColumnsType } from "ant-design-vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
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 ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
|
||||||
import { workspaceApi } from "@/lib/api";
|
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
import {
|
import { articlesApi, workspaceApi } from "@/lib/api";
|
||||||
formatDateTime,
|
import { getTemplateMeta } from "@/lib/display";
|
||||||
getGenerateStatusMeta,
|
import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions";
|
||||||
getPublishStatusMeta,
|
import { formatError } from "@/lib/errors";
|
||||||
getTemplateMeta,
|
|
||||||
} from "@/lib/display";
|
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const selectedArticleId = ref<number | null>(null);
|
const selectedArticleId = ref<number | null>(null);
|
||||||
const articleDrawerOpen = ref(false);
|
const articleDrawerOpen = ref(false);
|
||||||
|
const selectedPublishArticleId = ref<number | null>(null);
|
||||||
|
const publishModalOpen = ref(false);
|
||||||
|
|
||||||
const overviewQuery = useQuery({
|
const overviewQuery = useQuery({
|
||||||
queryKey: ["workspace", "overview"],
|
queryKey: ["workspace", "overview"],
|
||||||
@@ -74,23 +75,17 @@ const articleColumns = computed<TableColumnsType<RecentArticle>>(() => [
|
|||||||
dataIndex: "title",
|
dataIndex: "title",
|
||||||
key: "title",
|
key: "title",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t("common.templateType"),
|
|
||||||
dataIndex: "template_name",
|
|
||||||
key: "template_name",
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t("common.generateStatus"),
|
title: t("common.generateStatus"),
|
||||||
dataIndex: "generate_status",
|
dataIndex: "generate_status",
|
||||||
key: "generate_status",
|
key: "generate_status",
|
||||||
width: 140,
|
width: 128,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.publishStatus"),
|
title: t("common.publishStatus"),
|
||||||
dataIndex: "publish_status",
|
dataIndex: "publish_status",
|
||||||
key: "publish_status",
|
key: "publish_status",
|
||||||
width: 140,
|
width: 148,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.wordCount"),
|
title: t("common.wordCount"),
|
||||||
@@ -101,11 +96,25 @@ const articleColumns = computed<TableColumnsType<RecentArticle>>(() => [
|
|||||||
{
|
{
|
||||||
title: t("common.actions"),
|
title: t("common.actions"),
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 200,
|
width: 188,
|
||||||
align: 'right',
|
align: "right",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (articleId: number) => articlesApi.remove(articleId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
message.success(t("templates.list.deleteSuccess"));
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(formatError(error) || t("templates.list.deleteError"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Helper for the gradient classes based on index/type
|
// Helper for the gradient classes based on index/type
|
||||||
function getTemplateCardClass(idx: number): string {
|
function getTemplateCardClass(idx: number): string {
|
||||||
const classes = ["card-yellow", "card-blue-light", "card-blue-deep", "card-grey"];
|
const classes = ["card-yellow", "card-blue-light", "card-blue-deep", "card-grey"];
|
||||||
@@ -140,8 +149,51 @@ function openArticle(articleId: number): void {
|
|||||||
articleDrawerOpen.value = true;
|
articleDrawerOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(articleId: number): void {
|
function openPublish(articleId: number): void {
|
||||||
// Mock delete function
|
selectedPublishArticleId.value = articleId;
|
||||||
|
publishModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(article: RecentArticle): Promise<void> {
|
||||||
|
try {
|
||||||
|
const detail = await articlesApi.detail(article.id);
|
||||||
|
|
||||||
|
if (detail.generate_status !== "completed" && detail.source_type === "template" && detail.template_id) {
|
||||||
|
await router.push({
|
||||||
|
name: "article-wizard",
|
||||||
|
query: {
|
||||||
|
template_id: String(detail.template_id),
|
||||||
|
article_id: String(detail.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push({ name: "article-editor", params: { id: String(detail.id) } });
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatError(error) || t("common.noData"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(articleId: number): Promise<void> {
|
||||||
|
await deleteMutation.mutateAsync(articleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshDashboard(): void {
|
function refreshDashboard(): void {
|
||||||
@@ -230,48 +282,40 @@ function refreshDashboard(): void {
|
|||||||
<template v-if="column.key === 'title'">
|
<template v-if="column.key === 'title'">
|
||||||
<div class="table-title-col">
|
<div class="table-title-col">
|
||||||
<strong>{{ record.title || t("article.untitled") }}</strong>
|
<strong>{{ record.title || t("article.untitled") }}</strong>
|
||||||
<div class="table-title-sub">
|
<ArticleSourceMeta
|
||||||
<span style="color: #1f5cff; font-weight: 500; font-size: 11px;">自定义生成</span>
|
:source-type="record.source_type"
|
||||||
<span class="muted-dot">•</span>
|
:generation-mode="record.generation_mode"
|
||||||
<span class="muted">{{ record.source_label || formatDateTime(record.created_at) }}</span>
|
:created-at="record.created_at"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="column.key === 'template_name'">
|
|
||||||
<span style="color: #595959;">-</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'generate_status'">
|
<template v-else-if="column.key === 'generate_status'">
|
||||||
<!-- Custom dot badge -->
|
<ArticleGenerateStatus :status="record.generate_status" />
|
||||||
<div class="status-badge" :class="[record.generate_status === 'completed' ? 'badge-green' : 'badge-grey']">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span>已完成</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'publish_status'">
|
<template v-else-if="column.key === 'publish_status'">
|
||||||
<div
|
<ArticlePublishStatus
|
||||||
class="status-badge"
|
:status="record.publish_status"
|
||||||
:class="[getPublishStatusMeta(record.publish_status).color === 'success' ? 'badge-green-filled' : 'badge-grey']"
|
:article-id="record.id"
|
||||||
>
|
/>
|
||||||
<span class="dot" v-if="getPublishStatusMeta(record.publish_status).color !== 'success'"></span>
|
|
||||||
<span>{{ getPublishStatusMeta(record.publish_status).label }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'word_count'">
|
<template v-else-if="column.key === 'word_count'">
|
||||||
<span style="color: #595959;">{{ record.word_count || "-" }}</span>
|
{{ record.word_count || "--" }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<div class="table-actions-row">
|
<ArticleActionGroup
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-send"><SendOutlined /></a-button>
|
v-bind="resolveArticleActionState(record.generate_status)"
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-edit"><EditOutlined /></a-button>
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openArticle(record.id)"><EyeOutlined /></a-button>
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-copy"><CopyOutlined /></a-button>
|
@publish="openPublish(record.id)"
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-delete danger" @click="confirmDelete(record.id)"><DeleteOutlined /></a-button>
|
@edit="openEditor(record)"
|
||||||
</div>
|
@preview="openArticle(record.id)"
|
||||||
|
@copy="copyArticle(record.id)"
|
||||||
|
@delete="handleDelete(record.id)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -282,6 +326,11 @@ function refreshDashboard(): void {
|
|||||||
:article-id="selectedArticleId"
|
:article-id="selectedArticleId"
|
||||||
@close="articleDrawerOpen = false"
|
@close="articleDrawerOpen = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PublishArticleModal
|
||||||
|
v-model:open="publishModalOpen"
|
||||||
|
:article-id="selectedPublishArticleId"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -490,67 +539,12 @@ function refreshDashboard(): void {
|
|||||||
.table-title-col {
|
.table-title-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.table-title-col strong {
|
.table-title-col strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #141414;
|
color: #141414;
|
||||||
}
|
}
|
||||||
.table-title-sub {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.muted { color: #737373; font-size: 11px; }
|
|
||||||
.muted-dot { color: #d9d9d9; font-size: 10px; }
|
|
||||||
|
|
||||||
/* Status Badges */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.badge-green {
|
|
||||||
background: transparent;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
.badge-green .dot { background: #52c41a; }
|
|
||||||
|
|
||||||
.badge-green-filled {
|
|
||||||
background: #f6ffed;
|
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
.badge-grey {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #8c8c8c;
|
|
||||||
}
|
|
||||||
.badge-grey .dot { background: #d9d9d9; }
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.table-actions-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.action-btn {
|
|
||||||
color: #8c8c8c;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.action-send:hover { color: #1677ff; background: #e6f4ff; }
|
|
||||||
.action-edit:hover { color: #52c41a; background: #f6ffed; }
|
|
||||||
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
|
|
||||||
.action-copy:hover { color: #1677ff; background: #e6f4ff; }
|
|
||||||
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.workspace-header-grid {
|
.workspace-header-grid {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface RecentArticle {
|
|||||||
generate_status: string;
|
generate_status: string;
|
||||||
publish_status: string;
|
publish_status: string;
|
||||||
source_type: string;
|
source_type: string;
|
||||||
|
generation_mode: string | null;
|
||||||
word_count: number;
|
word_count: number;
|
||||||
source_label: string | null;
|
source_label: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA
|
|||||||
GenerateStatus: row.GenerateStatus,
|
GenerateStatus: row.GenerateStatus,
|
||||||
PublishStatus: row.PublishStatus,
|
PublishStatus: row.PublishStatus,
|
||||||
SourceType: row.SourceType,
|
SourceType: row.SourceType,
|
||||||
|
GenerationMode: row.GenerationMode,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
WordCount: row.WordCount,
|
WordCount: row.WordCount,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type RecentArticle struct {
|
|||||||
GenerateStatus string `json:"generate_status"`
|
GenerateStatus string `json:"generate_status"`
|
||||||
PublishStatus string `json:"publish_status"`
|
PublishStatus string `json:"publish_status"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
|
GenerationMode *string `json:"generation_mode"`
|
||||||
WordCount int `json:"word_count"`
|
WordCount int `json:"word_count"`
|
||||||
SourceLabel *string `json:"source_label"`
|
SourceLabel *string `json:"source_label"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -95,10 +95,21 @@ func (q *Queries) GetQuotaSummary(ctx context.Context, tenantID int64) (int32, e
|
|||||||
const getRecentArticles = `-- name: GetRecentArticles :many
|
const getRecentArticles = `-- name: GetRecentArticles :many
|
||||||
SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
|
SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
|
||||||
av.title, av.markdown_content, av.word_count, av.source_label,
|
av.title, av.markdown_content, av.word_count, av.source_label,
|
||||||
t.template_name
|
t.template_name,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(gt.input_params_json ->> 'generation_mode', ''),
|
||||||
|
CASE WHEN a.source_type = 'custom_generation' THEN 'instant' ELSE NULL END
|
||||||
|
) AS generation_mode
|
||||||
FROM articles a
|
FROM articles a
|
||||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||||
LEFT JOIN article_templates t ON t.id = a.template_id
|
LEFT JOIN article_templates t ON t.id = a.template_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT input_params_json
|
||||||
|
FROM generation_tasks
|
||||||
|
WHERE article_id = a.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) gt ON true
|
||||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL
|
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -115,6 +126,7 @@ type GetRecentArticlesRow struct {
|
|||||||
WordCount pgtype.Int4 `json:"word_count"`
|
WordCount pgtype.Int4 `json:"word_count"`
|
||||||
SourceLabel pgtype.Text `json:"source_label"`
|
SourceLabel pgtype.Text `json:"source_label"`
|
||||||
TemplateName pgtype.Text `json:"template_name"`
|
TemplateName pgtype.Text `json:"template_name"`
|
||||||
|
GenerationMode pgtype.Text `json:"generation_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error) {
|
func (q *Queries) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error) {
|
||||||
@@ -137,6 +149,7 @@ func (q *Queries) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetR
|
|||||||
&i.WordCount,
|
&i.WordCount,
|
||||||
&i.SourceLabel,
|
&i.SourceLabel,
|
||||||
&i.TemplateName,
|
&i.TemplateName,
|
||||||
|
&i.GenerationMode,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,21 @@ WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
|
|||||||
-- name: GetRecentArticles :many
|
-- name: GetRecentArticles :many
|
||||||
SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
|
SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
|
||||||
av.title, av.markdown_content, av.word_count, av.source_label,
|
av.title, av.markdown_content, av.word_count, av.source_label,
|
||||||
t.template_name
|
t.template_name,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(gt.input_params_json ->> 'generation_mode', ''),
|
||||||
|
CASE WHEN a.source_type = 'custom_generation' THEN 'instant' ELSE NULL END
|
||||||
|
) AS generation_mode
|
||||||
FROM articles a
|
FROM articles a
|
||||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||||
LEFT JOIN article_templates t ON t.id = a.template_id
|
LEFT JOIN article_templates t ON t.id = a.template_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT input_params_json
|
||||||
|
FROM generation_tasks
|
||||||
|
WHERE article_id = a.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) gt ON true
|
||||||
WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.deleted_at IS NULL
|
WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.deleted_at IS NULL
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT 10;
|
LIMIT 10;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type WorkspaceRecentArticle struct {
|
|||||||
GenerateStatus string
|
GenerateStatus string
|
||||||
PublishStatus string
|
PublishStatus string
|
||||||
SourceType string
|
SourceType string
|
||||||
|
GenerationMode *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Title *string
|
Title *string
|
||||||
WordCount int
|
WordCount int
|
||||||
@@ -72,6 +73,7 @@ func (r *workspaceRepository) GetRecentArticles(ctx context.Context, tenantID in
|
|||||||
GenerateStatus: row.GenerateStatus,
|
GenerateStatus: row.GenerateStatus,
|
||||||
PublishStatus: row.PublishStatus,
|
PublishStatus: row.PublishStatus,
|
||||||
SourceType: row.SourceType,
|
SourceType: row.SourceType,
|
||||||
|
GenerationMode: nullableText(row.GenerationMode),
|
||||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||||
Title: nullableText(row.Title),
|
Title: nullableText(row.Title),
|
||||||
WordCount: resolveWorkspaceWordCount(row.MarkdownContent, intFromInt4(row.WordCount)),
|
WordCount: resolveWorkspaceWordCount(row.MarkdownContent, intFromInt4(row.WordCount)),
|
||||||
|
|||||||
Reference in New Issue
Block a user