Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

670 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端组件设计文档
## 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 = `![图片描述](${url})`;
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 代理转发到后端静态文件服务。