feat: Implement News Info management features including CRUD operations and rich text editor
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled
This commit is contained in:
135
scripts/deploy-production-frontend.sh
Executable file
135
scripts/deploy-production-frontend.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# 正式环境部署参数固定在脚本中,避免通过环境变量误传到测试或其他服务器。
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly BUILD_DIR="${ROOT_DIR}/shihezi"
|
||||
readonly DEPLOY_HOST="47.111.103.66"
|
||||
readonly DEPLOY_USER="root"
|
||||
readonly DEPLOY_SSH_PORT="22"
|
||||
readonly DEPLOY_PATH="/opt/service/project/shz-admin/dist"
|
||||
|
||||
DRY_RUN=0
|
||||
|
||||
info() {
|
||||
printf '\033[1;32m%s\033[0m\n' "$*"
|
||||
}
|
||||
|
||||
error() {
|
||||
printf '\033[1;31m%s\033[0m\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy-production-frontend.sh
|
||||
./scripts/deploy-production-frontend.sh --dry-run
|
||||
|
||||
功能:
|
||||
1. 使用 npm run build 构建当前 PC 前端
|
||||
2. 将 shihezi/ 上传到正式服务器的 Nginx 静态目录
|
||||
3. 校验远程 index.html,并比对本地与远程 SHA-256
|
||||
|
||||
正式环境参数(固定):
|
||||
SSH 主机:root@47.111.103.66:22
|
||||
静态目录:/opt/service/project/shz-admin/dist
|
||||
访问前缀:/shihezi/
|
||||
|
||||
参数:
|
||||
--dry-run 只构建并检查产物,不连接服务器、不上传文件
|
||||
-h, --help 显示帮助
|
||||
|
||||
说明:
|
||||
脚本不会自动安装依赖。若 node_modules 不存在,请先执行 npm install。
|
||||
上传使用 rsync --delete,远程静态目录中本地没有的文件会被删除。
|
||||
脚本使用当前用户已有的 SSH key/agent,不保存密码。
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "未知参数:$1(使用 --help 查看用法)"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
validate_local_environment() {
|
||||
command -v npm >/dev/null 2>&1 || die "未找到 npm"
|
||||
command -v ssh >/dev/null 2>&1 || die "未找到 ssh"
|
||||
command -v rsync >/dev/null 2>&1 || die "未找到 rsync"
|
||||
command -v shasum >/dev/null 2>&1 || die "未找到 shasum,无法校验构建产物"
|
||||
|
||||
[[ -f "${ROOT_DIR}/package.json" ]] || die "未找到 ${ROOT_DIR}/package.json"
|
||||
[[ -d "${ROOT_DIR}/node_modules" ]] || die "未找到 node_modules,请先执行 npm install"
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
info "开始构建正式前端:npm run build"
|
||||
npm run build
|
||||
[[ -s "${BUILD_DIR}/index.html" ]] || die "构建完成但未找到 ${BUILD_DIR}/index.html"
|
||||
info "本地构建完成:${BUILD_DIR}"
|
||||
}
|
||||
|
||||
check_production_access() {
|
||||
local target="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
info "检查正式服务器目录:${target}:${DEPLOY_PATH}/"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -p "${DEPLOY_SSH_PORT}" "${target}" \
|
||||
"test -d '${DEPLOY_PATH}' && test -w '${DEPLOY_PATH}'"
|
||||
}
|
||||
|
||||
upload_frontend() {
|
||||
local target="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
local ssh_command="ssh -o BatchMode=yes -o ConnectTimeout=10 -p ${DEPLOY_SSH_PORT}"
|
||||
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
info "DRY RUN:不会连接服务器或上传文件"
|
||||
info "目标:${target}:${DEPLOY_PATH}/"
|
||||
return
|
||||
fi
|
||||
|
||||
check_production_access
|
||||
|
||||
info "上传构建产物:${BUILD_DIR}/ -> ${target}:${DEPLOY_PATH}/"
|
||||
rsync -az --delete --delay-updates \
|
||||
-e "${ssh_command}" \
|
||||
"${BUILD_DIR}/" "${target}:${DEPLOY_PATH}/"
|
||||
|
||||
local local_hash
|
||||
local remote_hash
|
||||
local_hash="$(shasum -a 256 "${BUILD_DIR}/index.html" | awk '{print $1}')"
|
||||
remote_hash="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -p "${DEPLOY_SSH_PORT}" "${target}" \
|
||||
"test -s '${DEPLOY_PATH}/index.html' && sha256sum '${DEPLOY_PATH}/index.html' | awk '{print \$1}'")"
|
||||
|
||||
[[ -n "${remote_hash}" ]] || die "远程 index.html 不存在或为空"
|
||||
[[ "${local_hash}" == "${remote_hash}" ]] || die "远程 index.html 校验失败:本地 ${local_hash},远程 ${remote_hash}"
|
||||
|
||||
info "正式前端部署完成:http://${DEPLOY_HOST}/shihezi/"
|
||||
info "index.html SHA-256 校验通过:${local_hash}"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
cd "${ROOT_DIR}"
|
||||
validate_local_environment
|
||||
build_frontend
|
||||
upload_frontend
|
||||
}
|
||||
|
||||
main "$@"
|
||||
54
src/pages/NewsInfo/components/DetailModal.tsx
Normal file
54
src/pages/NewsInfo/components/DetailModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Descriptions, Modal, Tag, Typography } from 'antd';
|
||||
import DictTag from '@/components/DictTag';
|
||||
import type { DictValueEnumObj } from '@/components/DictTag';
|
||||
import { normalizeNewsContent } from '@/utils/newsImageUrl';
|
||||
|
||||
interface DetailModalProps {
|
||||
open: boolean;
|
||||
data?: API.NewsInfo.NewsInfoItem;
|
||||
moduleEnum: DictValueEnumObj;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DetailModal: React.FC<DetailModalProps> = ({ open, data, moduleEnum, onCancel }) => (
|
||||
<Modal
|
||||
title="新闻资讯详情"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="资讯模块">
|
||||
{data?.module ? <DictTag enums={moduleEnum} value={data.module} /> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={data?.status === '0' ? 'success' : 'default'}>
|
||||
{data?.status === '0' ? '上架' : '下架'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标题" span={2}>
|
||||
{data?.title || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{data?.createTime || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{data?.updateTime || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="正文" span={2}>
|
||||
{data?.content ? (
|
||||
<Typography
|
||||
className="news-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: normalizeNewsContent(data.content) }}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{data?.remark || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DetailModal;
|
||||
111
src/pages/NewsInfo/components/EditModal.tsx
Normal file
111
src/pages/NewsInfo/components/EditModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAccess } from '@umijs/max';
|
||||
import { Col, Form, Input, Modal, Row, Select, Switch } from 'antd';
|
||||
import type { DictValueEnumObj } from '@/components/DictTag';
|
||||
import { normalizeNewsContent } from '@/utils/newsImageUrl';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
|
||||
interface EditModalProps {
|
||||
open: boolean;
|
||||
values?: API.NewsInfo.NewsInfoItem;
|
||||
moduleEnum: DictValueEnumObj;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: API.NewsInfo.NewsInfoPayload) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const EditModal: React.FC<EditModalProps> = ({ open, values, moduleEnum, onCancel, onSubmit }) => {
|
||||
const [form] = Form.useForm<API.NewsInfo.NewsInfoPayload & { published?: boolean }>();
|
||||
const access = useAccess();
|
||||
const canChangeStatus = access.hasPerms('cms:newsInfo:status');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.setFieldsValue({
|
||||
module: values?.module,
|
||||
title: values?.title,
|
||||
content: normalizeNewsContent(values?.content || ''),
|
||||
remark: values?.remark,
|
||||
published: values?.status === '0',
|
||||
});
|
||||
}, [form, open, values]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const formValues = await form.validateFields();
|
||||
await onSubmit({
|
||||
id: values?.id,
|
||||
module: formValues.module,
|
||||
title: formValues.title,
|
||||
content: normalizeNewsContent(formValues.content),
|
||||
remark: formValues.remark,
|
||||
status: canChangeStatus ? (formValues.published ? '0' : '1') : values?.status || '1',
|
||||
});
|
||||
};
|
||||
|
||||
const moduleOptions = Object.values(moduleEnum).map((item) => ({
|
||||
label: item.label,
|
||||
value: String(item.value),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={values ? '编辑新闻资讯' : '新增新闻资讯'}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
width={1000}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="module"
|
||||
label="资讯模块"
|
||||
rules={[{ required: true, message: '请选择资讯模块' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="请选择业务字典模块"
|
||||
options={moduleOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, max: 200, message: '请输入200字以内的标题' }]}
|
||||
>
|
||||
<Input showCount maxLength={200} placeholder="请输入新闻资讯标题" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="正文"
|
||||
rules={[{ required: true, message: '请输入正文' }]}
|
||||
valuePropName="value"
|
||||
>
|
||||
<RichTextEditor />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{canChangeStatus && (
|
||||
<Col span={8}>
|
||||
<Form.Item name="published" label="发布状态" valuePropName="checked">
|
||||
<Switch checkedChildren="上架" unCheckedChildren="下架" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={24}>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={500} showCount placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
35
src/pages/NewsInfo/components/RichTextEditor.less
Normal file
35
src/pages/NewsInfo/components/RichTextEditor.less
Normal file
@@ -0,0 +1,35 @@
|
||||
.news-rich-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
&__toolbar {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-height: 280px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.75;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 1px #1677ff;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 4px 12px;
|
||||
border-left: 4px solid #d9d9d9;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
229
src/pages/NewsInfo/components/RichTextEditor.tsx
Normal file
229
src/pages/NewsInfo/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, Modal, Select, Space, Tooltip, message } from 'antd';
|
||||
import {
|
||||
BoldOutlined,
|
||||
CodeOutlined,
|
||||
FontSizeOutlined,
|
||||
ItalicOutlined,
|
||||
LinkOutlined,
|
||||
OrderedListOutlined,
|
||||
PictureOutlined,
|
||||
StrikethroughOutlined,
|
||||
UnderlineOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { uploadNewsImage } from '@/services/cms/newsInfo';
|
||||
import { normalizeNewsContent, normalizeNewsImageUrl } from '@/utils/newsImageUrl';
|
||||
import './RichTextEditor.less';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({ value = '', onChange, disabled }) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastValueRef = useRef(value);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedValue = normalizeNewsContent(value);
|
||||
if (
|
||||
editorRef.current &&
|
||||
value !== lastValueRef.current &&
|
||||
normalizedValue !== editorRef.current.innerHTML
|
||||
) {
|
||||
editorRef.current.innerHTML = normalizedValue;
|
||||
}
|
||||
lastValueRef.current = normalizedValue;
|
||||
}, [value]);
|
||||
|
||||
const emitChange = () => {
|
||||
const html = editorRef.current?.innerHTML || '';
|
||||
lastValueRef.current = html;
|
||||
onChange?.(html);
|
||||
};
|
||||
|
||||
const exec = (command: string, commandValue?: string) => {
|
||||
if (disabled || !editorRef.current) return;
|
||||
editorRef.current.focus();
|
||||
document.execCommand(command, false, commandValue);
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const insertLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
if (!url) {
|
||||
message.warning('请输入链接地址');
|
||||
return;
|
||||
}
|
||||
exec('createLink', url);
|
||||
setLinkUrl('');
|
||||
setLinkModalOpen(false);
|
||||
};
|
||||
|
||||
const handleImageSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('只支持上传图片');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadNewsImage(file);
|
||||
const url = normalizeNewsImageUrl(
|
||||
result.url || result.data?.url,
|
||||
result.fileName || result.data?.fileName,
|
||||
);
|
||||
if (result.code !== 200 || !url) {
|
||||
throw new Error(result.msg || '图片上传失败');
|
||||
}
|
||||
exec('insertImage', url);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '图片上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="news-rich-editor">
|
||||
<div className="news-rich-editor__toolbar">
|
||||
<Space size={4} wrap>
|
||||
<Tooltip title="加粗">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<BoldOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('bold')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="斜体">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ItalicOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('italic')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下划线">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UnderlineOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('underline')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除线">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<StrikethroughOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('strikeThrough')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Select
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
value="正文/标题"
|
||||
options={[
|
||||
{ value: '正文/标题', label: '正文/标题' },
|
||||
{ value: 'H2', label: '标题 2' },
|
||||
{ value: 'H3', label: '标题 3' },
|
||||
{ value: 'P', label: '正文' },
|
||||
]}
|
||||
onChange={(format) => exec('formatBlock', format)}
|
||||
/>
|
||||
<Tooltip title="无序列表">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('insertUnorderedList')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="有序列表">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<OrderedListOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('insertOrderedList')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="引用">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('formatBlock', 'BLOCKQUOTE')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="插入链接">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => setLinkModalOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="插入图片">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PictureOutlined />}
|
||||
disabled={disabled || uploading}
|
||||
loading={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="清除格式">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontSizeOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => exec('removeFormat')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="news-rich-editor__content"
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
onInput={emitChange}
|
||||
onBlur={emitChange}
|
||||
dangerouslySetInnerHTML={{ __html: normalizeNewsContent(value) }}
|
||||
/>
|
||||
<Modal
|
||||
title="插入链接"
|
||||
open={linkModalOpen}
|
||||
onCancel={() => setLinkModalOpen(false)}
|
||||
onOk={insertLink}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(event) => setLinkUrl(event.target.value)}
|
||||
onPressEnter={insertLink}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
243
src/pages/NewsInfo/index.tsx
Normal file
243
src/pages/NewsInfo/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAccess } from '@umijs/max';
|
||||
import { Button, message, Modal, Switch, Tag } from 'antd';
|
||||
import { ActionType, PageContainer, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { DeleteOutlined, EyeOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
addNewsInfo,
|
||||
changeNewsInfoStatus,
|
||||
deleteNewsInfo,
|
||||
getNewsInfoDetail,
|
||||
getNewsInfoList,
|
||||
updateNewsInfo,
|
||||
} from '@/services/cms/newsInfo';
|
||||
import { getDictValueEnum } from '@/services/system/dict';
|
||||
import DictTag, { DictValueEnumObj } from '@/components/DictTag';
|
||||
import EditModal from './components/EditModal';
|
||||
import DetailModal from './components/DetailModal';
|
||||
|
||||
const NewsInfoPage: React.FC = () => {
|
||||
const access = useAccess();
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [moduleEnum, setModuleEnum] = useState<DictValueEnumObj>({});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<API.NewsInfo.NewsInfoItem>();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const loadModules = async () => {
|
||||
setModuleEnum(await getDictValueEnum('news_module', false, true));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadModules();
|
||||
}, []);
|
||||
|
||||
const handleDelete = (ids: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后新闻资讯将不再出现在列表和公开接口中,确定继续吗?',
|
||||
onOk: async () => {
|
||||
const result = await deleteNewsInfo(ids);
|
||||
if (result.code === 200) {
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
actionRef.current?.reload();
|
||||
} else {
|
||||
message.error(result.msg || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatus = (record: API.NewsInfo.NewsInfoItem, checked: boolean) => {
|
||||
const status: '0' | '1' = checked ? '0' : '1';
|
||||
const label = checked ? '上架' : '下架';
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `确定要${label}“${record.title}”吗?`,
|
||||
onOk: async () => {
|
||||
const result = await changeNewsInfoStatus(record.id, status);
|
||||
if (result.code === 200) {
|
||||
message.success(`${label}成功`);
|
||||
actionRef.current?.reload();
|
||||
} else {
|
||||
message.error(result.msg || `${label}失败`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ProColumns<API.NewsInfo.NewsInfoItem>[] = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
formItemProps: { rules: [{ max: 200 }] },
|
||||
},
|
||||
{
|
||||
title: '资讯模块',
|
||||
dataIndex: 'module',
|
||||
valueType: 'select',
|
||||
valueEnum: moduleEnum,
|
||||
render: (_, record) => <DictTag enums={moduleEnum} value={record.module} />,
|
||||
},
|
||||
{
|
||||
title: '上下架',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
width: 110,
|
||||
valueEnum: {
|
||||
'0': { text: '上架', status: 'Success' },
|
||||
'1': { text: '下架', status: 'Default' },
|
||||
},
|
||||
render: (_, record) =>
|
||||
access.hasPerms('cms:newsInfo:status') ? (
|
||||
<Switch
|
||||
checked={record.status === '0'}
|
||||
checkedChildren="上架"
|
||||
unCheckedChildren="下架"
|
||||
onChange={(checked) => handleStatus(record, checked)}
|
||||
/>
|
||||
) : (
|
||||
<Tag color={record.status === '0' ? 'success' : 'default'}>
|
||||
{record.status === '0' ? '上架' : '下架'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createTime', hideInSearch: true, width: 170 },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
fixed: 'right',
|
||||
width: 220,
|
||||
render: (_, record) => [
|
||||
<Button
|
||||
key="detail"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={async () => {
|
||||
const result = await getNewsInfoDetail(record.id);
|
||||
if (result.code === 200) {
|
||||
setCurrentRow(result.data);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>,
|
||||
<Button
|
||||
key="edit"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<FormOutlined />}
|
||||
hidden={!access.hasPerms('cms:newsInfo:edit')}
|
||||
onClick={async () => {
|
||||
const result = await getNewsInfoDetail(record.id);
|
||||
if (result.code === 200) {
|
||||
setCurrentRow(result.data);
|
||||
setEditOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
hidden={!access.hasPerms('cms:newsInfo:remove')}
|
||||
onClick={() => handleDelete(String(record.id))}
|
||||
>
|
||||
删除
|
||||
</Button>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ProTable<API.NewsInfo.NewsInfoItem>
|
||||
headerTitle="新闻资讯列表"
|
||||
actionRef={actionRef}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||||
request={async (params) => {
|
||||
const result = await getNewsInfoList({
|
||||
pageNum: params.current,
|
||||
pageSize: params.pageSize,
|
||||
title: params.title,
|
||||
module: params.module,
|
||||
status: params.status,
|
||||
});
|
||||
return {
|
||||
data: result.rows || [],
|
||||
total: result.total || 0,
|
||||
success: result.code === 200,
|
||||
};
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="add"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
hidden={!access.hasPerms('cms:newsInfo:add')}
|
||||
onClick={() => {
|
||||
setCurrentRow(undefined);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>,
|
||||
selectedRowKeys.length > 0 && (
|
||||
<Button
|
||||
key="batch-delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
hidden={!access.hasPerms('cms:newsInfo:remove')}
|
||||
onClick={() => handleDelete(selectedRowKeys.join(','))}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
<EditModal
|
||||
open={editOpen}
|
||||
values={currentRow}
|
||||
moduleEnum={moduleEnum}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setCurrentRow(undefined);
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
const result = values.id ? await updateNewsInfo(values) : await addNewsInfo(values);
|
||||
if (result.code !== 200) {
|
||||
message.error(result.msg || '保存失败');
|
||||
return;
|
||||
}
|
||||
message.success(values.id ? '修改成功' : '新增成功');
|
||||
setEditOpen(false);
|
||||
setCurrentRow(undefined);
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
<DetailModal
|
||||
open={detailOpen}
|
||||
data={currentRow}
|
||||
moduleEnum={moduleEnum}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
setCurrentRow(undefined);
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsInfoPage;
|
||||
68
src/services/cms/newsInfo.ts
Normal file
68
src/services/cms/newsInfo.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { request } from '@umijs/max';
|
||||
import { normalizeNewsContent } from '@/utils/newsImageUrl';
|
||||
|
||||
const BASE_URL = '/api/cms/newsInfo';
|
||||
|
||||
export async function getNewsInfoList(params?: API.NewsInfo.NewsInfoListParams) {
|
||||
return request<API.NewsInfo.NewsInfoPageResult>(`${BASE_URL}/list`, {
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getNewsInfoDetail(id: number) {
|
||||
return request<API.NewsInfo.NewsInfoDetailResult>(`${BASE_URL}/${id}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export async function addNewsInfo(data: API.NewsInfo.NewsInfoPayload) {
|
||||
return request<API.Result>(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
data: { ...data, content: normalizeNewsContent(data.content) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateNewsInfo(data: API.NewsInfo.NewsInfoPayload) {
|
||||
return request<API.Result>(BASE_URL, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
data: { ...data, content: normalizeNewsContent(data.content) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteNewsInfo(ids: string) {
|
||||
return request<API.Result>(`${BASE_URL}/${ids}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function changeNewsInfoStatus(id: number, status: '0' | '1') {
|
||||
return request<API.Result>(`${BASE_URL}/changeStatus`, {
|
||||
method: 'PUT',
|
||||
params: { id, status },
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadNewsImage(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request<API.NewsInfo.NewsInfoUploadResult>('/api/common/upload', {
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublishedNewsInfoList(params?: API.NewsInfo.NewsInfoListParams) {
|
||||
return request<API.NewsInfo.NewsInfoPageResult>('/api/app/newsInfo/list', {
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublishedNewsInfoDetail(id: number) {
|
||||
return request<API.NewsInfo.NewsInfoDetailResult>(`/api/app/newsInfo/${id}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import React, { lazy } from 'react';
|
||||
|
||||
let remoteMenu: any = null;
|
||||
|
||||
// Remote CMS menus are returned by the backend at runtime. Keep newly added
|
||||
// pages with an explicit import so production webpack builds cannot tree-shake
|
||||
// them out of the dynamic import context before the remote menu is available.
|
||||
const newsInfoRemoteComponent = lazy(() => import('@/pages/NewsInfo/index'));
|
||||
|
||||
export function getRemoteMenu() {
|
||||
return remoteMenu;
|
||||
}
|
||||
@@ -60,7 +65,11 @@ function patchRouteItems(route: any, menu: any, parentPath: string) {
|
||||
route.children = [];
|
||||
}
|
||||
const newRoute = {
|
||||
element: React.createElement(lazy(() => import('@/pages/' + path))),
|
||||
element: React.createElement(
|
||||
path === 'NewsInfo/index.tsx'
|
||||
? newsInfoRemoteComponent
|
||||
: lazy(() => import('@/pages/' + path)),
|
||||
),
|
||||
path: parentPath + menuItem.path,
|
||||
}
|
||||
route.children.push(newRoute);
|
||||
|
||||
54
src/types/news-info.d.ts
vendored
Normal file
54
src/types/news-info.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
declare namespace API.NewsInfo {
|
||||
export interface NewsInfoItem {
|
||||
id: number;
|
||||
module: string;
|
||||
moduleName?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
status: '0' | '1';
|
||||
createBy?: string;
|
||||
createTime?: string;
|
||||
updateBy?: string;
|
||||
updateTime?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface NewsInfoPayload {
|
||||
id?: number;
|
||||
module: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status?: '0' | '1';
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface NewsInfoListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
title?: string;
|
||||
keyword?: string;
|
||||
module?: string;
|
||||
status?: '0' | '1';
|
||||
}
|
||||
|
||||
export interface NewsInfoPageResult {
|
||||
code: number;
|
||||
msg?: string;
|
||||
total: number;
|
||||
rows: NewsInfoItem[];
|
||||
}
|
||||
|
||||
export interface NewsInfoDetailResult {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: NewsInfoItem;
|
||||
}
|
||||
|
||||
export interface NewsInfoUploadResult {
|
||||
code: number;
|
||||
msg?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
data?: { url?: string; fileName?: string };
|
||||
}
|
||||
}
|
||||
65
src/utils/newsImageUrl.ts
Normal file
65
src/utils/newsImageUrl.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
function getPublicApiPrefix(): string {
|
||||
// 开发服务器的 /api 代理会自动补上 /api/shihezi;生产 Nginx 则需要完整前缀。
|
||||
return process.env.NODE_ENV === 'development' ? '/api' : '/api/shihezi';
|
||||
}
|
||||
|
||||
function getBrowserOrigin(): string {
|
||||
return typeof window === 'undefined' ? '' : window.location.origin;
|
||||
}
|
||||
|
||||
function isProfileResource(pathname: string): boolean {
|
||||
return /^\/(?:api\/(?:shihezi\/)?)?profile(?:\/|$)/i.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将通用上传接口返回的本地资源地址转换为浏览器可访问的公开地址。
|
||||
*
|
||||
* 生产反向代理把后端暴露在 /api/shihezi/ 下,而后端默认返回的地址可能
|
||||
* 带有 127.0.0.1:9091 或直接使用 /profile 前缀。图片必须走公开代理地址,
|
||||
* 否则 CMS 浏览器会把服务器内网地址当成图片地址并显示破图。
|
||||
*/
|
||||
export function normalizeNewsImageUrl(
|
||||
fileUrl?: string,
|
||||
fileName?: string,
|
||||
currentOrigin = getBrowserOrigin(),
|
||||
): string {
|
||||
const value = (fileUrl || fileName || '').trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value, currentOrigin || 'http://localhost');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!isProfileResource(parsed.pathname) || !currentOrigin) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const profileIndex = parsed.pathname.toLowerCase().indexOf('/profile');
|
||||
const resourcePath = parsed.pathname.slice(profileIndex);
|
||||
return `${currentOrigin.replace(/\/+$/, '')}${getPublicApiPrefix()}${resourcePath}${
|
||||
parsed.search
|
||||
}${parsed.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复历史正文中由后端返回的 127.0.0.1/profile 图片地址。
|
||||
* 只改写 img[src],不改变正文的其他 HTML 结构。
|
||||
*/
|
||||
export function normalizeNewsContent(content?: string, currentOrigin = getBrowserOrigin()): string {
|
||||
if (!content) {
|
||||
return content || '';
|
||||
}
|
||||
|
||||
return content.replace(
|
||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(.*?)\2/gi,
|
||||
(match, prefix: string, quote: string, source: string) => {
|
||||
const normalized = normalizeNewsImageUrl(source, undefined, currentOrigin);
|
||||
return `${prefix}${quote}${normalized || source}${quote}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
50
tests/utils/newsImageUrl.test.ts
Normal file
50
tests/utils/newsImageUrl.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { normalizeNewsContent, normalizeNewsImageUrl } from '@/utils/newsImageUrl';
|
||||
|
||||
describe('news image URL normalization', () => {
|
||||
const origin = 'http://47.111.103.66';
|
||||
|
||||
it('maps the backend internal host to the public API resource path', () => {
|
||||
expect(
|
||||
normalizeNewsImageUrl(
|
||||
'http://127.0.0.1:9091/profile/upload/2026/07/20/news.png',
|
||||
undefined,
|
||||
origin,
|
||||
),
|
||||
).toBe('http://47.111.103.66/api/shihezi/profile/upload/2026/07/20/news.png');
|
||||
});
|
||||
|
||||
it('supports the fileName returned by the upload endpoint', () => {
|
||||
expect(normalizeNewsImageUrl(undefined, 'profile/upload/2026/07/20/news.png', origin)).toBe(
|
||||
'http://47.111.103.66/api/shihezi/profile/upload/2026/07/20/news.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes existing API-prefixed URLs to an absolute browser URL', () => {
|
||||
expect(normalizeNewsImageUrl('/api/shihezi/profile/upload/news.png', undefined, origin)).toBe(
|
||||
'http://47.111.103.66/api/shihezi/profile/upload/news.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the development proxy prefix in development builds', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
try {
|
||||
expect(
|
||||
normalizeNewsImageUrl('/profile/upload/news.png', undefined, 'http://localhost:8000'),
|
||||
).toBe('http://localhost:8000/api/profile/upload/news.png');
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('rewrites only image sources in existing rich text', () => {
|
||||
expect(
|
||||
normalizeNewsContent(
|
||||
'<p>正文</p><img src="http://127.0.0.1:9091/profile/upload/news.png"><img src="https://cdn.example.com/news.png">',
|
||||
origin,
|
||||
),
|
||||
).toBe(
|
||||
'<p>正文</p><img src="http://47.111.103.66/api/shihezi/profile/upload/news.png"><img src="https://cdn.example.com/news.png">',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user