diff --git a/apps/admin-web/src/components/ArticleActionGroup.vue b/apps/admin-web/src/components/ArticleActionGroup.vue new file mode 100644 index 0000000..8f58362 --- /dev/null +++ b/apps/admin-web/src/components/ArticleActionGroup.vue @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin-web/src/components/ArticleGenerateStatus.vue b/apps/admin-web/src/components/ArticleGenerateStatus.vue new file mode 100644 index 0000000..35762b4 --- /dev/null +++ b/apps/admin-web/src/components/ArticleGenerateStatus.vue @@ -0,0 +1,30 @@ + + + + + + + {{ statusMeta.label }} + + + + + diff --git a/apps/admin-web/src/components/ArticlePublishStatus.vue b/apps/admin-web/src/components/ArticlePublishStatus.vue new file mode 100644 index 0000000..ef6c20e --- /dev/null +++ b/apps/admin-web/src/components/ArticlePublishStatus.vue @@ -0,0 +1,604 @@ + + + + + + + + + {{ t("common.loading") }} + + + + + + + {{ section.label }} + + + + + + + + + + {{ getAccountInitial(entry.accountName) }} + + + + {{ entry.accountName }} + + + + + + {{ entry.shortName }} + + {{ entry.name }} + + + + + + + + {{ t("media.records.empty") }} + + + + + + + + {{ statusMeta.label }} + + + + + + + + {{ statusMeta.label }} + + + + diff --git a/apps/admin-web/src/components/ArticleSourceMeta.vue b/apps/admin-web/src/components/ArticleSourceMeta.vue new file mode 100644 index 0000000..c23e235 --- /dev/null +++ b/apps/admin-web/src/components/ArticleSourceMeta.vue @@ -0,0 +1,148 @@ + + + + + + + {{ sourceLabel }} + + + + {{ createdAtLabel }} + + + + + diff --git a/apps/admin-web/src/components/CustomArticleTab.vue b/apps/admin-web/src/components/CustomArticleTab.vue index dbb5be4..c4649f2 100644 --- a/apps/admin-web/src/components/CustomArticleTab.vue +++ b/apps/admin-web/src/components/CustomArticleTab.vue @@ -1,12 +1,4 @@ @@ -274,16 +292,19 @@ function handleCreate(): void { {{ record.title || t("article.untitled") }} - - {{ getSourceTypeLabel(record.source_type) }} · {{ formatDateTime(record.created_at) }} - + - - {{ getPublishStatusMeta(record.publish_status).label }} - + @@ -291,52 +312,16 @@ function handleCreate(): void { - - - - - - - - - - - - - - - - - - - - - + @@ -346,6 +331,12 @@ function handleCreate(): void { v-model:open="publishModalOpen" :article-id="selectedPublishArticleId" /> + + @@ -454,12 +445,7 @@ function handleCreate(): void { .free-create-view__title-cell { display: flex; flex-direction: column; - gap: 4px; -} - -.free-create-view__title-cell span { - color: var(--muted); - font-size: 12px; + gap: 8px; } @media (max-width: 1200px) { diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue index dc51499..9f7f98b 100644 --- a/apps/admin-web/src/views/TemplatesView.vue +++ b/apps/admin-web/src/views/TemplatesView.vue @@ -1,12 +1,7 @@ @@ -430,9 +445,11 @@ async function handleDelete(articleId: number): Promise { {{ record.title || t("article.untitled") }} - - {{ getSourceTypeLabel(record.source_type) }} · {{ formatDateTime(record.created_at) }} - + @@ -441,21 +458,14 @@ async function handleDelete(articleId: number): Promise { - - - - {{ getGenerateStatusMeta(record.generate_status).label }} - - + - - {{ getPublishStatusMeta(record.publish_status).label }} - + @@ -463,55 +473,16 @@ async function handleDelete(articleId: number): Promise { - - - - - - - - - - - - - - - - - - - - - + @@ -552,6 +523,12 @@ async function handleDelete(articleId: number): Promise { v-model:open="publishModalOpen" :article-id="selectedPublishArticleId" /> + + @@ -665,18 +642,7 @@ async function handleDelete(articleId: number): Promise { .templates-view__title-cell { display: flex; flex-direction: column; - gap: 4px; -} - -.templates-view__title-cell span { - color: var(--muted); - font-size: 12px; -} - -.templates-view__status-tag { - display: inline-flex; - align-items: center; - gap: 6px; + gap: 8px; } .template-cards-container { diff --git a/apps/admin-web/src/views/WorkspaceView.vue b/apps/admin-web/src/views/WorkspaceView.vue index 3f275f9..2b0729a 100644 --- a/apps/admin-web/src/views/WorkspaceView.vue +++ b/apps/admin-web/src/views/WorkspaceView.vue @@ -5,11 +5,6 @@ import { NodeIndexOutlined, RocketOutlined, TrophyOutlined, - SendOutlined, - EditOutlined, - EyeOutlined, - CopyOutlined, - DeleteOutlined, FileDoneOutlined, CloudUploadOutlined, BlockOutlined, @@ -17,26 +12,32 @@ import { ArrowRightOutlined, PlaySquareOutlined, } 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 { message } from "ant-design-vue"; import type { TableColumnsType } from "ant-design-vue"; import { computed, ref } 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 { workspaceApi } from "@/lib/api"; -import { - formatDateTime, - getGenerateStatusMeta, - getPublishStatusMeta, - getTemplateMeta, -} from "@/lib/display"; +import PublishArticleModal from "@/components/PublishArticleModal.vue"; +import { articlesApi, workspaceApi } from "@/lib/api"; +import { getTemplateMeta } from "@/lib/display"; +import { buildArticleClipboardContent, resolveArticleActionState } from "@/lib/article-list-actions"; +import { formatError } from "@/lib/errors"; +const queryClient = useQueryClient(); const router = useRouter(); const { t } = useI18n(); const selectedArticleId = ref(null); const articleDrawerOpen = ref(false); +const selectedPublishArticleId = ref(null); +const publishModalOpen = ref(false); const overviewQuery = useQuery({ queryKey: ["workspace", "overview"], @@ -74,23 +75,17 @@ const articleColumns = computed>(() => [ dataIndex: "title", key: "title", }, - { - title: t("common.templateType"), - dataIndex: "template_name", - key: "template_name", - width: 180, - }, { title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", - width: 140, + width: 128, }, { title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", - width: 140, + width: 148, }, { title: t("common.wordCount"), @@ -101,11 +96,25 @@ const articleColumns = computed>(() => [ { title: t("common.actions"), key: "actions", - width: 200, - align: 'right', + width: 188, + 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 function getTemplateCardClass(idx: number): string { const classes = ["card-yellow", "card-blue-light", "card-blue-deep", "card-grey"]; @@ -140,8 +149,51 @@ function openArticle(articleId: number): void { articleDrawerOpen.value = true; } -function confirmDelete(articleId: number): void { - // Mock delete function +function openPublish(articleId: number): void { + selectedPublishArticleId.value = articleId; + publishModalOpen.value = true; +} + +async function openEditor(article: RecentArticle): Promise { + 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 { + 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 { + await deleteMutation.mutateAsync(articleId); } function refreshDashboard(): void { @@ -230,48 +282,40 @@ function refreshDashboard(): void { {{ record.title || t("article.untitled") }} - - 自定义生成 - • - {{ record.source_label || formatDateTime(record.created_at) }} - + - - - - - - - - - 已完成 - + - - - {{ getPublishStatusMeta(record.publish_status).label }} - + - {{ record.word_count || "-" }} + {{ record.word_count || "--" }} - - - - - - - + @@ -282,6 +326,11 @@ function refreshDashboard(): void { :article-id="selectedArticleId" @close="articleDrawerOpen = false" /> + + @@ -490,67 +539,12 @@ function refreshDashboard(): void { .table-title-col { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .table-title-col strong { font-size: 14px; 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) { .workspace-header-grid { diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 4f9a421..5ba16cf 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -59,6 +59,7 @@ export interface RecentArticle { generate_status: string; publish_status: string; source_type: string; + generation_mode: string | null; word_count: number; source_label: string | null; created_at: string; diff --git a/server/internal/tenant/app/workspace_service.go b/server/internal/tenant/app/workspace_service.go index 2860304..6a65a9b 100644 --- a/server/internal/tenant/app/workspace_service.go +++ b/server/internal/tenant/app/workspace_service.go @@ -61,6 +61,7 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA GenerateStatus: row.GenerateStatus, PublishStatus: row.PublishStatus, SourceType: row.SourceType, + GenerationMode: row.GenerationMode, CreatedAt: row.CreatedAt, Title: row.Title, WordCount: row.WordCount, diff --git a/server/internal/tenant/domain/workspace.go b/server/internal/tenant/domain/workspace.go index fd10f31..8ff0eb4 100644 --- a/server/internal/tenant/domain/workspace.go +++ b/server/internal/tenant/domain/workspace.go @@ -16,6 +16,7 @@ type RecentArticle struct { GenerateStatus string `json:"generate_status"` PublishStatus string `json:"publish_status"` SourceType string `json:"source_type"` + GenerationMode *string `json:"generation_mode"` WordCount int `json:"word_count"` SourceLabel *string `json:"source_label"` CreatedAt time.Time `json:"created_at"` diff --git a/server/internal/tenant/repository/generated/workspace.sql.go b/server/internal/tenant/repository/generated/workspace.sql.go index 3572a84..1ffebdb 100644 --- a/server/internal/tenant/repository/generated/workspace.sql.go +++ b/server/internal/tenant/repository/generated/workspace.sql.go @@ -95,10 +95,21 @@ func (q *Queries) GetQuotaSummary(ctx context.Context, tenantID int64) (int32, e const getRecentArticles = `-- name: GetRecentArticles :many 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, - 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 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 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 ORDER BY a.created_at DESC LIMIT 10 @@ -115,6 +126,7 @@ type GetRecentArticlesRow struct { WordCount pgtype.Int4 `json:"word_count"` SourceLabel pgtype.Text `json:"source_label"` TemplateName pgtype.Text `json:"template_name"` + GenerationMode pgtype.Text `json:"generation_mode"` } 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.SourceLabel, &i.TemplateName, + &i.GenerationMode, ); err != nil { return nil, err } diff --git a/server/internal/tenant/repository/queries/workspace.sql b/server/internal/tenant/repository/queries/workspace.sql index ce7ad55..00c0e17 100644 --- a/server/internal/tenant/repository/queries/workspace.sql +++ b/server/internal/tenant/repository/queries/workspace.sql @@ -13,10 +13,21 @@ WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; -- name: GetRecentArticles :many 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, - 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 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 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 ORDER BY a.created_at DESC LIMIT 10; diff --git a/server/internal/tenant/repository/workspace_repo.go b/server/internal/tenant/repository/workspace_repo.go index 9f69b25..d053e05 100644 --- a/server/internal/tenant/repository/workspace_repo.go +++ b/server/internal/tenant/repository/workspace_repo.go @@ -15,6 +15,7 @@ type WorkspaceRecentArticle struct { GenerateStatus string PublishStatus string SourceType string + GenerationMode *string CreatedAt time.Time Title *string WordCount int @@ -72,6 +73,7 @@ func (r *workspaceRepository) GetRecentArticles(ctx context.Context, tenantID in GenerateStatus: row.GenerateStatus, PublishStatus: row.PublishStatus, SourceType: row.SourceType, + GenerationMode: nullableText(row.GenerationMode), CreatedAt: timeFromTimestamp(row.CreatedAt), Title: nullableText(row.Title), WordCount: resolveWorkspaceWordCount(row.MarkdownContent, intFromInt4(row.WordCount)),