Files
hms/apps/web/src/pages/health/ArticleEditor.tsx
iven 0d3e45300f
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
refactor(web): 前端错误处理统一化 — 9 个文件 13 处替换 handleApiError
统一使用 api/client.ts 的 handleApiError() 替代内联错误提取:
- Login/Users/Roles/Organizations/Settings 操作失败提示
- ArticleEditor/ArticleTagManage/ArticleCategoryManage 表单错误
- FamilyMembersTab 家庭成员操作

零 response?.data?.message 内联模式残留
2026-05-03 19:59:12 +08:00

549 lines
16 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined } 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 client, { handleApiError } from '../../api/client';
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) {
try {
const formData = new FormData();
formData.append('file', file);
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url: string = result.data.url;
const token = localStorage.getItem('access_token');
const urlWithToken = token ? `${url}?token=${token}` : url;
insertFn(urlWithToken, file.name, urlWithToken);
} catch {
message.error('图片上传失败');
}
},
},
},
}),
[],
);
// 及时销毁编辑器
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 {
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('文章已创建');
// 返回列表页,避免 WangEditor 全局 toolbar 注册导致同组件内重复初始化
navigate('/health/articles');
}
} catch (err: unknown) {
handleApiError(err, '保存失败');
} 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);
await articleApi.submit(_created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
}
// 编辑模式提交审核
if (id) {
await articleApi.submit(id, currentVersion);
}
message.success('已提交审核');
navigate('/health/articles');
} catch (err: unknown) {
handleApiError(err, '提交审核失败');
} 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' }}>
{/* 左侧: 富文本编辑器 — key 变化时强制重新挂载,防止 WangEditor toolbar 重复创建 */}
<div
key={id ?? 'new'}
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',
}}
>
</label>
<Space.Compact style={{ width: '100%' }}>
<Input
value={coverImage}
onChange={(e) => setCoverImage(e.target.value)}
placeholder="请输入封面图片 URL 或上传文件"
style={{ flex: 1 }}
/>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={async (file) => {
try {
const formData = new FormData();
formData.append('file', file);
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setCoverImage(result.data.url);
message.success('封面图上传成功');
} catch {
message.error('封面图上传失败');
}
return false;
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Space.Compact>
{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>
);
}