# 前端组件设计文档 ## 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 (
1} > {slides.map((slide, index) => ( {slide.title} {slide.title && (

{slide.title}

)}
))}
); } ``` #### 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}
)}
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 />
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 />
); } ``` ## 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 (
); }; ``` ### 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 代理转发到后端静态文件服务。