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

This commit is contained in:
2026-07-20 15:43:50 +08:00
parent 4aa30fc59d
commit f2a81d8867
11 changed files with 1054 additions and 1 deletions

View 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 "$@"

View 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;

View 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;

View 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;
}
}
}

View 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;

View 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;

View 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',
});
}

View File

@@ -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
View 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
View 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}`;
},
);
}

View 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">',
);
});
});