前端开发规范

57 阅读10分钟

1、 标准目录结构

PC端项目架构

src/
├── api/                    # API接口层
│   ├── index.js           # API统一导出
│   ├── user.js            # 用户相关接口
│   ├── payment.js         # 支付相关接口
│   └── modules/           # 业务模块接口
├── assets/                # 静态资源
│   ├── css/              # 样式文件
│   ├── images/           # 图片资源
│   └── fonts/            # 字体文件
├── components/            # 公共组件
│   ├── common/           # 通用组件
│   ├── tables/           # 表格组件
│   └── forms/            # 表单组件
├── config/               # 配置文件
│   ├── index.js          # 基础配置
│   └── url.js            # URL配置
├── directive/            # 自定义指令
├── filters/              # 过滤器
├── libs/                 # 工具库
│   ├── axios.js          # HTTP封装
│   ├── util.js           # 工具函数
│   └── httpRequest.js    # 请求封装
├── mock/                 # Mock数据
├── plugins/              # 插件配置
├── router/               # 路由配置
│   ├── index.js          # 路由主文件
│   ├── hooks.js          # 路由钩子
│   └── modules/          # 路由模块
├── store/                # 状态管理
│   ├── index.js          # Store主文件
│   └── modules/          # Store模块
├── utils/                # 工具函数
├── views/                # 页面组件
│   ├── Layout.vue        # 布局组件
│   ├── Home.vue          # 首页
│   └── modules/          # 业务模块页面
├── App.vue               # 根组件
└── main.js               # 入口文件

移动端项目架构

src/
├── api/                  # API接口层
├── assets/               # 静态资源
├── components/           # 公共组件
├── config/               # 配置文件
├── router/               # 路由配置
│   ├── index.js          # 路由主文件
│   ├── AccessPage.js     # 访问权限配置
│   └── modules/          # 路由模块
├── store/                # 状态管理
├── utils/                # 工具函数
│   ├── util.js           # 通用工具
│   ├── storage.js        # 存储工具
│   └── miniProgramJs.js  # 小程序工具
├── views/                # 页面组件
├── App.vue               # 根组件
└── main.js               # 入口文件

2. 状态管理架构

Vuex模式 (Vue 2)
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import app from './modules/app'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {
    user,
    app
  }
})
Pinia模式 (Vue 3)
// store/index.ts
import type { App } from 'vue'
import { createPinia } from 'pinia'

const store = createPinia()

export const setupStore = (app: App<Element>) => {
  app.use(store)
}

export { store }

3. API架构模式

统一API管理
// api/index.js
import { httpRequest } from '@/libs/httpRequest'
import { API_URL } from '@/config/url'

// 用户相关接口
export const getUserInfo = (params) => {
  return httpRequest({
    url: API_URL.USER_INFO,
    method: 'get',
    params
  })
}

// 列表查询接口
export const getOrderList = (data) => {
  return httpRequest({
    url: API_URL.ORDER_LIST,
    method: 'post',
    data
  })
}

2、项目技术栈

  • 前端框架: Vue 2.5.10 + Vue Router 3.0.1 + Vuex 3.0.1
  • UI组件库:
    • Element UI 2.15.14 (辅助组件)
    • @longfor/maia-ui 1.3.15 (龙湖内部UI库)
    • @longfor/cm-components 0.2.15 (龙湖通用组件)
  • 构建工具: Vue CLI 3.x + Webpack 4.x
  • 样式预处理: Less 2.7.3
  • 微前端: 乾坤(qiankun)架构
  • 其他核心依赖: Axios、ECharts、Moment.js等

移动端项目特征

  • postcss-pxtorem适配
  • 支持触摸事件
  • 移动端调试工具
  • 性能优化配置

PC端项目特征

  • Element UI组件库
  • 复杂表格和表单处理
  • 权限管理
  • 数据可视化需求

AI对话基本原则

1. 技术栈一致性

  • 所有代码建议必须基于Vue 2.x语法
  • 优先使用项目已有的UI组件库(iView > Element UI > Maia UI)
  • 遵循项目现有的目录结构和命名规范
  • 使用Less作为样式预处理器

