677 行
21 KiB
TypeScript
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>
|
|
);
|
|
}
|