diff --git a/scripts/deploy-production-frontend.sh b/scripts/deploy-production-frontend.sh new file mode 100755 index 0000000..8160f7b --- /dev/null +++ b/scripts/deploy-production-frontend.sh @@ -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 "$@" diff --git a/src/pages/NewsInfo/components/DetailModal.tsx b/src/pages/NewsInfo/components/DetailModal.tsx new file mode 100644 index 0000000..1d2f196 --- /dev/null +++ b/src/pages/NewsInfo/components/DetailModal.tsx @@ -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 = ({ open, data, moduleEnum, onCancel }) => ( + + + + {data?.module ? : '-'} + + + + {data?.status === '0' ? '上架' : '下架'} + + + + {data?.title || '-'} + + {data?.createTime || '-'} + {data?.updateTime || '-'} + + {data?.content ? ( + + ) : ( + '-' + )} + + + {data?.remark || '-'} + + + +); + +export default DetailModal; diff --git a/src/pages/NewsInfo/components/EditModal.tsx b/src/pages/NewsInfo/components/EditModal.tsx new file mode 100644 index 0000000..db58c77 --- /dev/null +++ b/src/pages/NewsInfo/components/EditModal.tsx @@ -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; +} + +const EditModal: React.FC = ({ open, values, moduleEnum, onCancel, onSubmit }) => { + const [form] = Form.useForm(); + 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 ( + +
+ + + + + + + + + + + + {canChangeStatus && ( + + + + + + )} + + + + + + +
+
+ ); +}; + +export default EditModal; diff --git a/src/pages/NewsInfo/components/RichTextEditor.less b/src/pages/NewsInfo/components/RichTextEditor.less new file mode 100644 index 0000000..c07ae5b --- /dev/null +++ b/src/pages/NewsInfo/components/RichTextEditor.less @@ -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; + } + } +} diff --git a/src/pages/NewsInfo/components/RichTextEditor.tsx b/src/pages/NewsInfo/components/RichTextEditor.tsx new file mode 100644 index 0000000..e4475f3 --- /dev/null +++ b/src/pages/NewsInfo/components/RichTextEditor.tsx @@ -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 = ({ value = '', onChange, disabled }) => { + const editorRef = useRef(null); + const fileInputRef = useRef(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) => { + 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 ( +
+
+ + +
+
+ setLinkModalOpen(false)} + onOk={insertLink} + destroyOnClose + > + setLinkUrl(event.target.value)} + onPressEnter={insertLink} + /> + +
+ ); +}; + +export default RichTextEditor; diff --git a/src/pages/NewsInfo/index.tsx b/src/pages/NewsInfo/index.tsx new file mode 100644 index 0000000..8555e22 --- /dev/null +++ b/src/pages/NewsInfo/index.tsx @@ -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(); + const [moduleEnum, setModuleEnum] = useState({}); + const [editOpen, setEditOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [currentRow, setCurrentRow] = useState(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + 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[] = [ + { + title: '标题', + dataIndex: 'title', + ellipsis: true, + formItemProps: { rules: [{ max: 200 }] }, + }, + { + title: '资讯模块', + dataIndex: 'module', + valueType: 'select', + valueEnum: moduleEnum, + render: (_, record) => , + }, + { + title: '上下架', + dataIndex: 'status', + valueType: 'select', + width: 110, + valueEnum: { + '0': { text: '上架', status: 'Success' }, + '1': { text: '下架', status: 'Default' }, + }, + render: (_, record) => + access.hasPerms('cms:newsInfo:status') ? ( + handleStatus(record, checked)} + /> + ) : ( + + {record.status === '0' ? '上架' : '下架'} + + ), + }, + { title: '创建时间', dataIndex: 'createTime', hideInSearch: true, width: 170 }, + { + title: '操作', + valueType: 'option', + fixed: 'right', + width: 220, + render: (_, record) => [ + , + , + , + ], + }, + ]; + + return ( + + + 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={() => [ + , + selectedRowKeys.length > 0 && ( + + ), + ]} + /> + { + 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(); + }} + /> + { + setDetailOpen(false); + setCurrentRow(undefined); + }} + /> + + ); +}; + +export default NewsInfoPage; diff --git a/src/services/cms/newsInfo.ts b/src/services/cms/newsInfo.ts new file mode 100644 index 0000000..ac6f99f --- /dev/null +++ b/src/services/cms/newsInfo.ts @@ -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(`${BASE_URL}/list`, { + method: 'GET', + params, + }); +} + +export async function getNewsInfoDetail(id: number) { + return request(`${BASE_URL}/${id}`, { + method: 'GET', + }); +} + +export async function addNewsInfo(data: API.NewsInfo.NewsInfoPayload) { + return request(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(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(`${BASE_URL}/${ids}`, { + method: 'DELETE', + }); +} + +export async function changeNewsInfoStatus(id: number, status: '0' | '1') { + return request(`${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/common/upload', { + method: 'POST', + data: formData, + }); +} + +export async function getPublishedNewsInfoList(params?: API.NewsInfo.NewsInfoListParams) { + return request('/api/app/newsInfo/list', { + method: 'GET', + params, + }); +} + +export async function getPublishedNewsInfoDetail(id: number) { + return request(`/api/app/newsInfo/${id}`, { + method: 'GET', + }); +} diff --git a/src/services/session.ts b/src/services/session.ts index 8b4e0d3..256d15d 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -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); diff --git a/src/types/news-info.d.ts b/src/types/news-info.d.ts new file mode 100644 index 0000000..06f7313 --- /dev/null +++ b/src/types/news-info.d.ts @@ -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 }; + } +} diff --git a/src/utils/newsImageUrl.ts b/src/utils/newsImageUrl.ts new file mode 100644 index 0000000..96274c0 --- /dev/null +++ b/src/utils/newsImageUrl.ts @@ -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( + /(]*\bsrc\s*=\s*)(["'])(.*?)\2/gi, + (match, prefix: string, quote: string, source: string) => { + const normalized = normalizeNewsImageUrl(source, undefined, currentOrigin); + return `${prefix}${quote}${normalized || source}${quote}`; + }, + ); +} diff --git a/tests/utils/newsImageUrl.test.ts b/tests/utils/newsImageUrl.test.ts new file mode 100644 index 0000000..801c4f8 --- /dev/null +++ b/tests/utils/newsImageUrl.test.ts @@ -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( + '

正文

', + origin, + ), + ).toBe( + '

正文

', + ); + }); +});