2. 代码风格规范

  • 遵循ESLint Standard配置
  • 使用2空格缩进
  • 组件名采用PascalCase命名
  • 文件名采用kebab-case命名
  • 方法名采用camelCase命名

3. 组件开发规范

// 标准Vue组件模板
<template>
  <div class="component-name">
    <!-- 模板内容 -->
  </div>
</template>

<script>
export default {
  name: 'ComponentName',
  props: {
    // props定义
  },
  data () {
    return {
      // 数据定义
    }
  },
  computed: {
    // 计算属性
  },
  methods: {
    // 方法定义
  },
  mounted () {
    // 生命周期钩子
  }
}
</script>

<style lang="less" scoped>
.component-name {
  // 样式定义
}
</style>

特殊场景处理

1. 微前端相关

  • 涉及微前端的问题,需要考虑qiankun生命周期
  • 注意基座应用的通信机制
  • 考虑路由base配置的影响

2. 权限管理

  • 基于菜单权限的页面访问控制
  • 使用store中的authorization模块
  • 考虑按钮级别的权限控制

3. 第三方组件集成

  • 优先使用项目已集成的组件库
  • 新增第三方依赖需要考虑项目兼容性
  • 注意龙湖内部组件的使用规范

代码质量要求

1. 性能优化

  • 合理使用Vue的响应式特性
  • 避免不必要的重渲染
  • 大列表使用虚拟滚动

2. 错误处理

  • 统一的错误处理机制
  • 用户友好的错误提示
  • 完善的日志记录

3. 可维护性

  • 清晰的代码注释
  • 合理的组件拆分
  • 统一的编码规范

禁止事项

  1. 不要建议使用Vue 3语法 - 项目基于Vue 2.x
  2. 不要推荐未集成的UI库 - 优先使用已有组件库
  3. 不要忽略微前端架构 - 考虑qiankun环境的特殊性
  4. 不要违反ESLint规范 - 遵循项目代码规范
  5. 不要建议大幅重构 - 保持项目架构稳定性

2、AI对话接口实现规范

1. 接口URL定义规范

常量定义方式
// src/api/API.js
/**
 * 业务模块名称
 */
// 功能描述
export const GET_USER_LIST = '/api/user/list'
export const CREATE_USER = '/api/user/create'
export const UPDATE_USER = '/api/user/update'
export const DELETE_USER = '/api/user/delete'

/**
 * 另一个业务模块
 */
export const GET_ORDER_LIST = '/api/order/list'
命名规范
  • 使用大写字母和下划线
  • 动词在前:GET_, CREATE_, UPDATE_, DELETE_
  • 业务对象在后:USER_LIST, ORDER_DETAIL
  • 按业务模块分组,添加注释说明

2. 接口封装规范

基础接口封装模式
// src/api/user.js
import axios from '@/libs/axios'

// 获取用户列表
export const getUserList = (data) => {
  return axios.request({
    url: '/api/user/list',
    data,
    method: 'post'
  })
}

// 获取用户详情
export const getUserDetail = (params) => {
  return axios.request({
    url: '/api/user/detail',
    params,
    method: 'get'
  })
}

// 创建用户
export const createUser = (data) => {
  return axios.request({
    url: '/api/user/create',
    data,
    method: 'post',
    loading: true,
    loadingText: '创建中...'
  })
}
多服务类型支持
// 不同服务类型的接口调用
export const getVasOrderList = (data) => {
  return axios.request({
    url: '/web/serviceOrder/getCouponOrderList',
    data,
    method: 'post',
    type: 'vas'  // 指定服务类型
  })
}

export const getPaymentOrderList = (data) => {
  return axios.request({
    url: '/web/manage/order/searchReconciliationOrder',
    data,
    method: 'post',
    type: 'payment'  // 支付服务
  })
}

3. HTTP请求配置规范

