20 KiB
20 KiB
前端组件设计文档
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 变量
: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 按钮
// 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 导航栏
// 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:
interface HomeModule {
id: number;
code: string;
type: string;
title: string;
subtitle: string;
content: object; // 模块内容(根据类型不同)
style_config: object; // 样式配置
}
BannerModule 轮播图模块
// 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 产品优势模块
// 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)
// 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 后台登录页
// 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。
// 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 模块导出:
// 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 编辑器组件封装
// 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 回调,打开图片选择弹窗:
<MarkdownEditor
value={formData.content}
onChange={(v) => setFormData(prev => ({ ...prev, content: v }))}
onImageInsert={(callback) => {
setImagePickerCallback(() => callback);
setShowImagePicker(true);
}}
/>
用户选择图片后,回调函数将图片 URL 插入到 Markdown 内容中。
8. 图片 URL 处理规则
前端使用统一的 getImageUrl 函数处理图片路径:
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}`;
};
路径优先级:
- 外部 URL(
http:///https://)→ 原样返回 /storage/路径 → 原样返回(由 Vite 代理到后端)/uploads/路径 → 原样返回(兼容旧路径)- 其他路径 → 前面拼接
/uploads
Vite 代理配置:
// 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 代理转发到后端静态文件服务。