Files
nanocode/new-media-platform/website-new/docs/FRONTEND.md
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

20 KiB
Raw Blame History

前端组件设计文档

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 = `![图片描述](${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 回调,打开图片选择弹窗:

<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}`;
};

路径优先级:

  1. 外部 URL(http:// / https://)→ 原样返回
  2. /storage/ 路径 → 原样返回(由 Vite 代理到后端)
  3. /uploads/ 路径 → 原样返回(兼容旧路径)
  4. 其他路径 → 前面拼接 /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 代理转发到后端静态文件服务。