服务类型配置
// src/libs/httpRequest.js 中的服务类型处理
const serviceTypeConfig = {
  'todo': {
    baseURL: todoBaseURL,
    apiKey: process.env.VUE_APP_GAIA_KEY_TODO
  },
  'vas': {
    baseURL: vasBaseURL,
    apiKey: process.env.VUE_APP_GAIA_KEY_VAS
  },
  'payment': {
    baseURL: payBaseURL,
    apiKey: process.env.VUE_APP_GAIA_KEY_PAY,
    headers: { 'menuPath': location.pathname }
  },
  'opo': {
    baseURL: opoBaseURL,
    apiKey: process.env.VUE_APP_GAIA_KEY_OPO
  }
}
请求拦截器配置
// 请求拦截器标准配置
instance.interceptors.request.use(config => {
  // 1. 设置动态应用标识
  config.headers['dynamicAppCode'] = 'YYZXPC'
  
  // 2. 根据type设置不同的baseURL和API Key
  if (config.type && serviceTypeConfig[config.type]) {
    const serviceConfig = serviceTypeConfig[config.type]
    config.baseURL = serviceConfig.baseURL
    config.headers['X-Gaia-Api-Key'] = serviceConfig.apiKey
    
    // 特殊headers处理
    if (serviceConfig.headers) {
      Object.assign(config.headers, serviceConfig.headers)
    }
  }
  
  // 3. 认证信息
  config.headers['CASTGC'] = Cookies.get('account') || Cookies.get('CASTGC')
  config.withCredentials = true
  
  // 4. Loading处理
  if (config.loading) {
    loadingInstance = Loading.service({
      background: 'rgba(0, 0, 0, .5)',
      text: config.loadingText || '加载中...'
    })
  }
  
  return config
})
响应拦截器配置
// 响应拦截器标准配置
instance.interceptors.response.use(
  res => {
    // 关闭loading
    loadingInstance && loadingInstance.close()
    
    let { code, resultCode } = res.data
    
    // 登录状态检查
    if ((code && +code === 10002) || (resultCode && +resultCode === 10002)) {
      localStorage.clear()
      goToLoginPage()
      return Promise.reject(new Error('登录过期'))
    }
    
    return res.data
  },
  error => {
    loadingInstance && loadingInstance.close()
    Message.error('服务内部错误')
    return Promise.reject(error)
  }
)
统一错误处理
// 在组件中使用接口
async handleGetOrderList() {
  try {
    const res = await getOrderList(this.searchParams)
    if (res.code === 200) {
      this.orderList = res.data.list
      this.total = res.data.total
    } else {
      this.$Message.error(res.message || '获取数据失败')
    }
  } catch (error) {
    console.error('获取订单列表失败:', error)
    this.$Message.error('网络错误,请稍后重试')
  }
}
业务状态码处理
// 标准业务状态码处理
const handleApiResponse = (res, successCallback, errorCallback) => {
  if (res.code === 200 || res.resultCode === 200) {
    successCallback && successCallback(res.data)
  } else if (res.code === 10002 || res.resultCode === 10002) {
    // 登录过期处理
    goToLoginPage()
  } else {
    const errorMsg = res.message || res.resultMessage || '操作失败'
    errorCallback ? errorCallback(errorMsg) : Message.error(errorMsg)
  }
}

4、AI页面样式规范

1. 基础样式规范

1.1 CSS Reset 和 Normalize
/* 统一的CSS Reset */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: Arial, "Microsoft Yahei", "Helvetica Neue", Helvetica, sans-serif;
  color: #333;
  font-size: 12px;
  line-height: 1.5;
  -webkit-text-size-adjust: none;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

/* 清除浮动 */
.clearfix::after {
  content: "";
  height: 0;
  line-height: 0;
  display: block;
  clear: both;
  visibility: hidden;
}
1.2 滚动条样式统一
/* 自定义滚动条 */
::-webkit-scrollbar {
  width: 5px;
  height: 10px;
}

::-webkit-scrollbar-track-piece {
  background-color: rgba(0, 0, 0, 0.2);
  border-radius: 6px;
}

