# 前端组件设计文档
## 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 (
);
}
```
### 4.2 布局组件
#### Navbar 导航栏
```jsx
// components/layout/Navbar.jsx
function Navbar() {
const [menus, setMenus] = useState([]);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
fetchNavMenus().then(setMenus);
}, []);
return (
);
}
```
### 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 (
);
}
```
#### FeaturesModule 产品优势模块
```jsx
// components/modules/FeaturesModule.jsx
function FeaturesModule({ module }) {
const { title, subtitle, content, style_config } = module;
const items = content?.items || [];
return (
{/* Section Header */}
{(title || subtitle) && (
{title && (
{title}
)}
{subtitle && (
{subtitle}
)}
)}
{/* Features Grid */}
{items.map((item, index) => (
{item.icon ? (

) : (
✦
)}
{item.title}
{item.desc}
))}
);
}
```
## 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 ;
};
return (
{modules.sort((a, b) => a.sort_order - b.sort_order)
.map(renderModule)}
);
}
```
### 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 (
后台管理
{error && (
{error}
)}
);
}
```
## 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 (
);
};
```
### 7.2 图片插入集成
编辑器工具栏的图片按钮会触发 `onImageInsert` 回调,打开图片选择弹窗:
```jsx
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 代理转发到后端静态文件服务。