feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
后端: - 文章审核状态机:draft → pending_review → published(含 reject/unpublish) - 文章分类 CRUD(article_category entity + service + handler) - 文章标签 CRUD(article_tag + article_article_tag 关联) - 文章修订版快照(article_revision) - 阅读计数、排序、slug、审核备注 - 新增 health.articles.review 权限 前端: - ArticleManageList:状态标签页 + 分类筛选 + 关键字搜索 + 审核操作 - ArticleEditor:Wangeditor 富文本编辑器 + 元数据侧栏 - ArticleCategoryManage:分类 CRUD + 父子层级 - ArticleTagManage:标签 CRUD 修复: - diagnosis_service/health_data_service/dialysis_service: 补充 key_version 字段 - ArticleCategoryManage: 补充 Select 组件导入
This commit is contained in:
@@ -44,6 +44,12 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||
|
||||
// 内容管理
|
||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||
const ArticleEditor = lazy(() => import('./pages/health/ArticleEditor'));
|
||||
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
|
||||
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
@@ -192,6 +198,12 @@ export default function App() {
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
{/* 内容管理 */}
|
||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
|
||||
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
|
||||
<Route path="/health/article-tags" element={<ArticleTagManage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,52 +1,122 @@
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
// --- Article Types ---
|
||||
|
||||
export type ArticleStatus = 'draft' | 'pending_review' | 'published' | 'rejected';
|
||||
export type ArticleContentType = 'rich_text' | 'markdown';
|
||||
|
||||
export interface ArticleListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
cover_image?: string;
|
||||
category?: string;
|
||||
content_type: ArticleContentType;
|
||||
status: ArticleStatus;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
category_name?: string;
|
||||
tags?: ArticleTagItem[];
|
||||
author?: string;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
review_note?: string;
|
||||
view_count: number;
|
||||
sort_order: number;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface Article extends ArticleListItem {
|
||||
content?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateArticleReq {
|
||||
title: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
content_type?: ArticleContentType;
|
||||
cover_image?: string;
|
||||
category?: string;
|
||||
author?: string;
|
||||
published_at?: string;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
tag_ids?: string[];
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateArticleReq {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
content_type?: ArticleContentType;
|
||||
cover_image?: string;
|
||||
category?: string;
|
||||
author?: string;
|
||||
published_at?: string;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
tag_ids?: string[];
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
export interface ArticleListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: ArticleStatus;
|
||||
category_id?: string;
|
||||
tag_id?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// --- Category Types ---
|
||||
|
||||
export interface ArticleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
parent_name?: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryReq {
|
||||
name: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryReq {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// --- Tag Types ---
|
||||
|
||||
export interface ArticleTagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTagReq {
|
||||
name: string;
|
||||
slug?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// --- Article API ---
|
||||
|
||||
export const articleApi = {
|
||||
list: async (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
}) => {
|
||||
list: async (params: ArticleListParams) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<ArticleListItem>;
|
||||
@@ -85,4 +155,108 @@ export const articleApi = {
|
||||
}>(`/health/articles/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
submit: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/submit`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
approve: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/approve`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, version: number, review_note: string) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/reject`, { version, review_note });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
unpublish: async (id: string, version: number) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/unpublish`, { version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
view: async (id: string) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: Article;
|
||||
}>(`/health/articles/${id}/view`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// --- Category API ---
|
||||
|
||||
export const articleCategoryApi = {
|
||||
list: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ArticleCategory[];
|
||||
}>('/health/article-categories');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateCategoryReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: ArticleCategory;
|
||||
}>('/health/article-categories', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: UpdateCategoryReq) => {
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: ArticleCategory;
|
||||
}>(`/health/article-categories/${id}`, req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/article-categories/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// --- Tag API ---
|
||||
|
||||
export const articleTagApi = {
|
||||
list: async () => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: ArticleTagItem[];
|
||||
}>('/health/article-tags');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
create: async (req: CreateTagReq) => {
|
||||
const { data } = await client.post<{
|
||||
success: boolean;
|
||||
data: ArticleTagItem;
|
||||
}>('/health/article-tags', req);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/article-tags/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,6 +87,11 @@ const routeTitleFallback: Record<string, string> = {
|
||||
'/health/consultations/:id': '咨询详情',
|
||||
'/health/points-rules': '积分规则管理',
|
||||
'/health/offline-events': '线下活动管理',
|
||||
'/health/articles': '内容管理',
|
||||
'/health/articles/new': '新建文章',
|
||||
'/health/articles/:id/edit': '编辑文章',
|
||||
'/health/article-categories': '分类管理',
|
||||
'/health/article-tags': '标签管理',
|
||||
};
|
||||
|
||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||
|
||||
273
apps/web/src/pages/health/ArticleCategoryManage.tsx
Normal file
273
apps/web/src/pages/health/ArticleCategoryManage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
articleCategoryApi,
|
||||
type ArticleCategory,
|
||||
type CreateCategoryReq,
|
||||
type UpdateCategoryReq,
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
export default function ArticleCategoryManage() {
|
||||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<ArticleCategory | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await articleCategoryApi.list();
|
||||
setCategories(result);
|
||||
} catch {
|
||||
message.error('加载分类列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingCategory(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ sort_order: 0 });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: ArticleCategory) => {
|
||||
setEditingCategory(record);
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
slug: record.slug,
|
||||
parent_id: record.parent_id,
|
||||
sort_order: record.sort_order,
|
||||
description: record.description,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingCategory(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (editingCategory) {
|
||||
const req: UpdateCategoryReq = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
parent_id: values.parent_id,
|
||||
sort_order: values.sort_order,
|
||||
description: values.description,
|
||||
};
|
||||
await articleCategoryApi.update(editingCategory.id, req);
|
||||
message.success('分类更新成功');
|
||||
} else {
|
||||
const req: CreateCategoryReq = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
parent_id: values.parent_id,
|
||||
sort_order: values.sort_order,
|
||||
description: values.description,
|
||||
};
|
||||
await articleCategoryApi.create(req);
|
||||
message.success('分类创建成功');
|
||||
}
|
||||
closeModal();
|
||||
fetchCategories();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await articleCategoryApi.delete(id);
|
||||
message.success('分类已删除');
|
||||
fetchCategories();
|
||||
} catch {
|
||||
message.error('删除失败,可能该分类下还有文章');
|
||||
}
|
||||
};
|
||||
|
||||
// 构建父分类选项(排除自身)
|
||||
const parentOptions = categories
|
||||
.filter((c) => !editingCategory || c.id !== editingCategory.id)
|
||||
.map((c) => ({ label: c.name, value: c.id }));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '分类名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '别名 (Slug)',
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
width: 180,
|
||||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '父分类',
|
||||
dataIndex: 'parent_name',
|
||||
key: 'parent_name',
|
||||
width: 140,
|
||||
render: (_v: string | undefined, record: ArticleCategory) => {
|
||||
if (!record.parent_id) return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||||
const parent = categories.find((c) => c.id === record.parent_id);
|
||||
return parent?.name || record.parent_id;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order',
|
||||
width: 80,
|
||||
render: (v: number) => v ?? 0,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: ArticleCategory) => (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此分类?"
|
||||
description="删除后不可恢复,关联文章将变为未分类"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>分类管理</h4>
|
||||
<div className="erp-page-subtitle">管理文章分类,支持多级分类结构</div>
|
||||
</div>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建分类
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={categories}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑分类弹窗 */}
|
||||
<Modal
|
||||
title={editingCategory ? '编辑分类' : '新建分类'}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
width={520}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="分类名称"
|
||||
rules={[{ required: true, message: '请输入分类名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入分类名称" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名 (Slug)">
|
||||
<Input placeholder="例如: health-tips(留空则自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parent_id" label="父分类">
|
||||
<Select
|
||||
placeholder="选择父分类(可选)"
|
||||
allowClear
|
||||
options={parentOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="请输入分类描述" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
apps/web/src/pages/health/ArticleEditor.tsx
Normal file
516
apps/web/src/pages/health/ArticleEditor.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button, Input, Select, Space, message, Spin } from 'antd';
|
||||
import { ArrowLeftOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import {
|
||||
articleApi,
|
||||
articleCategoryApi,
|
||||
articleTagApi,
|
||||
type Article,
|
||||
type ArticleTagItem,
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
|
||||
export default function ArticleEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
const isDark = useThemeMode();
|
||||
|
||||
// 表单状态
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [coverImage, setCoverImage] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// 选项数据
|
||||
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||
|
||||
// 加载分类和标签
|
||||
useEffect(() => {
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
const [cats, tagList] = await Promise.all([
|
||||
articleCategoryApi.list(),
|
||||
articleTagApi.list(),
|
||||
]);
|
||||
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||
setTags(tagList);
|
||||
} catch {
|
||||
// 选项加载失败不阻塞
|
||||
}
|
||||
};
|
||||
fetchOptions();
|
||||
}, []);
|
||||
|
||||
// 编辑模式:加载现有文章
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fetchArticle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const article: Article = await articleApi.get(id);
|
||||
setTitle(article.title);
|
||||
setSummary(article.summary || '');
|
||||
setContent(article.content || '');
|
||||
setCoverImage(article.cover_image || '');
|
||||
setSlug(article.slug || '');
|
||||
setCategoryId(article.category_id);
|
||||
setSelectedTagIds(article.tags?.map((t) => t.id) || []);
|
||||
setSortOrder(article.sort_order);
|
||||
setVersion(article.version);
|
||||
} catch {
|
||||
message.error('加载文章失败');
|
||||
navigate('/health/articles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchArticle();
|
||||
}, [id, navigate]);
|
||||
|
||||
// 编辑器配置
|
||||
const toolbarConfig = useMemo<Partial<IToolbarConfig>>(
|
||||
() => ({
|
||||
excludeKeys: [
|
||||
'group-video',
|
||||
'insertLink',
|
||||
'editLink',
|
||||
'unLink',
|
||||
'viewLink',
|
||||
'codeView',
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const editorConfig = useMemo<Partial<IEditorConfig>>(
|
||||
() => ({
|
||||
placeholder: '请输入文章内容...',
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
// 自定义图片上传 - 预留后端接口
|
||||
async customUpload(_file: File, _insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||
// TODO: 实现图片上传到后端
|
||||
message.warning('图片上传功能待实现');
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// 及时销毁编辑器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
setEditor(null);
|
||||
}
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) {
|
||||
message.warning('请输入文章标题');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await articleApi.update(id, {
|
||||
title,
|
||||
summary: summary || undefined,
|
||||
content,
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
sort_order: sortOrder,
|
||||
version,
|
||||
});
|
||||
message.success('文章已保存');
|
||||
// 重新加载以获取新 version
|
||||
const updated = await articleApi.get(id);
|
||||
setVersion(updated.version);
|
||||
} else {
|
||||
const created = await articleApi.create({
|
||||
title,
|
||||
summary: summary || undefined,
|
||||
content,
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
message.success('文章已创建');
|
||||
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'保存失败';
|
||||
message.error(errorMsg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
id, isEdit, title, summary, content, coverImage, slug, categoryId,
|
||||
selectedTagIds, sortOrder, version, navigate,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim()) {
|
||||
message.warning('请输入文章标题');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
// 先保存
|
||||
let currentVersion = version;
|
||||
if (isEdit && id) {
|
||||
await articleApi.update(id, {
|
||||
title,
|
||||
summary: summary || undefined,
|
||||
content,
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
sort_order: sortOrder,
|
||||
version,
|
||||
});
|
||||
const updated = await articleApi.get(id);
|
||||
currentVersion = updated.version;
|
||||
setVersion(updated.version);
|
||||
} else {
|
||||
const created = await articleApi.create({
|
||||
title,
|
||||
summary: summary || undefined,
|
||||
content,
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
currentVersion = created.version;
|
||||
setVersion(created.version);
|
||||
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
||||
}
|
||||
// 提交审核
|
||||
if (id || isEdit) {
|
||||
const articleId = id!;
|
||||
await articleApi.submit(articleId, currentVersion);
|
||||
}
|
||||
message.success('已提交审核');
|
||||
navigate('/health/articles');
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'提交审核失败';
|
||||
message.error(errorMsg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
id, isEdit, title, summary, content, coverImage, slug, categoryId,
|
||||
selectedTagIds, sortOrder, version, navigate,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题栏 */}
|
||||
<div className="erp-page-header">
|
||||
<Space size={12}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/health/articles')}
|
||||
/>
|
||||
<div>
|
||||
<h4 style={{ margin: 0 }}>{isEdit ? '编辑文章' : '新建文章'}</h4>
|
||||
</div>
|
||||
</Space>
|
||||
<Space size={8}>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
>
|
||||
保存草稿
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={saving}
|
||||
>
|
||||
提交审核
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||
{/* 左侧: 富文本编辑器 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
mode="default"
|
||||
style={{
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
borderBottom: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ height: 600, overflowY: 'auto' }}>
|
||||
<Editor
|
||||
defaultConfig={editorConfig}
|
||||
value={content}
|
||||
onCreated={setEditor}
|
||||
onChange={(editorInstance) => setContent(editorInstance.getHtml())}
|
||||
mode="default"
|
||||
style={{
|
||||
minHeight: 500,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
color: isDark ? '#e2e8f0' : '#1e293b',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧: 设置面板 */}
|
||||
<div
|
||||
style={{
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
文章标题 <span style={{ color: '#dc2626' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="请输入文章标题"
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分类 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
分类
|
||||
</label>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="选择分类"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
标签
|
||||
</label>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedTagIds}
|
||||
onChange={setSelectedTagIds}
|
||||
placeholder="选择标签"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={tags.map((t) => ({ label: t.name, value: t.id }))}
|
||||
maxTagCount={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 摘要 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
摘要
|
||||
</label>
|
||||
<Input.TextArea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="请输入文章摘要"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
封面图 URL
|
||||
</label>
|
||||
<Input
|
||||
value={coverImage}
|
||||
onChange={(e) => setCoverImage(e.target.value)}
|
||||
placeholder="请输入封面图片 URL"
|
||||
/>
|
||||
{coverImage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={coverImage}
|
||||
alt="封面预览"
|
||||
style={{ width: '100%', height: 120, objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
URL 别名 (Slug)
|
||||
</label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="例如: health-tips-for-elderly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}
|
||||
>
|
||||
排序
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
498
apps/web/src/pages/health/ArticleManageList.tsx
Normal file
498
apps/web/src/pages/health/ArticleManageList.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Tabs,
|
||||
Popconfirm,
|
||||
message,
|
||||
Modal,
|
||||
Form,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SendOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
RollbackOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
articleApi,
|
||||
articleCategoryApi,
|
||||
type ArticleListItem,
|
||||
type ArticleStatus,
|
||||
type ArticleTagItem,
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
{ key: 'pending_review', label: '待审核' },
|
||||
{ key: 'published', label: '已发布' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
];
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
draft: { label: '草稿', color: 'default' },
|
||||
pending_review: { label: '待审核', color: 'processing' },
|
||||
published: { label: '已发布', color: 'success' },
|
||||
rejected: { label: '已拒绝', color: 'error' },
|
||||
};
|
||||
|
||||
export default function ArticleManageList() {
|
||||
const [articles, setArticles] = useState<ArticleListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusTab, setStatusTab] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
|
||||
const [rejectForm] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchArticles = useCallback(
|
||||
async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await articleApi.list({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
status: (statusTab || undefined) as ArticleStatus | undefined,
|
||||
category_id: categoryId,
|
||||
keyword: keyword || undefined,
|
||||
});
|
||||
setArticles(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载文章列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[page, statusTab, categoryId, keyword],
|
||||
);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const cats = await articleCategoryApi.list();
|
||||
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||
} catch {
|
||||
// 分类列表加载失败不阻塞页面
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, [fetchArticles]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedSearch = useCallback((value: string) => {
|
||||
setKeyword(value);
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await articleApi.delete(id);
|
||||
message.success('文章已删除');
|
||||
fetchArticles();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.submit(record.id, record.version);
|
||||
message.success('已提交审核');
|
||||
fetchArticles();
|
||||
} catch {
|
||||
message.error('提交审核失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.approve(record.id, record.version);
|
||||
message.success('审核通过,文章已发布');
|
||||
fetchArticles();
|
||||
} catch {
|
||||
message.error('审核操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openRejectModal = (record: ArticleListItem) => {
|
||||
setRejectingArticle(record);
|
||||
rejectForm.resetFields();
|
||||
setRejectModalOpen(true);
|
||||
};
|
||||
|
||||
const handleReject = async (values: { review_note: string }) => {
|
||||
if (!rejectingArticle) return;
|
||||
try {
|
||||
await articleApi.reject(
|
||||
rejectingArticle.id,
|
||||
rejectingArticle.version,
|
||||
values.review_note,
|
||||
);
|
||||
message.success('已拒绝文章');
|
||||
setRejectModalOpen(false);
|
||||
fetchArticles();
|
||||
} catch {
|
||||
message.error('拒绝操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.unpublish(record.id, record.version);
|
||||
message.success('文章已撤回为草稿');
|
||||
fetchArticles();
|
||||
} catch {
|
||||
message.error('撤回操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const renderActions = (record: ArticleListItem) => (
|
||||
<Space size={4} wrap>
|
||||
{record.status === 'draft' && (
|
||||
<>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => handleSubmit(record)}
|
||||
>
|
||||
提交审核
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'pending_review' && (
|
||||
<>
|
||||
<AuthButton code="health.articles.review">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
style={{ color: '#059669' }}
|
||||
onClick={() => handleApprove(record)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton code="health.articles.review">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
onClick={() => openRejectModal(record)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'published' && (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<RollbackOutlined />}
|
||||
onClick={() => handleUnpublish(record)}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
{(record.status === 'draft' || record.status === 'rejected') && (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Popconfirm
|
||||
title="确定删除此文章?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
render: (title: string, record: ArticleListItem) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{record.cover_image && (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 6,
|
||||
background: `url(${record.cover_image}) center/cover`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{ fontWeight: 500, fontSize: 14, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{record.summary && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#64748b' : '#94a3b8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
{record.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category_name',
|
||||
key: 'category_name',
|
||||
width: 120,
|
||||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>未分类</span>,
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tags',
|
||||
key: 'tags',
|
||||
width: 180,
|
||||
render: (tags?: ArticleTagItem[]) => {
|
||||
if (!tags || tags.length === 0) {
|
||||
return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{tags.map((t) => (
|
||||
<Tag
|
||||
key={t.id}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
const config = STATUS_CONFIG[status] || { label: status, color: 'default' };
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '作者',
|
||||
dataIndex: 'author',
|
||||
key: 'author',
|
||||
width: 100,
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '阅读数',
|
||||
dataIndex: 'view_count',
|
||||
key: 'view_count',
|
||||
width: 80,
|
||||
render: (v: number) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }} />
|
||||
{v ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '发布时间',
|
||||
dataIndex: 'published_at',
|
||||
key: 'published_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: ArticleListItem) => renderActions(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>内容管理</h4>
|
||||
<div className="erp-page-subtitle">管理健康科普文章、资讯和内容发布</div>
|
||||
</div>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/health/articles/new')}
|
||||
>
|
||||
新建文章
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="搜索文章标题..."
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={keyword}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220, borderRadius: 8 }}
|
||||
/>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(v) => {
|
||||
setCategoryId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="选择分类"
|
||||
allowClear
|
||||
style={{ width: 160, borderRadius: 8 }}
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态标签页 + 表格 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={statusTab}
|
||||
onChange={(key) => {
|
||||
setStatusTab(key);
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
style={{ padding: '0 16px', marginBottom: 0 }}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={articles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchArticles(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 拒绝理由弹窗 */}
|
||||
<Modal
|
||||
title="拒绝文章"
|
||||
open={rejectModalOpen}
|
||||
onCancel={() => setRejectModalOpen(false)}
|
||||
onOk={() => rejectForm.submit()}
|
||||
okText="确认拒绝"
|
||||
okButtonProps={{ danger: true }}
|
||||
width={480}
|
||||
>
|
||||
<Form
|
||||
form={rejectForm}
|
||||
onFinish={handleReject}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="review_note"
|
||||
label="拒绝理由"
|
||||
rules={[{ required: true, message: '请输入拒绝理由' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="请输入拒绝理由" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
apps/web/src/pages/health/ArticleTagManage.tsx
Normal file
220
apps/web/src/pages/health/ArticleTagManage.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
articleTagApi,
|
||||
type ArticleTagItem,
|
||||
type CreateTagReq,
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
export default function ArticleTagManage() {
|
||||
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await articleTagApi.list();
|
||||
setTags(result);
|
||||
} catch {
|
||||
message.error('加载标签列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, [fetchTags]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCreate = async (values: { name: string; slug?: string; color?: string }) => {
|
||||
try {
|
||||
const req: CreateTagReq = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
color: values.color,
|
||||
};
|
||||
await articleTagApi.create(req);
|
||||
message.success('标签创建成功');
|
||||
closeModal();
|
||||
fetchTags();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'创建失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await articleTagApi.delete(id);
|
||||
message.success('标签已删除');
|
||||
fetchTags();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标签名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: ArticleTagItem) => (
|
||||
<Tag
|
||||
color={record.color || undefined}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '2px 10px',
|
||||
...(record.color ? {} : {
|
||||
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '别名 (Slug)',
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
width: 180,
|
||||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '颜色',
|
||||
dataIndex: 'color',
|
||||
key: 'color',
|
||||
width: 100,
|
||||
render: (v?: string) =>
|
||||
v ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
background: v,
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 12, fontFamily: 'monospace' }}>{v}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>默认</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: unknown, record: ArticleTagItem) => (
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Popconfirm
|
||||
title="确定删除此标签?"
|
||||
description="删除后关联的文章将移除该标签"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>标签管理</h4>
|
||||
<div className="erp-page-subtitle">管理文章标签,用于文章分类和筛选</div>
|
||||
</div>
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建标签
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tags}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新建标签弹窗 */}
|
||||
<Modal
|
||||
title="新建标签"
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => form.submit()}
|
||||
width={440}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标签名称"
|
||||
rules={[{ required: true, message: '请输入标签名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入标签名称" maxLength={30} />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名 (Slug)">
|
||||
<Input placeholder="例如: diabetes(留空则自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="颜色">
|
||||
<Input
|
||||
type="color"
|
||||
style={{ width: 60, height: 36, padding: 2, cursor: 'pointer' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user