::-webkit-scrollbar-thumb:vertical {
  height: 5px;
  background-color: rgba(125, 125, 125, 0.7);
  border-radius: 6px;
}

2. 颜色规范

2.1 主色调定义
// 龙湖品牌色
$longfor-blue: #003894;
$color-primary: #007AF5;

// 功能色彩
$color-success: #67C23A;
$color-warning: #E6A23C;
$color-danger: #F56C6C;
$color-info: #909399;

// 中性色
$color-text-primary: #303133;
$color-text-regular: #606266;
$color-text-secondary: #909399;
$color-text-placeholder: #C0C4CC;

// 边框色
$border-color-base: #DCDFE6;
$border-color-light: #E4E7ED;
$border-color-lighter: #EBEEF5;
$border-color-extra-light: #F2F6FC;

// 背景色
$background-color-base: #F5F7FA;
$background-color-light: #FAFAFA;
$background-color-white: #FFFFFF;
2.2 项目特定颜色
// web项目 (iView)
$menu-dark-title: #001529;
$menu-dark-active-bg: #000c17;
$layout-sider-background: #001529;

// operate-management-web项目
$namespace: 'optm';
$optm-main-bg: #f4f7fc;
$optm-card-bg: #ffffff;

// 移动端项目
$mobile-primary: #007AF5;
$mobile-background: #F5F5F5;

3. 布局规范

3.1 PC端布局模式
标准后台布局
<template>
  <div class="admin-layout">
    <!-- 侧边栏 -->
    <aside class="sidebar" :class="{ collapsed: isCollapsed }">
      <div class="logo-container">
        <img src="@/assets/images/logo.png" alt="Logo">
      </div>
      <nav class="nav-menu">
        <!-- 菜单内容 -->
      </nav>
    </aside>
    
    <!-- 主内容区 -->
    <main class="main-container">
      <!-- 顶部导航 -->
      <header class="header-bar">
        <div class="header-left">
          <button class="collapse-btn" @click="toggleSidebar">
            <i class="icon-menu"></i>
          </button>
        </div>
        <div class="header-right">
          <!-- 用户信息、通知等 -->
        </div>
      </header>
      
      <!-- 内容区域 -->
      <section class="content-wrapper">
        <router-view />
      </section>
    </main>
  </div>
</template>

<style lang="less" scoped>
.admin-layout {
  display: flex;
  height: 100vh;
  
  .sidebar {
    width: 256px;
    background: #001529;
    transition: width 0.3s;
    
    &.collapsed {
      width: 64px;
    }
    
    .logo-container {
      height: 64px;
      padding: 10px;
      text-align: center;
      
      img {
        height: 44px;
        width: auto;
      }
    }
  }
  
  .main-container {
    flex: 1;
    display: flex;
    flex-direction: column;
    
    .header-bar {
      height: 64px;
      background: #fff;
      border-bottom: 1px solid #f0f0f0;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px;
    }
    
    .content-wrapper {
      flex: 1;
      padding: 18px;
      background: #f5f7fa;
      overflow: auto;
    }
  }
}
</style>
页面容器规范
<template>
  <div class="page-container">
    <!-- 页面标题区 -->
    <div class="page-header" v-if="showHeader">
      <h1 class="page-title">{{ pageTitle }}</h1>
      <div class="page-actions">
        <slot name="actions"></slot>
      </div>
    </div>
    
    <!-- 搜索区域 -->
    <div class="search-container" v-if="showSearch">
      <el-form :model="searchForm" inline class="search-form">
        <slot name="search"></slot>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">
            <i class="el-icon-search"></i>
            搜索
          </el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    
    <!-- 主要内容区 -->
    <div class="content-container">
      <slot></slot>
    </div>
  </div>
</template>

