统一使用 api/client.ts 的 handleApiError() 替代内联错误提取: - Login/Users/Roles/Organizations/Settings 操作失败提示 - ArticleEditor/ArticleTagManage/ArticleCategoryManage 表单错误 - FamilyMembersTab 家庭成员操作 零 response?.data?.message 内联模式残留
549 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|