670 lines
20 KiB
Markdown
670 lines
20 KiB
Markdown
# 前端组件设计文档
|
||
|
||
## 1. 技术栈
|
||
|
||
| 技术 | 版本 | 用途 |
|
||
|------|------|------|
|
||
| React | ^19.2.4 | UI 框架 |
|
||
| Vite | ^8.0.4 | 构建工具 |
|
||
| TailwindCSS | ^4.2.2 | 原子化样式 |
|
||
| React Router | ^7.14.1 | 路由管理 |
|
||
| @uiw/react-md-editor | ^4.1.0 | Markdown 编辑器 |
|
||
| Lucide React | ^1.8.0 | 图标库 |
|
||
|
||
## 2. 目录结构
|
||
|
||
```
|
||
frontend/src/
|
||
├── main.jsx # 应用入口
|
||
├── App.jsx # 根组件(含路由配置)
|
||
├── App.css # 根样式
|
||
├── index.css # 全局样式
|
||
│
|
||
├── api/ # API 请求封装
|
||
│ └── index.js # 自定义 ApiClient(基于 fetch)
|
||
│
|
||
├── components/ # 公共组件
|
||
│ ├── admin/ # 后台通用组件
|
||
│ │ ├── Badge.jsx
|
||
│ │ ├── Button.jsx
|
||
│ │ ├── Card.jsx
|
||
│ │ ├── Input.jsx
|
||
│ │ ├── Modal.jsx
|
||
│ │ ├── Stats.jsx
|
||
│ │ ├── Table.jsx
|
||
│ │ └── index.js
|
||
│ ├── AdminLayout.jsx # 后台布局
|
||
│ ├── HelpModal.jsx # 帮助弹窗
|
||
│ └── Layout.jsx # 前台布局
|
||
│
|
||
├── contexts/ # React Context
|
||
│ └── AuthContext.jsx # 认证上下文
|
||
│
|
||
├── pages/ # 页面组件
|
||
│ ├── admin/ # 后台管理页
|
||
│ │ ├── images/
|
||
│ │ │ └── ImageManager.jsx # 图片管理
|
||
│ │ ├── news/
|
||
│ │ │ ├── CategoryList.jsx # 栏目管理
|
||
│ │ │ ├── CrawlNews.jsx # 采集新闻
|
||
│ │ │ ├── PostEdit.jsx # 添加/编辑资讯
|
||
│ │ │ └── PostList.jsx # 资讯列表
|
||
│ │ ├── BannerManage.jsx
|
||
│ │ ├── FeaturesManage.jsx
|
||
│ │ └── HomeManage.jsx
|
||
│ ├── AdminHome.jsx # 后台首页
|
||
│ ├── Login.jsx # 登录页
|
||
│ ├── Modules.jsx # 前台首页模块展示
|
||
│ ├── NavMenus.jsx # 导航菜单管理
|
||
│ ├── NewsDetail.jsx # 新闻详情页
|
||
│ ├── NewsList.jsx # 新闻列表页
|
||
│ ├── PublicHome.jsx # 前台首页
|
||
│ └── Settings.jsx # 系统设置
|
||
│
|
||
└── assets/ # 静态资源
|
||
├── hero.png
|
||
├── react.svg
|
||
└── vite.svg
|
||
```
|
||
|
||
## 3. 新粗野主义设计系统
|
||
|
||
### 3.1 CSS 变量
|
||
|
||
```css
|
||
:root {
|
||
/* 颜色 */
|
||
--nb-bg-primary: #FEF9E7;
|
||
--nb-bg-white: #FFFFFF;
|
||
--nb-text-primary: #000000;
|
||
--nb-text-secondary: #333333;
|
||
--nb-accent-pink: #FF6B9D;
|
||
--nb-accent-cyan: #00D9FF;
|
||
--nb-accent-yellow: #FFDE59;
|
||
--nb-accent-green: #4ADE80;
|
||
|
||
/* 边框和阴影 */
|
||
--nb-border: 2px solid #000000;
|
||
--nb-shadow: 4px 4px 0px 0px #000000;
|
||
--nb-shadow-hover: 6px 6px 0px 0px #000000;
|
||
--nb-shadow-active: 2px 2px 0px 0px #000000;
|
||
}
|
||
```
|
||
|
||
### 3.2 组件样式规范
|
||
|
||
**Button:**
|
||
- 直角边框: `rounded-none`
|
||
- 2px黑色边框: `border-2 border-black`
|
||
- 硬阴影: `shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]`
|
||
- 悬停位移: `hover:translate-x-[2px] hover:translate-y-[2px]`
|
||
- 激活阴影变小: `active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]`
|
||
|
||
**Card:**
|
||
- 直角边框
|
||
- 2px黑色边框
|
||
- 4px阴影
|
||
- 奶油色或白色背景
|
||
|
||
**Section:**
|
||
- 全宽布局
|
||
- 内部容器最大宽度 1200px
|
||
- 上下内边距 60-80px
|
||
|
||
## 4. 组件详细设计
|
||
|
||
### 4.1 通用组件
|
||
|
||
#### Button 按钮
|
||
|
||
```jsx
|
||
// components/common/Button.jsx
|
||
function Button({
|
||
children,
|
||
variant = 'primary', // primary, secondary, outline
|
||
size = 'md', // sm, md, lg
|
||
className = '',
|
||
...props
|
||
}) {
|
||
const baseStyle = 'inline-flex items-center justify-center font-bold ' +
|
||
'border-2 border-black rounded-none transition-all duration-150 ' +
|
||
'shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] ' +
|
||
'hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] ' +
|
||
'active:translate-x-[4px] active:translate-y-[4px] active:shadow-none';
|
||
|
||
const variants = {
|
||
primary: 'bg-[#FF6B9D] text-white',
|
||
secondary: 'bg-[#00D9FF] text-black',
|
||
outline: 'bg-white text-black'
|
||
};
|
||
|
||
const sizes = {
|
||
sm: 'px-4 py-2 text-sm',
|
||
md: 'px-6 py-3 text-base',
|
||
lg: 'px-8 py-4 text-lg'
|
||
};
|
||
|
||
return (
|
||
<button
|
||
className={`${baseStyle} ${variants[variant]} ${sizes[size]} ${className}`}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.2 布局组件
|
||
|
||
#### Navbar 导航栏
|
||
|
||
```jsx
|
||
// components/layout/Navbar.jsx
|
||
function Navbar() {
|
||
const [menus, setMenus] = useState([]);
|
||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetchNavMenus().then(setMenus);
|
||
}, []);
|
||
|
||
return (
|
||
<nav className="sticky top-0 z-50 bg-[#FEF9E7] border-b-2 border-black">
|
||
<div className="max-w-[1200px] mx-auto px-4 h-16 flex items-center justify-between">
|
||
{/* Logo */}
|
||
<Link to="/" className="flex items-center gap-2">
|
||
<img src="/logo.png" alt="飞点健康" className="h-10" />
|
||
</Link>
|
||
|
||
{/* Desktop Menu */}
|
||
<div className="hidden md:flex items-center gap-1">
|
||
{menus.filter(m => m.is_enabled).map(menu => (
|
||
<NavLink
|
||
key={menu.id}
|
||
to={menu.link}
|
||
className={({ isActive }) => `
|
||
px-4 py-2 font-bold transition-all
|
||
${isActive ? 'bg-black text-white' : 'hover:bg-black hover:text-white'}
|
||
`}
|
||
>
|
||
{menu.name}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
|
||
{/* Mobile Menu Button */}
|
||
<button
|
||
className="md:hidden p-2 border-2 border-black"
|
||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||
>
|
||
{mobileMenuOpen ? <X /> : <Menu />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Mobile Menu Dropdown */}
|
||
{mobileMenuOpen && (
|
||
<div className="md:hidden border-t-2 border-black bg-white">
|
||
{menus.filter(m => m.is_enabled).map(menu => (
|
||
<Link
|
||
key={menu.id}
|
||
to={menu.link}
|
||
className="block px-4 py-3 border-b border-gray-200 font-bold"
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
>
|
||
{menu.name}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</nav>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.3 首页模块组件
|
||
|
||
每个模块组件接收 `module` 对象作为 props:
|
||
|
||
```typescript
|
||
interface HomeModule {
|
||
id: number;
|
||
code: string;
|
||
type: string;
|
||
title: string;
|
||
subtitle: string;
|
||
content: object; // 模块内容(根据类型不同)
|
||
style_config: object; // 样式配置
|
||
}
|
||
```
|
||
|
||
#### BannerModule 轮播图模块
|
||
|
||
```jsx
|
||
// components/modules/BannerModule.jsx
|
||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||
import { Pagination, Navigation, Autoplay } from 'swiper/modules';
|
||
|
||
function BannerModule({ module }) {
|
||
const { content, style_config } = module;
|
||
const slides = content?.slides || [];
|
||
|
||
return (
|
||
<section
|
||
className="w-full"
|
||
style={{
|
||
backgroundColor: style_config?.backgroundColor || '#ffffff',
|
||
paddingTop: style_config?.paddingTop || '0',
|
||
paddingBottom: style_config?.paddingBottom || '0'
|
||
}}
|
||
>
|
||
<Swiper
|
||
modules={[Pagination, Navigation, Autoplay]}
|
||
spaceBetween={0}
|
||
slidesPerView={1}
|
||
pagination={{ clickable: true }}
|
||
navigation
|
||
autoplay={{ delay: 5000 }}
|
||
loop={slides.length > 1}
|
||
>
|
||
{slides.map((slide, index) => (
|
||
<SwiperSlide key={index}>
|
||
<a href={slide.link || '#'} className="block relative">
|
||
<img
|
||
src={slide.image}
|
||
alt={slide.title}
|
||
className="w-full h-[400px] md:h-[500px] object-cover"
|
||
/>
|
||
{slide.title && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<h2 className="text-4xl md:text-6xl font-bold text-white drop-shadow-lg">
|
||
{slide.title}
|
||
</h2>
|
||
</div>
|
||
)}
|
||
</a>
|
||
</SwiperSlide>
|
||
))}
|
||
</Swiper>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### FeaturesModule 产品优势模块
|
||
|
||
```jsx
|
||
// components/modules/FeaturesModule.jsx
|
||
function FeaturesModule({ module }) {
|
||
const { title, subtitle, content, style_config } = module;
|
||
const items = content?.items || [];
|
||
|
||
return (
|
||
<section
|
||
className="w-full py-16 md:py-20"
|
||
style={{
|
||
backgroundColor: style_config?.backgroundColor || '#FEF9E7'
|
||
}}
|
||
>
|
||
<div className="max-w-[1200px] mx-auto px-4">
|
||
{/* Section Header */}
|
||
{(title || subtitle) && (
|
||
<div className="text-center mb-12">
|
||
{title && (
|
||
<h2 className="text-3xl md:text-4xl font-bold mb-4 inline-block
|
||
border-b-4 border-[#FF6B9D] pb-2">
|
||
{title}
|
||
</h2>
|
||
)}
|
||
{subtitle && (
|
||
<p className="text-gray-600 text-lg">{subtitle}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Features Grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
{items.map((item, index) => (
|
||
<div
|
||
key={index}
|
||
className="bg-white border-2 border-black p-6
|
||
shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]
|
||
hover:translate-x-[2px] hover:translate-y-[2px]
|
||
hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]
|
||
transition-all duration-200"
|
||
>
|
||
<div className="w-16 h-16 bg-[#00D9FF] border-2 border-black
|
||
flex items-center justify-center mb-4">
|
||
{item.icon ? (
|
||
<img src={item.icon} alt="" className="w-8 h-8" />
|
||
) : (
|
||
<span className="text-2xl">✦</span>
|
||
)}
|
||
</div>
|
||
<h3 className="text-xl font-bold mb-2">{item.title}</h3>
|
||
<p className="text-gray-600">{item.desc}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
## 5. 页面设计
|
||
|
||
### 5.1 首页 (Home/Index)
|
||
|
||
```jsx
|
||
// views/home/Index.jsx
|
||
function HomePage() {
|
||
const [modules, setModules] = useState([]);
|
||
|
||
useEffect(() => {
|
||
fetchHomeModules().then(setModules);
|
||
}, []);
|
||
|
||
// 根据模块类型渲染对应组件
|
||
const renderModule = (module) => {
|
||
const components = {
|
||
banner: BannerModule,
|
||
features: FeaturesModule,
|
||
news: NewsModule,
|
||
partners: PartnersModule,
|
||
downloads: DownloadsModule,
|
||
contact: ContactModule,
|
||
html: HtmlModule
|
||
};
|
||
|
||
const Component = components[module.type];
|
||
if (!Component || !module.is_enabled) return null;
|
||
|
||
return <Component key={module.id} module={module} />;
|
||
};
|
||
|
||
return (
|
||
<PageLayout>
|
||
<div className="min-h-screen">
|
||
{modules.sort((a, b) => a.sort_order - b.sort_order)
|
||
.map(renderModule)}
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5.2 后台登录页
|
||
|
||
```jsx
|
||
// views/admin/Login.jsx
|
||
function AdminLogin() {
|
||
const [credentials, setCredentials] = useState({ username: '', password: '' });
|
||
const [error, setError] = useState('');
|
||
const navigate = useNavigate();
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const { token } = await login(credentials);
|
||
localStorage.setItem('admin_token', token);
|
||
navigate('/admin');
|
||
} catch (err) {
|
||
setError('用户名或密码错误');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#FEF9E7] flex items-center justify-center p-4">
|
||
<div className="w-full max-w-md bg-white border-2 border-black
|
||
shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
|
||
<h1 className="text-3xl font-bold text-center mb-8">后台管理</h1>
|
||
|
||
{error && (
|
||
<div className="bg-[#FF6B9D] text-white p-3 mb-4 border-2 border-black">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="block font-bold mb-2">用户名</label>
|
||
<input
|
||
type="text"
|
||
value={credentials.username}
|
||
onChange={e => setCredentials({...credentials, username: e.target.value})}
|
||
className="w-full px-4 py-3 border-2 border-black rounded-none
|
||
focus:outline-none focus:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]
|
||
transition-shadow"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block font-bold mb-2">密码</label>
|
||
<input
|
||
type="password"
|
||
value={credentials.password}
|
||
onChange={e => setCredentials({...credentials, password: e.target.value})}
|
||
className="w-full px-4 py-3 border-2 border-black rounded-none
|
||
focus:outline-none focus:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]
|
||
transition-shadow"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" variant="primary" size="lg" className="w-full">
|
||
登录
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## 6. API 请求封装
|
||
|
||
项目使用自定义 `ApiClient` 类封装 `fetch`,替代 Axios。
|
||
|
||
```javascript
|
||
// api/index.js
|
||
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
||
|
||
class ApiClient {
|
||
constructor(baseURL) {
|
||
this.baseURL = baseURL;
|
||
}
|
||
|
||
async request(url, options = {}, raw = false) {
|
||
const token = localStorage.getItem('token');
|
||
const isFormData = options.body instanceof FormData;
|
||
const headers = {
|
||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||
...options.headers,
|
||
};
|
||
|
||
const response = await fetch(`${this.baseURL}${url}`, {
|
||
...options,
|
||
headers,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
localStorage.removeItem('token');
|
||
window.location.href = '/admin/login';
|
||
}
|
||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||
throw new Error(error.message || `HTTP ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
// API returns {code, data, message} format for list operations
|
||
// But auth returns {code, token, admin} without data field
|
||
if (raw) return result;
|
||
return result.data !== undefined ? result.data : result;
|
||
}
|
||
|
||
get(url) { return this.request(url); }
|
||
post(url, data) { /* FormData 自动处理 */ }
|
||
put(url, data) { return this.request(url, { method: 'PUT', body: JSON.stringify(data) }); }
|
||
delete(url) { return this.request(url, { method: 'DELETE' }); }
|
||
}
|
||
|
||
const api = new ApiClient(API_BASE_URL);
|
||
export default api;
|
||
```
|
||
|
||
**API 模块导出:**
|
||
|
||
```javascript
|
||
// Auth API - use raw=true to get full response including token
|
||
export const authApi = {
|
||
login: (username, password) => api.post('/api/auth/login', { username, password }, true),
|
||
me: () => api.get('/api/auth/me'),
|
||
changePassword: (old, neu) => api.post('/api/auth/change-password', { old_password: old, new_password: neu }),
|
||
};
|
||
|
||
// Home Modules API
|
||
export const modulesApi = {
|
||
getPublic: () => api.get('/api/home/modules'),
|
||
getAll: () => api.get('/api/admin/home/modules'),
|
||
getById: (id) => api.get(`/api/admin/home/modules/${id}`),
|
||
update: (id, data) => api.put(`/api/admin/home/modules/${id}`, data),
|
||
sort: (orders) => api.put('/api/admin/home/modules/sort', { orders }),
|
||
toggle: (id) => api.put(`/api/admin/home/modules/${id}/toggle`),
|
||
};
|
||
|
||
// Nav Menu API
|
||
export const navApi = {
|
||
getPublic: () => api.get('/api/nav'),
|
||
getAll: () => api.get('/api/admin/nav'),
|
||
create: (data) => api.post('/api/admin/nav', data),
|
||
update: (id, data) => api.put(`/api/admin/nav/${id}`, data),
|
||
delete: (id) => api.delete(`/api/admin/nav/${id}`),
|
||
};
|
||
|
||
// Settings API
|
||
export const settingsApi = {
|
||
getPublic: () => api.get('/api/settings'),
|
||
getAll: () => api.get('/api/admin/settings'),
|
||
update: (settings) => api.put('/api/admin/settings', { settings }),
|
||
};
|
||
|
||
// Upload API
|
||
export const uploadApi = {
|
||
uploadImage: (file) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
return fetch(`${API_BASE_URL}/api/upload/image`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||
body: formData,
|
||
}).then(r => r.json());
|
||
},
|
||
};
|
||
```
|
||
|
||
## 7. Markdown 编辑器
|
||
|
||
使用 `@uiw/react-md-editor` 作为 Markdown 编辑器,替代原来自制的 textarea 组件。
|
||
|
||
### 7.1 编辑器组件封装
|
||
|
||
```jsx
|
||
// PostEdit.jsx 中的 MarkdownEditor 组件
|
||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||
import '@uiw/react-md-editor/markdown-editor.css';
|
||
|
||
const MarkdownEditor = ({ value, onChange, onImageInsert }) => {
|
||
const customImageCommand = {
|
||
...commands.image,
|
||
execute: (state, textApi) => {
|
||
onImageInsert?.((img) => {
|
||
const url = typeof img === 'string' ? img : img.file_path;
|
||
const imageMarkdown = ``;
|
||
textApi.replaceSelection(imageMarkdown);
|
||
});
|
||
},
|
||
};
|
||
|
||
const customCommands = commands.getCommands().map((cmd) => {
|
||
if (cmd.name === 'image' || cmd.keyCommand === 'image') {
|
||
return customImageCommand;
|
||
}
|
||
if (cmd.groupName === 'title' && Array.isArray(cmd.children)) {
|
||
return {
|
||
...cmd,
|
||
children: cmd.children.map((child) =>
|
||
child.keyCommand === 'image' ? customImageCommand : child
|
||
),
|
||
};
|
||
}
|
||
return cmd;
|
||
});
|
||
|
||
return (
|
||
<div data-color-mode="light">
|
||
<MDEditor
|
||
value={value || ''}
|
||
onChange={onChange}
|
||
height={384}
|
||
commands={customCommands}
|
||
extraCommands={commands.getExtraCommands()}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 7.2 图片插入集成
|
||
|
||
编辑器工具栏的图片按钮会触发 `onImageInsert` 回调,打开图片选择弹窗:
|
||
|
||
```jsx
|
||
<MarkdownEditor
|
||
value={formData.content}
|
||
onChange={(v) => setFormData(prev => ({ ...prev, content: v }))}
|
||
onImageInsert={(callback) => {
|
||
setImagePickerCallback(() => callback);
|
||
setShowImagePicker(true);
|
||
}}
|
||
/>
|
||
```
|
||
|
||
用户选择图片后,回调函数将图片 URL 插入到 Markdown 内容中。
|
||
|
||
## 8. 图片 URL 处理规则
|
||
|
||
前端使用统一的 `getImageUrl` 函数处理图片路径:
|
||
|
||
```javascript
|
||
const getImageUrl = (path) => {
|
||
if (!path) return '/placeholder-image.png';
|
||
if (path.startsWith('http')) return path;
|
||
if (path.startsWith('/storage/')) return path;
|
||
if (path.startsWith('/uploads/')) return path;
|
||
return `/uploads${path}`;
|
||
};
|
||
```
|
||
|
||
**路径优先级:**
|
||
1. 外部 URL(`http://` / `https://`)→ 原样返回
|
||
2. `/storage/` 路径 → 原样返回(由 Vite 代理到后端)
|
||
3. `/uploads/` 路径 → 原样返回(兼容旧路径)
|
||
4. 其他路径 → 前面拼接 `/uploads`
|
||
|
||
**Vite 代理配置:**
|
||
|
||
```js
|
||
// vite.config.js
|
||
proxy: {
|
||
'/api': { target: 'http://localhost:10003', changeOrigin: true },
|
||
'/uploads': { target: 'http://localhost:10003', changeOrigin: true },
|
||
'/storage': { target: 'http://localhost:10003', changeOrigin: true },
|
||
}
|
||
```
|
||
|
||
所有图片请求通过 Vite 代理转发到后端静态文件服务。
|