文件
OmniCard/admin/src/pages/card/articles/index.tsx

677 行
21 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import {
Table,
Drawer,
Form,
Input,
Switch,
Button,
Popconfirm,
Card,
Tag,
Message,
Typography,
Space,
Grid,
Radio,
} from '@arco-design/web-react';
import { ImageUploader } from '@/components/Upload';
import type { PaginationProps, TableColumnProps } from '@arco-design/web-react';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import { lightColors } from '@/style/tokens';
import type { CardArticle, ArticleOgMeta } from '@/types/omni';
import locale from './locale';
type TablePaginationConfig = PaginationProps;
const DEFAULT_PAGE = 1;
const DEFAULT_SIZE = 10;
const PAGE_API = '/api/admin/card/article/page';
const ADD_API = '/api/admin/card/article/add';
const UPDATE_API = '/api/admin/card/article/update';
const DELETE_API = '/api/admin/card/article/delete';
interface ArticleFilterForm {
keyWord?: string;
cardId?: string;
isPublished?: boolean;
}
interface ArticleEditForm {
id?: number | string;
cardId?: number | string;
title?: string;
summary?: string;
content?: string;
coverImage?: string;
isPublished?: boolean;
}
interface CoolPagination {
page?: number;
size?: number;
total?: number;
}
interface CoolPageData {
list?: CardArticle[];
pagination?: CoolPagination;
}
interface CoolResponse<T = unknown> {
code: number;
message?: string;
data?: T;
}
function resolveErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError(error)) {
const payload = error.response?.data as { message?: string } | undefined;
if (payload && typeof payload.message === 'string' && payload.message) {
return payload.message;
}
if (error.message) {
return error.message;
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
function extractPlainText(content?: string): string {
if (!content) {
return '';
}
return content
.replace(/<[^>]+>/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/[#*`_~>-]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function extractFirstImage(content?: string): string {
if (!content) {
return '';
}
const htmlImgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (htmlImgMatch && htmlImgMatch[1]) {
return htmlImgMatch[1].trim();
}
const mdImgMatch = content.match(/!\[.*?\]\((https?:\/\/[^\s)]+|\/[^\s)]+)\)/i);
if (mdImgMatch && mdImgMatch[1]) {
return mdImgMatch[1].trim();
}
return '';
}
function deriveOgMeta(formData: ArticleEditForm): ArticleOgMeta {
const title = (formData.title || '').trim();
let description = (formData.summary || '').trim();
if (!description && formData.content) {
description = extractPlainText(formData.content).slice(0, 160);
}
let image = (formData.coverImage || '').trim();
if (!image && formData.content) {
image = extractFirstImage(formData.content);
}
return { title, description, image };
}
export default function CardArticlesPage() {
const t = useLocale(locale);
const [filterForm] = Form.useForm<ArticleFilterForm>();
const [drawerForm] = Form.useForm<ArticleEditForm>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [articles, setArticles] = useState<CardArticle[]>([]);
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: DEFAULT_PAGE,
pageSize: DEFAULT_SIZE,
total: 0,
showTotal: true,
});
const [drawerVisible, setDrawerVisible] = useState(false);
const [currentArticleId, setCurrentArticleId] = useState<number | string | null>(null);
// 表单联动 OpenGraph 实时预览状态
const [formValues, setFormValues] = useState<ArticleEditForm>({
cardId: 1,
title: '',
summary: '',
content: '',
coverImage: '',
isPublished: false,
});
const [ogRatio, setOgRatio] = useState<'landscape' | 'square'>('landscape');
const ogPreview = useMemo(() => deriveOgMeta(formValues), [formValues]);
const loadData = async (page = DEFAULT_PAGE, size = DEFAULT_SIZE) => {
setLoading(true);
try {
const filters = filterForm.getFieldsValue();
const payload: Record<string, unknown> = {
page,
size,
};
if (filters.keyWord) {
payload.keyWord = filters.keyWord.trim();
}
if (filters.cardId) {
payload.cardId = filters.cardId.trim();
}
if (typeof filters.isPublished === 'boolean') {
payload.isPublished = filters.isPublished;
}
const res = await axios.post<CoolResponse<CoolPageData>>(PAGE_API, payload);
if (res.data && res.data.code === 1000) {
const pageData = res.data.data;
const list = Array.isArray(pageData?.list) ? pageData.list : [];
setArticles(list);
setPagination((prev) => ({
...prev,
current: page,
pageSize: size,
total: pageData?.pagination?.total ?? list.length,
}));
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleOpenCreate = () => {
setCurrentArticleId(null);
const initData: ArticleEditForm = {
cardId: 1,
title: '',
summary: '',
content: '',
coverImage: '',
isPublished: true,
};
drawerForm.setFieldsValue(initData);
setFormValues(initData);
setDrawerVisible(true);
};
const handleOpenEdit = (record: CardArticle) => {
setCurrentArticleId(record.id ?? null);
const editData: ArticleEditForm = {
id: record.id,
cardId: record.cardId,
title: record.title,
summary: record.summary,
content: record.content,
coverImage: record.coverImage,
isPublished: Boolean(record.isPublished),
};
drawerForm.setFieldsValue(editData);
setFormValues(editData);
setDrawerVisible(true);
};
const handleTogglePublish = async (record: CardArticle, checked: boolean) => {
try {
const res = await axios.post<CoolResponse>(UPDATE_API, {
id: record.id,
isPublished: checked,
});
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.statusSuccess']);
setArticles((prev) =>
prev.map((item) => (item.id === record.id ? { ...item, isPublished: checked } : item))
);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
}
};
const handleDelete = async (record: CardArticle) => {
try {
const res = await axios.post<CoolResponse>(DELETE_API, {
ids: [record.id],
});
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.deleteSuccess']);
loadData(pagination.current, pagination.pageSize);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
}
};
const handleSaveArticle = async () => {
try {
const values = await drawerForm.validate();
setSaving(true);
const isEdit = Boolean(currentArticleId);
const api = isEdit ? UPDATE_API : ADD_API;
const payload = {
...values,
id: currentArticleId ?? undefined,
ogMeta: deriveOgMeta(values),
};
const res = await axios.post<CoolResponse>(api, payload);
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.saveSuccess']);
setDrawerVisible(false);
loadData(pagination.current, pagination.pageSize);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
} finally {
setSaving(false);
}
};
const columns: TableColumnProps<CardArticle>[] = [
{
title: t['cardArticles.field.id'],
dataIndex: 'id',
width: 80,
},
{
title: t['cardArticles.field.cardId'],
dataIndex: 'cardId',
width: 100,
render: (val: unknown) => (val !== undefined && val !== null ? String(val) : '-'),
},
{
title: t['cardArticles.field.title'],
dataIndex: 'title',
render: (title: string, record: CardArticle) => (
<div>
<Typography.Text style={{ fontWeight: 500, color: lightColors.text }}>
{title}
</Typography.Text>
{record.summary && (
<div
style={{
fontSize: '12px',
color: lightColors.text2,
marginTop: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '380px',
}}
>
{record.summary}
</div>
)}
</div>
),
},
{
title: t['cardArticles.field.status'],
dataIndex: 'isPublished',
width: 130,
render: (isPublished: boolean, record: CardArticle) => (
<Space size={8}>
<Switch
checked={Boolean(isPublished)}
size="small"
onChange={(checked) => handleTogglePublish(record, checked)}
/>
<Tag color={isPublished ? lightColors.success : lightColors.text2}>
{isPublished ? t['cardArticles.status.published'] : t['cardArticles.status.draft']}
</Tag>
</Space>
),
},
{
title: t['cardArticles.field.updateTime'],
dataIndex: 'updateTime',
width: 180,
render: (val: unknown) => (typeof val === 'string' ? val : '-'),
},
{
title: t['cardArticles.field.operations'],
width: 140,
render: (_: unknown, record: CardArticle) => (
<Space size={12}>
<Button
type="text"
size="small"
style={{ color: lightColors.primary, padding: 0 }}
onClick={() => handleOpenEdit(record)}
>
{t['cardArticles.action.edit']}
</Button>
<Popconfirm
title={t['cardArticles.action.deleteConfirm']}
onOk={() => handleDelete(record)}
>
<Button
type="text"
size="small"
style={{ color: lightColors.error, padding: 0 }}
>
{t['cardArticles.action.delete']}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{/* 头部导航与操作栏 */}
<Card
bordered={false}
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
marginBottom: '16px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<div>
<Typography.Title heading={5} style={{ margin: 0, color: lightColors.text }}>
{t['cardArticles.title']}
</Typography.Title>
<Typography.Text style={{ color: lightColors.text2, fontSize: '13px' }}>
{t['cardArticles.description']}
</Typography.Text>
</div>
{/* 遵循瑞士国际主义风格:整屏唯一主 CTA 按钮 */}
<Button type="primary" onClick={handleOpenCreate}>
{t['cardArticles.action.add']}
</Button>
</div>
{/* 筛选表单 */}
<Form
form={filterForm}
layout="inline"
onValuesChange={() => loadData(1, pagination.pageSize)}
>
<Form.Item field="keyWord">
<Input.Search
placeholder={t['cardArticles.filter.search']}
allowClear
style={{ width: 260 }}
onSearch={() => loadData(1, pagination.pageSize)}
/>
</Form.Item>
<Form.Item field="cardId">
<Input
placeholder={t['cardArticles.filter.cardId']}
allowClear
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item>
<Button
type="secondary"
onClick={() => {
filterForm.resetFields();
loadData(1, pagination.pageSize);
}}
>
{t['cardArticles.action.refresh']}
</Button>
</Form.Item>
</Form>
</Card>
{/* 数据表格 */}
<Card
bordered={false}
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
}}
>
<Table<CardArticle>
rowKey="id"
loading={loading}
columns={columns}
data={articles}
pagination={{
...pagination,
onChange: (current, pageSize) => loadData(current, pageSize),
}}
noDataElement={
<div style={{ textAlign: 'center', padding: '48px 0', color: lightColors.text2 }}>
{t['cardArticles.empty.text']}
</div>
}
/>
</Card>
{/* 文章编辑与 OpenGraph 社交预览抽屉 */}
<Drawer
title={
currentArticleId
? t['cardArticles.drawer.editTitle']
: t['cardArticles.drawer.createTitle']
}
visible={drawerVisible}
width={780}
onCancel={() => setDrawerVisible(false)}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button type="secondary" onClick={() => setDrawerVisible(false)}>
{t['cardArticles.action.saveDraft']}
</Button>
<Button
type="outline"
loading={saving}
onClick={handleSaveArticle}
>
{t['cardArticles.action.publish']}
</Button>
</div>
}
>
<Grid.Row gutter={24}>
{/* 左侧表单编辑区 */}
<Grid.Col span={14}>
<Form
form={drawerForm}
layout="vertical"
onValuesChange={(_, allValues) => setFormValues(allValues)}
>
<Form.Item
label={t['cardArticles.field.cardId']}
field="cardId"
rules={[{ required: true }]}
>
<Input placeholder="101" />
</Form.Item>
<Form.Item
label={t['cardArticles.field.title']}
field="title"
rules={[{ required: true }]}
>
<Input placeholder="输入深度文章标题..." maxLength={120} showWordLimit />
</Form.Item>
<Form.Item label={t['cardArticles.field.summary']} field="summary">
<Input.TextArea
placeholder="文章摘要(留空将自动从正文剥离 HTML 提取前 160 字符)..."
rows={3}
maxLength={300}
showWordLimit
/>
</Form.Item>
<Form.Item label={t['cardArticles.field.coverImage']} field="coverImage">
<ImageUploader />
</Form.Item>
<Form.Item
label={t['cardArticles.field.content']}
field="content"
rules={[{ required: true }]}
>
<Input.TextArea
placeholder="正文内容(支持 HTML 或 Markdown 格式)..."
rows={8}
/>
</Form.Item>
<Form.Item
label={t['cardArticles.field.status']}
field="isPublished"
triggerPropName="checked"
>
<Switch />
</Form.Item>
</Form>
</Grid.Col>
{/* 右侧 OpenGraph 社交图文卡实时预览区 */}
<Grid.Col span={10}>
<div
style={{
backgroundColor: lightColors.surface,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
padding: '16px',
}}
>
<Typography.Title heading={6} style={{ margin: 0, color: lightColors.text }}>
{t['cardArticles.ogPreview.title']}
</Typography.Title>
<Typography.Text
style={{
display: 'block',
fontSize: '12px',
color: lightColors.text2,
marginTop: '4px',
marginBottom: '12px',
}}
>
{t['cardArticles.ogPreview.desc']}
</Typography.Text>
<Radio.Group
type="button"
size="mini"
value={ogRatio}
onChange={setOgRatio}
style={{ marginBottom: '12px', width: '100%', display: 'flex' }}
>
<Radio value="landscape" style={{ flex: 1, textAlign: 'center' }}>
{t['cardArticles.ogPreview.landscape']}
</Radio>
<Radio value="square" style={{ flex: 1, textAlign: 'center' }}>
{t['cardArticles.ogPreview.square']}
</Radio>
</Radio.Group>
{/* 模拟国际社交平台卡片 (LinkedIn / WhatsApp / X Card) */}
<div
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,24,40,0.04)',
}}
>
{/* 封面图预览 */}
<div
style={{
height: ogRatio === 'square' ? '220px' : '140px',
transition: 'height 250ms cubic-bezier(0.2, 0.8, 0.2, 1)',
backgroundColor: lightColors.primarySoft,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{ogPreview.image ? (
<img
src={ogPreview.image}
alt="OpenGraph Cover"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Typography.Text style={{ color: lightColors.primary, fontSize: '12px' }}>
OG Image
</Typography.Text>
)}
</div>
{/* 社交文本预览 */}
<div style={{ padding: '12px' }}>
<div
style={{
fontSize: '11px',
textTransform: 'uppercase',
color: lightColors.text2,
letterSpacing: '0.5px',
}}
>
{t['cardArticles.ogPreview.domain']}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: lightColors.text,
marginTop: '4px',
lineHeight: 1.3,
}}
>
{ogPreview.title || t['cardArticles.ogPreview.emptyTitle']}
</div>
<div
style={{
fontSize: '12px',
color: lightColors.text2,
marginTop: '6px',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{ogPreview.description || t['cardArticles.ogPreview.emptyDesc']}
</div>
</div>
</div>
</div>
</Grid.Col>
</Grid.Row>
</Drawer>
</div>
);
}