<style lang="less" scoped>
.page-container {
  background: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    padding-bottom: 16px;
    border-bottom: 1px solid #f0f0f0;
    
    .page-title {
      font-size: 18px;
      font-weight: 500;
      color: #303133;
      margin: 0;
    }
  }
  
  .search-container {
    background: #fafafa;
    padding: 16px;
    border-radius: 4px;
    margin-bottom: 16px;
    
    .search-form {
      .el-form-item {
        margin-bottom: 0;
        margin-right: 16px;
      }
    }
  }
  
  .content-container {
    min-height: 400px;
  }
}
</style>
3.2 移动端布局模式
H5页面布局
<template>
  <div class="mobile-layout">
    <!-- 顶部导航栏 -->
    <header class="mobile-header" v-if="showHeader">
      <div class="header-left">
        <i class="icon-back" @click="goBack" v-if="showBack"></i>
      </div>
      <div class="header-center">
        <h1 class="header-title">{{ title }}</h1>
      </div>
      <div class="header-right">
        <slot name="header-right"></slot>
      </div>
    </header>
    
    <!-- 主要内容 -->
    <main class="mobile-content" :class="{ 'has-header': showHeader, 'has-footer': showFooter }">
      <slot></slot>
    </main>
    
    <!-- 底部导航 -->
    <footer class="mobile-footer" v-if="showFooter">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<style lang="less" scoped>
.mobile-layout {
  height: 100vh;
  display: flex;
  flex-direction: column;
  
  .mobile-header {
    height: 44px;
    background: #fff;
    border-bottom: 1px solid #f0f0f0;
    display: flex;
    align-items: center;
    padding: 0 16px;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    
    .header-left,
    .header-right {
      width: 60px;
    }
    
    .header-center {
      flex: 1;
      text-align: center;
      
      .header-title {
        font-size: 18px;
        font-weight: 500;
        color: #333;
        margin: 0;
      }
    }
  }
  
  .mobile-content {
    flex: 1;
    overflow: auto;
    -webkit-overflow-scrolling: touch;
    
    &.has-header {
      padding-top: 44px;
    }
    
    &.has-footer {
      padding-bottom: 50px;
    }
  }
  
  .mobile-footer {
    height: 50px;
    background: #fff;
    border-top: 1px solid #f0f0f0;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 1000;
  }
}
</style>

4. 组件样式规范

4.1 表格组件样式
Element UI 表格规范
<template>
  <div class="table-container">
    <!-- 表格工具栏 -->
    <div class="table-toolbar" v-if="showToolbar">
      <div class="toolbar-left">
        <el-button type="primary" @click="handleAdd" v-if="showAdd">
          <i class="el-icon-plus"></i>
          新增
        </el-button>
        <el-button type="danger" @click="handleBatchDelete" v-if="showBatchDelete">
          <i class="el-icon-delete"></i>
          批量删除
        </el-button>
      </div>
      <div class="toolbar-right">
        <el-button circle @click="handleRefresh">
          <i class="el-icon-refresh"></i>
        </el-button>
      </div>
    </div>
    
    <!-- 数据表格 -->
    <el-table
      :data="tableData"
      :loading="loading"
      stripe
      border
      class="data-table"
      @selection-change="handleSelectionChange"
    >
      <el-table-column type="selection" width="55" v-if="showSelection"></el-table-column>
      <el-table-column type="index" label="序号" width="60" v-if="showIndex"></el-table-column>
      <slot></slot>
      <el-table-column label="操作" width="200" fixed="right" v-if="showActions">
        <template #default="{ row }">
          <el-button type="text" @click="handleEdit(row)">编辑</el-button>
          <el-button type="text" @click="handleView(row)">查看</el-button>
          <el-button type="text" class="danger-text" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页组件 -->
    <div class="pagination-container" v-if="showPagination">
      <el-pagination
        :current-page="pagination.current"
        :page-size="pagination.size"
        :total="pagination.total"
        :page-sizes="[10, 20, 50, 100]"
        layout="total, sizes, prev, pager, next, jumper"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<style lang="less" scoped>
.table-container {
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .el-button + .el-button {
        margin-left: 8px;
      }
    }
  }
  
  .data-table {
    .danger-text {
      color: #f56c6c;
      
      &:hover {
        color: #f78989;
      }
    }
  }
  
  .pagination-container {
    margin-top: 16px;
    text-align: right;
  }
}
</style>
4.2 表单组件样式
标准表单布局
<template>
  <div class="form-container">
    <el-form
      :model="formData"
      :rules="formRules"
      ref="formRef"
      label-width="120px"
      class="standard-form"
    >
      <el-row :gutter="24">
        <el-col :span="12">
          <el-form-item label="用户名" prop="username">
            <el-input v-model="formData.username" placeholder="请输入用户名" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="邮箱" prop="email">
            <el-input v-model="formData.email" placeholder="请输入邮箱" />
          </el-form-item>
        </el-col>
      </el-row>
      
      <el-row :gutter="24">
        <el-col :span="24">
          <el-form-item label="描述" prop="description">
            <el-input
              type="textarea"
              v-model="formData.description"
              :rows="4"
              placeholder="请输入描述"
            />
          </el-form-item>
        </el-col>
      </el-row>
      
      <el-form-item class="form-actions">
        <el-button type="primary" @click="handleSubmit">提交</el-button>
        <el-button @click="handleReset">重置</el-button>
        <el-button @click="handleCancel">取消</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<style lang="less" scoped>
.form-container {
  background: #fff;
  padding: 24px;
  border-radius: 4px;
  
  .standard-form {
    .form-actions {
      margin-top: 32px;
      text-align: center;
      
      .el-button + .el-button {
        margin-left: 16px;
      }
    }
  }
}
</style>
4.3 移动端组件样式
Vant 组件规范
<template>
  <div class="mobile-form">
    <van-cell-group>
      <van-field
        v-model="formData.name"
        label="姓名"
        placeholder="请输入姓名"
        required
      />
      <van-field
        v-model="formData.phone"
        label="手机号"
        placeholder="请输入手机号"
        type="tel"
        required
      />
      <van-field
        v-model="formData.remark"
        label="备注"
        placeholder="请输入备注"
        type="textarea"
        rows="3"
      />
    </van-cell-group>
    
    <div class="form-actions">
      <van-button type="primary" block @click="handleSubmit">
        提交
      </van-button>
    </div>
  </div>
</template>

<style lang="less" scoped>
.mobile-form {
  padding: 16px;
  
  .form-actions {
    margin-top: 24px;
  }
}
</style>
Cube UI 组件规范
<template>
  <div class="cube-form">
    <cube-form :model="formData" :schema="schema" ref="form"></cube-form>
    
    <div class="form-actions">
      <cube-button type="primary" @click="handleSubmit">提交</cube-button>
    </div>
  </div>
</template>

<style lang="stylus" scoped>
.cube-form
  padding 16px
  
  .form-actions
    margin-top 24px
    padding 0 16px
</style>

5. 响应式设计规范

5.1 响应式布局示例
<style lang="less" scoped>
.responsive-container {
  padding: 16px;
  
  // 移动端
  @media (max-width: 767px) {
    padding: 8px;
    
    .el-col {
      margin-bottom: 16px;
    }
  }
  
  // 平板端
  @media (min-width: 768px) and (max-width: 991px) {
    padding: 12px;
  }
  
  // 桌面端
  @media (min-width: 992px) {
    padding: 24px;
  }
}
</style>

6. 动画和过渡效果

6.1 标准过渡动画
/* 淡入淡出 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
}

/* 滑动效果 */
.slide-enter-active, .slide-leave-active {
  transition: transform 0.3s ease;
}
.slide-enter, .slide-leave-to {
  transform: translateX(100%);
}

/* 缩放效果 */
.scale-enter-active, .scale-leave-active {
  transition: transform 0.3s ease;
}
.scale-enter, .scale-leave-to {
  transform: scale(0);
}
6.2 加载动画
/* 旋转加载 */
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-spinner {
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 2px solid #f3f3f3;
  border-top: 2px solid #007AF5;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

/* 脉冲效果 */
@keyframes pulse {
  0% { opacity: 1; }
  50% { opacity: 0.5; }
  100% { opacity: 1; }
}

.loading-pulse {
  animation: pulse 1.5s ease-in-out infinite;
}