commit 7cf0a756039935165faf36fe3296b699d0bc0916 Author: zhxiao1124 Date: Mon Apr 13 14:22:31 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b9276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +.eslintcache + +# Cypress +/cypress/videos/ +/cypress/screenshots/ + +# Vitest +__screenshots__/ +/.trae/documents +/.trae/specs \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6bb6a4 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# 销售业绩与收益计算管理系统 + +## 项目结构 + +``` +sales-management-system/ +├── backend/ # FastAPI 后端 +├── frontend/ # Vue3 前端 +└── docs/ # 项目文档 +``` + +## 技术栈 + +- **后端**: FastAPI + SQLite3 +- **前端**: Vue 3 + Element Plus + ECharts +- **认证**: JWT + +## 开发文档 + +- [PRD产品需求文档](docs/01-PRD.md) +- [数据库设计文档](docs/02-Database.md) +- [API接口文档](docs/03-API.md) +- [开发计划文档](docs/04-Development-Plan.md) + +## 快速开始 + +### 后端启动 +```bash +cd backend +pip install -r requirements.txt +python init_db.py +uvicorn app.main:app --reload +``` + +### 前端启动 +```bash +cd frontend +npm install +npm run dev +``` + +## 默认账号 + +- 用户名: admin +- 密码: admin123 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8dc179e --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# 使app目录成为Python包 diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a279581 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/auth.cpython-312.pyc b/backend/app/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..5cea4e3 Binary files /dev/null and b/backend/app/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..96435fa Binary files /dev/null and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..d81f809 Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/__pycache__/init_data.cpython-312.pyc b/backend/app/__pycache__/init_data.cpython-312.pyc new file mode 100644 index 0000000..84a52b0 Binary files /dev/null and b/backend/app/__pycache__/init_data.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..7dedbc0 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..9f581d4 Binary files /dev/null and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..cdababc --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建JWT访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[dict]: + """解码JWT令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e0c8dea --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # 应用配置 + APP_NAME: str = "销售业绩管理系统" + DEBUG: bool = True + + # 数据库配置 + DATABASE_URL: str = "sqlite:///./sales_management.db" + + # JWT配置 + SECRET_KEY: str = "your-secret-key-here-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24小时 + + # 默认配置 + DEFAULT_SALARY_BASE_MIN: float = 4000.0 + DEFAULT_SALARY_BASE_MAX: float = 6000.0 + DEFAULT_SALARY_PERFORMANCE: float = 1000.0 + DEFAULT_SALARY_PERFORMANCE_THRESHOLD: float = 50.0 + + DEFAULT_COMMISSION_AI_MODEL: float = 0.03 + DEFAULT_COMMISSION_CLOUD_BASE: float = 0.10 + DEFAULT_COMMISSION_MAIN_PRODUCT: float = 0.08 + + DEFAULT_AGENT_EMPLOYEE_COMMISSION: float = 0.01 + DEFAULT_AGENT_PROFIT_SHARE_MIN: float = 0.60 + DEFAULT_AGENT_PROFIT_SHARE_MAX: float = 0.65 + + class Config: + env_file = ".env" + + +@lru_cache() +def get_settings(): + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1b1d6e4 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine, event +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from app.config import get_settings + +settings = get_settings() + +# 创建引擎 +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}, + echo=settings.DEBUG +) + +# 会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 模型基类 +Base = declarative_base() + + +def get_db() -> Session: + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """初始化数据库""" + Base.metadata.create_all(bind=engine) diff --git a/backend/app/init_data.py b/backend/app/init_data.py new file mode 100644 index 0000000..594a4d5 --- /dev/null +++ b/backend/app/init_data.py @@ -0,0 +1,80 @@ +from sqlalchemy.orm import Session +from app.models import User, Setting, ProductCategory +from app.config import get_settings +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +settings = get_settings() + + +def init_default_data(db: Session): + """初始化默认数据""" + + # 1. 创建默认管理员账号 + admin = db.query(User).filter(User.username == "admin").first() + if not admin: + admin = User( + username="admin", + password_hash=pwd_context.hash("admin123"), + role="admin", + name="系统管理员", + status=1 + ) + db.add(admin) + db.commit() + print("✅ 默认管理员账号已创建") + print(" 用户名: admin") + print(" 密码: admin123") + + # 2. 初始化默认配置 + default_settings = [ + ("salary_base_min", str(settings.DEFAULT_SALARY_BASE_MIN), "底薪范围最小值(元)", "salary"), + ("salary_base_max", str(settings.DEFAULT_SALARY_BASE_MAX), "底薪范围最大值(元)", "salary"), + ("salary_performance", str(settings.DEFAULT_SALARY_PERFORMANCE), "绩效奖金基数(元)", "salary"), + ("salary_performance_threshold", str(settings.DEFAULT_SALARY_PERFORMANCE_THRESHOLD), "绩效完成率阈值(%)", "salary"), + ("commission_ai_model", str(settings.DEFAULT_COMMISSION_AI_MODEL), "大模型类产品提成比例", "commission"), + ("commission_cloud_base", str(settings.DEFAULT_COMMISSION_CLOUD_BASE), "云基础类产品提成比例", "commission"), + ("commission_main_product", str(settings.DEFAULT_COMMISSION_MAIN_PRODUCT), "主推产品提成比例", "commission"), + ("agent_employee_commission", str(settings.DEFAULT_AGENT_EMPLOYEE_COMMISSION), "员工二级代理提成比例", "agent"), + ("agent_profit_share_min", str(settings.DEFAULT_AGENT_PROFIT_SHARE_MIN), "二级代理分佣比例最小值", "agent"), + ("agent_profit_share_max", str(settings.DEFAULT_AGENT_PROFIT_SHARE_MAX), "二级代理分佣比例最大值", "agent"), + ("company_name", "火山引擎渠道合作伙伴", "公司名称", "company"), + ] + + for key, value, desc, group in default_settings: + existing = db.query(Setting).filter(Setting.setting_key == key).first() + if not existing: + setting = Setting( + setting_key=key, + setting_value=value, + description=desc, + group_name=group + ) + db.add(setting) + + db.commit() + print("✅ 默认系统配置已初始化") + + # 3. 初始化默认产品分类 + default_categories = [ + ("大模型类产品", "ai_model", 0.03, 0.30, 0), + ("云基础类产品", "cloud_base", 0.10, 0.25, 0), + ("主推产品-直播大师", "main_live_master", 0.08, 0.35, 1), + ("主推产品-创作Agent", "main_create_agent", 0.08, 0.35, 1), + ] + + for name, code, commission, rebate, is_main in default_categories: + existing = db.query(ProductCategory).filter(ProductCategory.code == code).first() + if not existing: + category = ProductCategory( + name=name, + code=code, + commission_rate=commission, + monthly_rebate=rebate, + is_main_product=is_main, + status=1 + ) + db.add(category) + + db.commit() + print("✅ 默认产品分类已初始化") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e52bc6b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import get_settings +from app.routers import auth, settings as settings_router, employees, agents, categories, performance, calculate, reports, dashboard + +settings = get_settings() + +app = FastAPI( + title=settings.APP_NAME, + description="销售业绩与收益计算管理系统 API", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境应限制具体域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(auth.router, prefix="/api/v1") +app.include_router(settings_router.router, prefix="/api/v1") +app.include_router(employees.router, prefix="/api/v1") +app.include_router(agents.router, prefix="/api/v1") +app.include_router(categories.router, prefix="/api/v1") +app.include_router(performance.router, prefix="/api/v1") +app.include_router(calculate.router, prefix="/api/v1") +app.include_router(reports.router, prefix="/api/v1") +app.include_router(dashboard.router, prefix="/api/v1") + + +@app.get("/") +def root(): + return { + "message": settings.APP_NAME, + "version": "1.0.0", + "docs": "/docs" + } + + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..5524b02 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,198 @@ +from sqlalchemy import Column, Integer, String, DateTime, Date, DECIMAL, Text, ForeignKey, CheckConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + role = Column(String(20), nullable=False, default="employee") # admin/employee/agent + name = Column(String(50), nullable=False) + phone = Column(String(20)) + email = Column(String(100)) + status = Column(Integer, nullable=False, default=1) # 0-禁用,1-启用 + last_login_at = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="user", uselist=False) + agent = relationship("SecondaryAgent", back_populates="user", uselist=False) + + __table_args__ = ( + CheckConstraint(role.in_(['admin', 'employee', 'agent'])), + ) + + +class Employee(Base): + __tablename__ = "employees" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + base_salary = Column(DECIMAL(10, 2), nullable=False, default=4000.00) + monthly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + quarterly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + half_year_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + yearly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + hire_date = Column(Date) + department = Column(String(50)) + position = Column(String(50)) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + user = relationship("User", back_populates="employee") + agents = relationship("SecondaryAgent", back_populates="employee") + performance_records = relationship("PerformanceRecord", back_populates="employee") + calculation_results = relationship("CalculationResult", back_populates="employee") + + +class SecondaryAgent(Base): + __tablename__ = "secondary_agents" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False) + company_name = Column(String(100), nullable=False) + contact_name = Column(String(50)) + contact_phone = Column(String(20)) + profit_share_rate = Column(DECIMAL(5, 2), nullable=False, default=0.60) + address = Column(String(255)) + remark = Column(Text) + status = Column(Integer, nullable=False, default=1) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + user = relationship("User", back_populates="agent") + employee = relationship("Employee", back_populates="agents") + performance_records = relationship("PerformanceRecord", back_populates="agent") + + +class Setting(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + setting_key = Column(String(50), unique=True, nullable=False) + setting_value = Column(Text, nullable=False) + description = Column(String(255)) + group_name = Column(String(50), default="general") + sort_order = Column(Integer, default=0) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class ProductCategory(Base): + __tablename__ = "product_categories" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(50), nullable=False) + code = Column(String(30), unique=True) + parent_id = Column(Integer, ForeignKey("product_categories.id", ondelete="SET NULL")) + commission_rate = Column(DECIMAL(5, 2), nullable=False, default=0.03) + monthly_rebate = Column(DECIMAL(5, 2), nullable=False, default=0.10) + quarterly_rebate = Column(DECIMAL(5, 2)) + is_main_product = Column(Integer, nullable=False, default=0) + sort_order = Column(Integer, default=0) + status = Column(Integer, nullable=False, default=1) # 0-下线/禁用,1-启用 + # 火山引擎导入相关 + source_file = Column(String(100)) + import_batch = Column(String(50)) + last_import_at = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + parent = relationship("ProductCategory", remote_side=[id], backref="children") + performance_records = relationship("PerformanceRecord", back_populates="category") + + +class PerformanceRecord(Base): + __tablename__ = "performance_records" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + record_type = Column(String(20), nullable=False) # employee/agent + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="SET NULL")) + agent_id = Column(Integer, ForeignKey("secondary_agents.id", ondelete="SET NULL")) + category_id = Column(Integer, ForeignKey("product_categories.id", ondelete="RESTRICT"), nullable=False) + amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + record_date = Column(Date, nullable=False) + customer_name = Column(String(100)) + order_no = Column(String(50)) + remark = Column(Text) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="performance_records") + agent = relationship("SecondaryAgent", back_populates="performance_records") + category = relationship("ProductCategory", back_populates="performance_records") + + __table_args__ = ( + CheckConstraint(record_type.in_(['employee', 'agent'])), + ) + + +class CalculationResult(Base): + __tablename__ = "calculation_results" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False) + calc_period = Column(String(20), nullable=False) # monthly/quarterly/half_yearly/yearly + calc_year = Column(Integer, nullable=False) + calc_month = Column(Integer) + calc_quarter = Column(Integer) + period_start_date = Column(Date, nullable=False) + period_end_date = Column(Date, nullable=False) + + # 业绩数据 + total_performance = Column(DECIMAL(15, 2), nullable=False, default=0.00) + target_amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + completion_rate = Column(DECIMAL(5, 2), nullable=False, default=0.00) + + # 员工收益 + base_salary = Column(DECIMAL(10, 2), nullable=False, default=0.00) + performance_bonus = Column(DECIMAL(10, 2), nullable=False, default=0.00) + personal_commission = Column(DECIMAL(15, 2), nullable=False, default=0.00) + agent_commission = Column(DECIMAL(15, 2), nullable=False, default=0.00) + total_income = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 公司收益 + company_rebate = Column(DECIMAL(15, 2), nullable=False, default=0.00) + company_cost = Column(DECIMAL(15, 2), nullable=False, default=0.00) + company_profit = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 二级代理收益 + agent_performance = Column(DECIMAL(15, 2), nullable=False, default=0.00) + agent_share_amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 计算详情(JSON格式) + detail_json = Column(Text) + + created_at = Column(DateTime, server_default=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="calculation_results") + + __table_args__ = ( + CheckConstraint(calc_period.in_(['monthly', 'quarterly', 'half_yearly', 'yearly'])), + ) + + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + action = Column(String(50), nullable=False) + target_type = Column(String(50)) + target_id = Column(Integer) + old_value = Column(Text) + new_value = Column(Text) + ip_address = Column(String(50)) + user_agent = Column(String(255)) + created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/app/routers/__pycache__/agents.cpython-312.pyc b/backend/app/routers/__pycache__/agents.cpython-312.pyc new file mode 100644 index 0000000..ed909a2 Binary files /dev/null and b/backend/app/routers/__pycache__/agents.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..eaea478 Binary files /dev/null and b/backend/app/routers/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/calculate.cpython-312.pyc b/backend/app/routers/__pycache__/calculate.cpython-312.pyc new file mode 100644 index 0000000..a1054eb Binary files /dev/null and b/backend/app/routers/__pycache__/calculate.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/categories.cpython-312.pyc b/backend/app/routers/__pycache__/categories.cpython-312.pyc new file mode 100644 index 0000000..4b6a05f Binary files /dev/null and b/backend/app/routers/__pycache__/categories.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/dashboard.cpython-312.pyc b/backend/app/routers/__pycache__/dashboard.cpython-312.pyc new file mode 100644 index 0000000..48feab0 Binary files /dev/null and b/backend/app/routers/__pycache__/dashboard.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/employees.cpython-312.pyc b/backend/app/routers/__pycache__/employees.cpython-312.pyc new file mode 100644 index 0000000..c470d83 Binary files /dev/null and b/backend/app/routers/__pycache__/employees.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/performance.cpython-312.pyc b/backend/app/routers/__pycache__/performance.cpython-312.pyc new file mode 100644 index 0000000..e2657f2 Binary files /dev/null and b/backend/app/routers/__pycache__/performance.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/reports.cpython-312.pyc b/backend/app/routers/__pycache__/reports.cpython-312.pyc new file mode 100644 index 0000000..0b0618a Binary files /dev/null and b/backend/app/routers/__pycache__/reports.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/settings.cpython-312.pyc b/backend/app/routers/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..e6901d8 Binary files /dev/null and b/backend/app/routers/__pycache__/settings.cpython-312.pyc differ diff --git a/backend/app/routers/agents.py b/backend/app/routers/agents.py new file mode 100644 index 0000000..efcd1e7 --- /dev/null +++ b/backend/app/routers/agents.py @@ -0,0 +1,367 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, SecondaryAgent, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/agents", tags=["二级代理管理"]) + + +# Pydantic模型 +class AgentBase(BaseModel): + company_name: str + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + profit_share_rate: Decimal = Field(default=0.60) + address: Optional[str] = None + remark: Optional[str] = None + employee_id: int + + +class AgentCreate(AgentBase): + username: Optional[str] = None + password: Optional[str] = None + + +class AgentUpdate(BaseModel): + company_name: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + profit_share_rate: Optional[Decimal] = None + address: Optional[str] = None + remark: Optional[str] = None + employee_id: Optional[int] = None + status: Optional[int] = None + + +class AgentResponse(BaseModel): + id: int + user_id: Optional[int] + employee_id: int + employee_name: str + company_name: str + contact_name: Optional[str] + contact_phone: Optional[str] + profit_share_rate: Decimal + address: Optional[str] + remark: Optional[str] + status: int + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_agents( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + search: Optional[str] = None, + employee_id: Optional[int] = None, + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代理列表""" + query = db.query(SecondaryAgent).join(Employee).join(User, Employee.user_id == User.id) + + # 搜索条件 + if search: + query = query.filter( + (SecondaryAgent.company_name.contains(search)) | + (SecondaryAgent.contact_name.contains(search)) | + (SecondaryAgent.contact_phone.contains(search)) + ) + + # 员工筛选 + if employee_id: + query = query.filter(SecondaryAgent.employee_id == employee_id) + + # 状态筛选 + if status is not None: + query = query.filter(SecondaryAgent.status == status) + + # 非管理员只能查看自己关联的代理 + if current_user.role != "admin": + # 检查当前用户是否是员工 + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + query = query.filter(SecondaryAgent.employee_id == employee.id) + + # 计算总数 + total = query.count() + + # 分页 + agents = query.offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + items = [] + for agent in agents: + items.append({ + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{agent_id}", response_model=dict) +def get_agent( + agent_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代理详情""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + # 权限检查:非管理员只能查看自己的代理 + if current_user.role != "admin": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or agent.employee_id != employee.id: + raise HTTPException(status_code=403, detail="权限不足") + + return { + "code": 200, + "message": "success", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_agent( + data: AgentCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建代理""" + # 检查员工是否存在 + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="所属员工不存在") + + user_id = None + + # 如果提供了用户名和密码,创建用户账号 + if data.username and data.password: + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.username == data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="用户名已存在") + + # 创建用户 + from app.auth import get_password_hash + user = User( + username=data.username, + password_hash=get_password_hash(data.password), + role="agent", + name=data.contact_name or data.company_name, + phone=data.contact_phone, + status=1 + ) + db.add(user) + db.flush() # 获取user.id + user_id = user.id + + # 创建代理 + agent = SecondaryAgent( + user_id=user_id, + employee_id=data.employee_id, + company_name=data.company_name, + contact_name=data.contact_name, + contact_phone=data.contact_phone, + profit_share_rate=data.profit_share_rate, + address=data.address, + remark=data.remark, + status=1 + ) + db.add(agent) + db.commit() + db.refresh(agent) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_AGENT", "agent", agent.id, + new_value=f"创建代理: {data.company_name}, 所属员工: {employee.user.name}") + + return { + "code": 200, + "message": "代理创建成功", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": employee.user.name, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None + } + } + + +@router.put("/{agent_id}", response_model=dict) +def update_agent( + agent_id: int, + data: AgentUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新代理""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + # 如果更换所属员工,检查新员工是否存在 + if data.employee_id is not None and data.employee_id != agent.employee_id: + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="所属员工不存在") + + # 记录旧值 + old_value = f"company={agent.company_name}, contact={agent.contact_name}, profit_rate={agent.profit_share_rate}, employee_id={agent.employee_id}" + + # 更新代理信息 + if data.company_name is not None: + agent.company_name = data.company_name + if data.contact_name is not None: + agent.contact_name = data.contact_name + # 同时更新用户名称 + if agent.user: + agent.user.name = data.contact_name + if data.contact_phone is not None: + agent.contact_phone = data.contact_phone + # 同时更新用户电话 + if agent.user: + agent.user.phone = data.contact_phone + if data.profit_share_rate is not None: + agent.profit_share_rate = data.profit_share_rate + if data.address is not None: + agent.address = data.address + if data.remark is not None: + agent.remark = data.remark + if data.employee_id is not None: + agent.employee_id = data.employee_id + if data.status is not None: + agent.status = data.status + # 同时更新用户状态 + if agent.user: + agent.user.status = data.status + + db.commit() + db.refresh(agent) + + # 记录操作日志 + new_value = f"company={agent.company_name}, contact={agent.contact_name}, profit_rate={agent.profit_share_rate}, employee_id={agent.employee_id}" + log_operation(db, current_admin.id, "UPDATE_AGENT", "agent", agent_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "代理信息更新成功", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + } + } + + +@router.delete("/{agent_id}", response_model=dict) +def delete_agent( + agent_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除代理""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + company_name = agent.company_name + user_id = agent.user_id + + # 删除代理 + db.delete(agent) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_AGENT", "agent", agent_id, + old_value=f"删除代理: {company_name}, user_id={user_id}") + + return { + "code": 200, + "message": "代理删除成功", + "data": None + } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..03b1341 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,152 @@ +from typing import Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from pydantic import BaseModel +from datetime import timedelta + +from app.database import get_db +from app.auth import verify_password, create_access_token, get_password_hash +from app.models import User +from app.config import get_settings + +router = APIRouter(prefix="/auth", tags=["认证"]) +settings = get_settings() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +# Pydantic模型 +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + user: dict + + +class UserInfo(BaseModel): + id: int + username: str + name: str + role: str + phone: str = None + email: str = None + last_login_at: str = None + + +# 依赖函数 +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + """获取当前登录用户""" + from app.auth import decode_token + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_token(token) + if payload is None: + raise credentials_exception + + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + + user = db.query(User).filter(User.id == int(user_id), User.status == 1).first() + if user is None: + raise credentials_exception + + return user + + +async def get_current_admin(current_user: User = Depends(get_current_user)) -> User: + """验证当前用户是管理员""" + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,需要管理员权限" + ) + return current_user + + +# 路由 +@router.post("/login", response_model=dict) +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + """用户登录""" + # 查找用户 + user = db.query(User).filter(User.username == form_data.username).first() + + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if user.status != 1: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号已被禁用" + ) + + # 更新最后登录时间 + from sqlalchemy import func + user.last_login_at = func.now() + db.commit() + + # 创建访问令牌 + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(user.id), "role": user.role}, + expires_delta=access_token_expires + ) + + return { + "code": 200, + "message": "登录成功", + "data": { + "access_token": access_token, + "token_type": "bearer", + "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "user": { + "id": user.id, + "username": user.username, + "name": user.name, + "role": user.role + } + } + } + + +@router.post("/logout") +def logout(current_user: User = Depends(get_current_user)): + """用户退出(前端清除token即可)""" + return { + "code": 200, + "message": "退出成功", + "data": None + } + + +@router.get("/me", response_model=dict) +def get_me(current_user: User = Depends(get_current_user)): + """获取当前用户信息""" + return { + "code": 200, + "message": "success", + "data": { + "id": current_user.id, + "username": current_user.username, + "name": current_user.name, + "role": current_user.role, + "phone": current_user.phone, + "email": current_user.email, + "last_login_at": current_user.last_login_at.isoformat() if current_user.last_login_at else None + } + } diff --git a/backend/app/routers/calculate.py b/backend/app/routers/calculate.py new file mode 100644 index 0000000..e20a722 --- /dev/null +++ b/backend/app/routers/calculate.py @@ -0,0 +1,217 @@ +from typing import Optional, Literal +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from app.database import get_db +from app.models import User +from app.routers.auth import get_current_user, get_current_admin +from app.services.calculate_service import ( + calculate_employee_income, + calculate_company_profit, + calculate_agent_profit, + get_calculation_history, + get_calculation_detail +) + +router = APIRouter(prefix="/calculate", tags=["收益计算"]) + + +# Pydantic模型 +class EmployeeCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +class CompanyCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +class AgentCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +@router.post("/employee/{employee_id}", response_model=dict) +def calculate_employee( + employee_id: int, + data: EmployeeCalculateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """计算员工收益""" + # 权限检查:非管理员只能计算自己的收益 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or employee.id != employee_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,只能查看自己的收益" + ) + + try: + result = calculate_employee_income( + db=db, + employee_id=employee_id, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter, + save_result=True + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") + + +@router.get("/history", response_model=dict) +def get_history( + employee_id: Optional[int] = None, + period: Optional[str] = None, + year: Optional[int] = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取计算历史列表""" + # 权限检查:非管理员只能查看自己的历史 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + employee_id = employee.id + else: + return { + "code": 200, + "message": "success", + "data": {"items": [], "total": 0, "page": page, "page_size": page_size} + } + + result = get_calculation_history( + db=db, + employee_id=employee_id, + period=period, + year=year, + page=page, + page_size=page_size + ) + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.get("/history/{calculation_id}", response_model=dict) +def get_history_detail( + calculation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取计算历史详情""" + result = get_calculation_detail(db, calculation_id) + + if not result: + raise HTTPException(status_code=404, detail="计算记录不存在") + + # 权限检查:非管理员只能查看自己的记录 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or result["employee_id"] != employee.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足" + ) + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.post("/company", response_model=dict) +def calculate_company( + data: CompanyCalculateRequest, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """计算公司收益汇总(仅管理员)""" + try: + result = calculate_company_profit( + db=db, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter, + save_result=False + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") + + +@router.post("/agent/{agent_id}", response_model=dict) +def calculate_agent( + agent_id: int, + data: AgentCalculateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """计算代理收益""" + # 权限检查:非管理员只能查看自己关联的代理 + if current_user.role != "admin": + from app.models import Employee, SecondaryAgent + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + agent = db.query(SecondaryAgent).filter( + SecondaryAgent.id == agent_id, + SecondaryAgent.employee_id == employee.id + ).first() + if not agent: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足" + ) + + try: + result = calculate_agent_profit( + db=db, + agent_id=agent_id, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..8e115e0 --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,559 @@ +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal +import json + +from app.database import get_db +from app.models import User, ProductCategory, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/categories", tags=["产品分类管理"]) + + +# Pydantic模型 +class CategoryBase(BaseModel): + name: str + code: Optional[str] = None + parent_id: Optional[int] = None + commission_rate: Decimal = Field(default=0.03) + monthly_rebate: Decimal = Field(default=0.10) + quarterly_rebate: Optional[Decimal] = None + is_main_product: int = Field(default=0) + sort_order: int = Field(default=0) + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + code: Optional[str] = None + parent_id: Optional[int] = None + commission_rate: Optional[Decimal] = None + monthly_rebate: Optional[Decimal] = None + quarterly_rebate: Optional[Decimal] = None + is_main_product: Optional[int] = None + sort_order: Optional[int] = None + status: Optional[int] = None + + +class CategoryImportItem(BaseModel): + name: str + code: str + parent_code: Optional[str] = None + commission_rate: Decimal = Field(default=0.03) + monthly_rebate: Decimal = Field(default=0.10) + quarterly_rebate: Optional[Decimal] = None + is_main_product: int = Field(default=0) + + +class CategoryImportData(BaseModel): + items: List[CategoryImportItem] + source_file: str + + +# 导入预览缓存(实际生产环境应使用Redis等) +_import_preview_cache: Dict[str, Any] = {} + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: Optional[int] = None, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +def build_category_tree(categories: List[ProductCategory], parent_id: Optional[int] = None) -> List[Dict]: + """构建分类树形结构""" + tree = [] + for cat in categories: + if cat.parent_id == parent_id: + children = build_category_tree(categories, cat.id) + node = { + "id": cat.id, + "name": cat.name, + "code": cat.code, + "parent_id": cat.parent_id, + "commission_rate": str(cat.commission_rate), + "monthly_rebate": str(cat.monthly_rebate), + "quarterly_rebate": str(cat.quarterly_rebate) if cat.quarterly_rebate else None, + "is_main_product": cat.is_main_product, + "sort_order": cat.sort_order, + "status": cat.status, + "source_file": cat.source_file, + "import_batch": cat.import_batch, + "last_import_at": cat.last_import_at.isoformat() if cat.last_import_at else None, + "created_at": cat.created_at.isoformat() if cat.created_at else None, + "updated_at": cat.updated_at.isoformat() if cat.updated_at else None, + "children": children if children else [] + } + tree.append(node) + return sorted(tree, key=lambda x: x["sort_order"]) + + +@router.get("", response_model=dict) +def get_categories( + tree: bool = Query(True, description="是否返回树形结构"), + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取分类列表(树形结构)""" + query = db.query(ProductCategory) + + # 状态筛选 + if status is not None: + query = query.filter(ProductCategory.status == status) + + categories = query.order_by(ProductCategory.sort_order, ProductCategory.id).all() + + if tree: + data = build_category_tree(categories) + else: + data = [] + for cat in categories: + data.append({ + "id": cat.id, + "name": cat.name, + "code": cat.code, + "parent_id": cat.parent_id, + "commission_rate": str(cat.commission_rate), + "monthly_rebate": str(cat.monthly_rebate), + "quarterly_rebate": str(cat.quarterly_rebate) if cat.quarterly_rebate else None, + "is_main_product": cat.is_main_product, + "sort_order": cat.sort_order, + "status": cat.status, + "source_file": cat.source_file, + "import_batch": cat.import_batch, + "last_import_at": cat.last_import_at.isoformat() if cat.last_import_at else None, + "created_at": cat.created_at.isoformat() if cat.created_at else None, + "updated_at": cat.updated_at.isoformat() if cat.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/{category_id}", response_model=dict) +def get_category( + category_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取分类详情""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 获取父级信息 + parent_name = None + if category.parent_id: + parent = db.query(ProductCategory).filter(ProductCategory.id == category.parent_id).first() + if parent: + parent_name = parent.name + + # 获取子分类 + children = db.query(ProductCategory).filter(ProductCategory.parent_id == category_id).all() + children_data = [{"id": c.id, "name": c.name, "code": c.code} for c in children] + + return { + "code": 200, + "message": "success", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "parent_name": parent_name, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "source_file": category.source_file, + "import_batch": category.import_batch, + "last_import_at": category.last_import_at.isoformat() if category.last_import_at else None, + "children": children_data, + "created_at": category.created_at.isoformat() if category.created_at else None, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建分类""" + # 检查code是否已存在 + if data.code: + existing = db.query(ProductCategory).filter(ProductCategory.code == data.code).first() + if existing: + raise HTTPException(status_code=400, detail="分类编码已存在") + + # 检查父级是否存在 + if data.parent_id: + parent = db.query(ProductCategory).filter(ProductCategory.id == data.parent_id).first() + if not parent: + raise HTTPException(status_code=400, detail="父级分类不存在") + + category = ProductCategory( + name=data.name, + code=data.code, + parent_id=data.parent_id, + commission_rate=data.commission_rate, + monthly_rebate=data.monthly_rebate, + quarterly_rebate=data.quarterly_rebate, + is_main_product=data.is_main_product, + sort_order=data.sort_order, + status=1 + ) + db.add(category) + db.commit() + db.refresh(category) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_CATEGORY", "category", category.id, + new_value=f"创建分类: {data.name}, code={data.code}") + + return { + "code": 200, + "message": "分类创建成功", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "created_at": category.created_at.isoformat() if category.created_at else None + } + } + + +@router.put("/{category_id}", response_model=dict) +def update_category( + category_id: int, + data: CategoryUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新分类""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 如果更换父级,检查是否存在且不会形成循环 + if data.parent_id is not None and data.parent_id != category.parent_id: + if data.parent_id == category_id: + raise HTTPException(status_code=400, detail="不能将自己设为父级") + parent = db.query(ProductCategory).filter(ProductCategory.id == data.parent_id).first() + if not parent: + raise HTTPException(status_code=400, detail="父级分类不存在") + # 检查是否会成为子分类的子级(循环引用) + def check_circular(parent_id: int, target_id: int) -> bool: + if parent_id == target_id: + return True + children = db.query(ProductCategory).filter(ProductCategory.parent_id == parent_id).all() + for child in children: + if check_circular(child.id, target_id): + return True + return False + + if check_circular(category_id, data.parent_id): + raise HTTPException(status_code=400, detail="不能将分类设为其子分类的子级") + + # 检查code是否已存在 + if data.code and data.code != category.code: + existing = db.query(ProductCategory).filter(ProductCategory.code == data.code).first() + if existing: + raise HTTPException(status_code=400, detail="分类编码已存在") + + # 记录旧值 + old_value = f"name={category.name}, code={category.code}, commission_rate={category.commission_rate}" + + # 更新分类信息 + if data.name is not None: + category.name = data.name + if data.code is not None: + category.code = data.code + if data.parent_id is not None: + category.parent_id = data.parent_id + if data.commission_rate is not None: + category.commission_rate = data.commission_rate + if data.monthly_rebate is not None: + category.monthly_rebate = data.monthly_rebate + if data.quarterly_rebate is not None: + category.quarterly_rebate = data.quarterly_rebate + if data.is_main_product is not None: + category.is_main_product = data.is_main_product + if data.sort_order is not None: + category.sort_order = data.sort_order + if data.status is not None: + category.status = data.status + + db.commit() + db.refresh(category) + + # 记录操作日志 + new_value = f"name={category.name}, code={category.code}, commission_rate={category.commission_rate}" + log_operation(db, current_admin.id, "UPDATE_CATEGORY", "category", category_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "分类更新成功", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + } + + +@router.delete("/{category_id}", response_model=dict) +def delete_category( + category_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除分类""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 检查是否有子分类 + children = db.query(ProductCategory).filter(ProductCategory.parent_id == category_id).first() + if children: + raise HTTPException(status_code=400, detail="该分类下有子分类,请先删除子分类") + + # 检查是否有关联的业绩记录 + from app.models import PerformanceRecord + records = db.query(PerformanceRecord).filter(PerformanceRecord.category_id == category_id).first() + if records: + raise HTTPException(status_code=400, detail="该分类已关联业绩记录,无法删除") + + name = category.name + + # 删除分类 + db.delete(category) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_CATEGORY", "category", category_id, + old_value=f"删除分类: {name}") + + return { + "code": 200, + "message": "分类删除成功", + "data": None + } + + +@router.post("/import", response_model=dict) +def preview_import_categories( + data: CategoryImportData, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """火山引擎清单导入(预览)""" + import uuid + + batch_id = str(uuid.uuid4())[:8] + preview_items = [] + errors = [] + + # 收集现有分类编码 + existing_codes = {c.code: c for c in db.query(ProductCategory).all() if c.code} + + # 构建编码到ID的映射(用于父级关联) + code_to_id = {} + new_code_to_index = {} + + for idx, item in enumerate(data.items): + if not item.code: + errors.append({"index": idx, "message": "分类编码不能为空"}) + continue + + if not item.name: + errors.append({"index": idx, "message": "分类名称不能为空"}) + continue + + # 检查编码是否重复(在当前导入数据中) + if item.code in new_code_to_index: + errors.append({"index": idx, "message": f"编码 '{item.code}' 在导入数据中重复"}) + continue + + new_code_to_index[item.code] = idx + + # 判断是新增还是更新 + action = "update" if item.code in existing_codes else "create" + existing = existing_codes.get(item.code) + + preview_item = { + "index": idx, + "code": item.code, + "name": item.name, + "parent_code": item.parent_code, + "commission_rate": str(item.commission_rate), + "monthly_rebate": str(item.monthly_rebate), + "quarterly_rebate": str(item.quarterly_rebate) if item.quarterly_rebate else None, + "is_main_product": item.is_main_product, + "action": action, + "existing_id": existing.id if existing else None, + "existing_name": existing.name if existing else None + } + preview_items.append(preview_item) + + # 检查父级编码是否存在 + for item in preview_items: + if item["parent_code"]: + if item["parent_code"] not in existing_codes and item["parent_code"] not in new_code_to_index: + errors.append({"index": item["index"], "message": f"父级编码 '{item['parent_code']}' 不存在"}) + + # 保存预览数据到缓存 + preview_data = { + "batch_id": batch_id, + "source_file": data.source_file, + "items": data.items, + "preview": preview_items, + "errors": errors, + "created_at": datetime.now().isoformat() + } + _import_preview_cache[batch_id] = preview_data + + return { + "code": 200, + "message": "预览生成成功", + "data": { + "batch_id": batch_id, + "total": len(data.items), + "create_count": len([p for p in preview_items if p["action"] == "create"]), + "update_count": len([p for p in preview_items if p["action"] == "update"]), + "error_count": len(errors), + "preview": preview_items, + "errors": errors + } + } + + +@router.post("/import/confirm", response_model=dict) +def confirm_import_categories( + batch_id: str, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """确认导入""" + if batch_id not in _import_preview_cache: + raise HTTPException(status_code=404, detail="导入批次不存在或已过期") + + preview_data = _import_preview_cache[batch_id] + + if preview_data["errors"]: + raise HTTPException(status_code=400, detail="导入数据存在错误,无法确认导入") + + items = preview_data["items"] + source_file = preview_data["source_file"] + + # 收集现有分类 + existing_codes = {c.code: c for c in db.query(ProductCategory).all() if c.code} + + # 第一步:创建/更新所有分类(不处理parent_id) + created_items = {} + updated_count = 0 + created_count = 0 + + for item in items: + if item.code in existing_codes: + # 更新现有分类 + category = existing_codes[item.code] + category.name = item.name + category.commission_rate = item.commission_rate + category.monthly_rebate = item.monthly_rebate + category.quarterly_rebate = item.quarterly_rebate + category.is_main_product = item.is_main_product + category.source_file = source_file + category.import_batch = batch_id + category.last_import_at = datetime.now() + updated_count += 1 + else: + # 创建新分类 + category = ProductCategory( + name=item.name, + code=item.code, + commission_rate=item.commission_rate, + monthly_rebate=item.monthly_rebate, + quarterly_rebate=item.quarterly_rebate, + is_main_product=item.is_main_product, + source_file=source_file, + import_batch=batch_id, + last_import_at=datetime.now(), + status=1 + ) + db.add(category) + db.flush() # 获取ID + created_count += 1 + + created_items[item.code] = category + + # 第二步:处理父级关联 + for item in items: + if item.parent_code: + category = created_items[item.code] + if item.parent_code in created_items: + category.parent_id = created_items[item.parent_code].id + elif item.parent_code in existing_codes: + category.parent_id = existing_codes[item.parent_code].id + + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "IMPORT_CATEGORIES", "category", None, + new_value=f"导入分类: 创建{created_count}个, 更新{updated_count}个, 来源:{source_file}") + + # 清理缓存 + del _import_preview_cache[batch_id] + + return { + "code": 200, + "message": "导入成功", + "data": { + "batch_id": batch_id, + "created": created_count, + "updated": updated_count, + "total": len(items) + } + } diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..24ddda5 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,172 @@ +from typing import Optional, Literal +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, and_, extract +from datetime import datetime, timedelta +from decimal import Decimal + +from app.database import get_db +from app.models import ( + User, Employee, SecondaryAgent, ProductCategory, + PerformanceRecord, CalculationResult +) +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/dashboard", tags=["数据看板"]) + + +@router.get("/summary", response_model=dict) +def get_dashboard_summary( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取仪表盘汇总数据""" + # 本月业绩总额 + now = datetime.now() + start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + monthly_performance = db.query( + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")) + ).filter( + PerformanceRecord.record_date >= start_of_month.date() + ).scalar() + + # 员工收益总额(本月) + monthly_income = db.query( + func.coalesce(func.sum(CalculationResult.total_income), Decimal("0")) + ).filter( + CalculationResult.calc_year == now.year, + CalculationResult.calc_month == now.month + ).scalar() + + # 员工总数 + employee_count = db.query(Employee).join(User).filter(User.status == 1).count() + + # 二级代理总数 + agent_count = db.query(SecondaryAgent).join(User).filter(User.status == 1).count() + + # 产品分类数 + category_count = db.query(ProductCategory).filter(ProductCategory.status == 1).count() + + return { + "code": 200, + "message": "success", + "data": { + "total_performance": str(monthly_performance), + "total_income": str(monthly_income), + "employee_count": employee_count, + "agent_count": agent_count, + "category_count": category_count + } + } + + +@router.get("/chart", response_model=dict) +def get_dashboard_chart( + type: Literal["performance", "category"] = Query(..., description="图表类型"), + months: int = Query(6, ge=1, le=12, description="显示月数"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取仪表盘图表数据""" + + if type == "performance": + # 业绩趋势数据 + end_date = datetime.now() + data = [] + + for i in range(months - 1, -1, -1): + month_date = end_date - timedelta(days=i * 30) + start_of_month = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if month_date.month == 12: + end_of_month = month_date.replace(year=month_date.year + 1, month=1, day=1) + else: + end_of_month = month_date.replace(month=month_date.month + 1, day=1) + + amount = db.query( + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")) + ).filter( + PerformanceRecord.record_date >= start_of_month.date(), + PerformanceRecord.record_date < end_of_month.date() + ).scalar() + + data.append({ + "month": month_date.strftime("%Y-%m"), + "amount": float(amount) + }) + + return { + "code": 200, + "message": "success", + "data": data + } + + elif type == "category": + # 产品分类占比数据 + results = db.query( + ProductCategory.name, + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")).label("amount") + ).outerjoin( + PerformanceRecord, ProductCategory.id == PerformanceRecord.category_id + ).filter( + ProductCategory.status == 1 + ).group_by(ProductCategory.id).all() + + data = [ + {"name": name, "amount": float(amount)} + for name, amount in results if amount > 0 + ] + + return { + "code": 200, + "message": "success", + "data": data + } + + return { + "code": 400, + "message": "不支持的图表类型", + "data": [] + } + + +@router.get("/recent-performance", response_model=dict) +def get_recent_performance( + limit: int = Query(5, ge=1, le=20), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取最近业绩记录""" + records = db.query(PerformanceRecord).order_by( + PerformanceRecord.record_date.desc() + ).limit(limit).all() + + data = [] + for record in records: + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp and emp.user: + employee_name = emp.user.name + + category_name = None + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + data.append({ + "id": record.id, + "record_date": record.record_date.isoformat() if record.record_date else None, + "employee_name": employee_name, + "category_name": category_name, + "amount": str(record.amount), + "customer_name": record.customer_name, + "order_no": record.order_no + }) + + return { + "code": 200, + "message": "success", + "data": data + } diff --git a/backend/app/routers/employees.py b/backend/app/routers/employees.py new file mode 100644 index 0000000..b5cbb82 --- /dev/null +++ b/backend/app/routers/employees.py @@ -0,0 +1,421 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import date +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/employees", tags=["员工管理"]) + + +# Pydantic模型 +class EmployeeTarget(BaseModel): + monthly_target: Decimal = Field(default=0) + quarterly_target: Decimal = Field(default=0) + half_year_target: Decimal = Field(default=0) + yearly_target: Decimal = Field(default=0) + + +class EmployeeBase(BaseModel): + name: str + phone: Optional[str] = None + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + base_salary: Decimal = Field(default=4000) + hire_date: Optional[date] = None + + +class EmployeeCreate(EmployeeBase): + username: str + password: str + targets: Optional[EmployeeTarget] = None + + +class EmployeeUpdate(BaseModel): + name: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + base_salary: Optional[Decimal] = None + hire_date: Optional[date] = None + status: Optional[int] = None + + +class EmployeeResponse(BaseModel): + id: int + user_id: int + username: str + name: str + phone: Optional[str] + email: Optional[str] + department: Optional[str] + position: Optional[str] + base_salary: Decimal + monthly_target: Decimal + quarterly_target: Decimal + half_year_target: Decimal + yearly_target: Decimal + hire_date: Optional[date] + status: int + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class EmployeeListResponse(BaseModel): + items: List[EmployeeResponse] + total: int + page: int + page_size: int + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_employees( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + search: Optional[str] = None, + department: Optional[str] = None, + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取员工列表(支持分页、搜索)""" + query = db.query(Employee).join(User) + + # 搜索条件 + if search: + query = query.filter( + (User.name.contains(search)) | + (User.username.contains(search)) | + (User.phone.contains(search)) + ) + + # 部门筛选 + if department: + query = query.filter(Employee.department == department) + + # 状态筛选 + if status is not None: + query = query.filter(User.status == status) + + # 计算总数 + total = query.count() + + # 分页 + employees = query.offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + items = [] + for emp in employees: + items.append({ + "id": emp.id, + "user_id": emp.user_id, + "username": emp.user.username, + "name": emp.user.name, + "phone": emp.user.phone, + "email": emp.user.email, + "department": emp.department, + "position": emp.position, + "base_salary": str(emp.base_salary), + "monthly_target": str(emp.monthly_target), + "quarterly_target": str(emp.quarterly_target), + "half_year_target": str(emp.half_year_target), + "yearly_target": str(emp.yearly_target), + "hire_date": emp.hire_date.isoformat() if emp.hire_date else None, + "status": emp.user.status, + "created_at": emp.created_at.isoformat() if emp.created_at else None, + "updated_at": emp.updated_at.isoformat() if emp.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{employee_id}", response_model=dict) +def get_employee( + employee_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取员工详情""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + return { + "code": 200, + "message": "success", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": employee.user.username, + "name": employee.user.name, + "phone": employee.user.phone, + "email": employee.user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": employee.user.status, + "created_at": employee.created_at.isoformat() if employee.created_at else None, + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_employee( + data: EmployeeCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建员工(同时创建用户账号)""" + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.username == data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="用户名已存在") + + # 检查手机号是否已存在 + if data.phone: + existing_phone = db.query(User).filter(User.phone == data.phone).first() + if existing_phone: + raise HTTPException(status_code=400, detail="手机号已存在") + + # 创建用户 + from app.auth import get_password_hash + user = User( + username=data.username, + password_hash=get_password_hash(data.password), + role="employee", + name=data.name, + phone=data.phone, + email=data.email, + status=1 + ) + db.add(user) + db.flush() # 获取user.id + + # 创建员工记录 + targets = data.targets or EmployeeTarget() + employee = Employee( + user_id=user.id, + base_salary=data.base_salary, + monthly_target=targets.monthly_target, + quarterly_target=targets.quarterly_target, + half_year_target=targets.half_year_target, + yearly_target=targets.yearly_target, + hire_date=data.hire_date, + department=data.department, + position=data.position + ) + db.add(employee) + db.commit() + db.refresh(employee) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_EMPLOYEE", "employee", employee.id, + new_value=f"创建员工: {data.name}, 用户名: {data.username}") + + return { + "code": 200, + "message": "员工创建成功", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": user.username, + "name": user.name, + "phone": user.phone, + "email": user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": user.status, + "created_at": employee.created_at.isoformat() if employee.created_at else None + } + } + + +@router.put("/{employee_id}", response_model=dict) +def update_employee( + employee_id: int, + data: EmployeeUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新员工信息""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + user = employee.user + + # 记录旧值 + old_value = f"name={user.name}, phone={user.phone}, department={employee.department}, position={employee.position}" + + # 更新用户信息 + if data.name is not None: + user.name = data.name + if data.phone is not None: + user.phone = data.phone + if data.email is not None: + user.email = data.email + if data.status is not None: + user.status = data.status + + # 更新员工信息 + if data.department is not None: + employee.department = data.department + if data.position is not None: + employee.position = data.position + if data.base_salary is not None: + employee.base_salary = data.base_salary + if data.hire_date is not None: + employee.hire_date = data.hire_date + + db.commit() + db.refresh(employee) + + # 记录操作日志 + new_value = f"name={user.name}, phone={user.phone}, department={employee.department}, position={employee.position}" + log_operation(db, current_admin.id, "UPDATE_EMPLOYEE", "employee", employee.id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "员工信息更新成功", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": user.username, + "name": user.name, + "phone": user.phone, + "email": user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": user.status, + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } + + +@router.delete("/{employee_id}", response_model=dict) +def delete_employee( + employee_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除员工""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + user_id = employee.user_id + user_name = employee.user.name + + # 删除员工(级联删除用户) + db.delete(employee) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_EMPLOYEE", "employee", employee_id, + old_value=f"删除员工: {user_name}, user_id={user_id}") + + return { + "code": 200, + "message": "员工删除成功", + "data": None + } + + +@router.put("/{employee_id}/targets", response_model=dict) +def update_employee_targets( + employee_id: int, + data: EmployeeTarget, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新员工目标""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + # 记录旧值 + old_value = f"monthly={employee.monthly_target}, quarterly={employee.quarterly_target}, half_year={employee.half_year_target}, yearly={employee.yearly_target}" + + # 更新目标 + employee.monthly_target = data.monthly_target + employee.quarterly_target = data.quarterly_target + employee.half_year_target = data.half_year_target + employee.yearly_target = data.yearly_target + + db.commit() + db.refresh(employee) + + # 记录操作日志 + new_value = f"monthly={data.monthly_target}, quarterly={data.quarterly_target}, half_year={data.half_year_target}, yearly={data.yearly_target}" + log_operation(db, current_admin.id, "UPDATE_EMPLOYEE_TARGETS", "employee", employee_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "员工目标更新成功", + "data": { + "id": employee.id, + "name": employee.user.name, + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } diff --git a/backend/app/routers/performance.py b/backend/app/routers/performance.py new file mode 100644 index 0000000..f5948d3 --- /dev/null +++ b/backend/app/routers/performance.py @@ -0,0 +1,406 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import date, datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, SecondaryAgent, ProductCategory, PerformanceRecord, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/performance", tags=["业绩管理"]) + + +# Pydantic模型 +class PerformanceBase(BaseModel): + record_type: str = Field(default="employee", description="记录类型: employee/agent") + employee_id: Optional[int] = None + agent_id: Optional[int] = None + category_id: int + amount: Decimal = Field(gt=0) + record_date: date + customer_name: Optional[str] = None + order_no: Optional[str] = None + remark: Optional[str] = None + + +class PerformanceCreate(PerformanceBase): + pass + + +class PerformanceUpdate(BaseModel): + record_type: Optional[str] = None + employee_id: Optional[int] = None + agent_id: Optional[int] = None + category_id: Optional[int] = None + amount: Optional[Decimal] = None + record_date: Optional[date] = None + customer_name: Optional[str] = None + order_no: Optional[str] = None + remark: Optional[str] = None + + +class PerformanceResponse(BaseModel): + id: int + record_type: str + employee_id: Optional[int] + employee_name: Optional[str] + agent_id: Optional[int] + agent_name: Optional[str] + category_id: int + category_name: str + amount: str + record_date: str + customer_name: Optional[str] + order_no: Optional[str] + remark: Optional[str] + created_by: int + created_by_name: str + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class PerformanceListResponse(BaseModel): + list: List[PerformanceResponse] + total: int + page: int + page_size: int + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_performance_list( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + employee_id: Optional[int] = None, + agent_id: Optional[int] = None, + category_id: Optional[int] = None, + record_type: Optional[str] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩列表""" + query = db.query(PerformanceRecord) + + # 筛选条件 + if employee_id: + query = query.filter(PerformanceRecord.employee_id == employee_id) + if agent_id: + query = query.filter(PerformanceRecord.agent_id == agent_id) + if category_id: + query = query.filter(PerformanceRecord.category_id == category_id) + if record_type: + query = query.filter(PerformanceRecord.record_type == record_type) + if start_date: + query = query.filter(PerformanceRecord.record_date >= start_date) + if end_date: + query = query.filter(PerformanceRecord.record_date <= end_date) + + # 普通员工只能看自己的业绩 + if current_user.role == "employee": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + query = query.filter(PerformanceRecord.employee_id == employee.id) + + # 计算总数 + total = query.count() + + # 分页 + records = query.order_by(PerformanceRecord.record_date.desc()).offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + list_data = [] + for record in records: + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp: + employee_name = emp.user.name if emp.user else None + + agent_name = None + if record.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == record.agent_id).first() + if agent: + agent_name = agent.company_name + + category_name = "" + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + created_by_name = "" + if record.created_by: + creator = db.query(User).filter(User.id == record.created_by).first() + if creator: + created_by_name = creator.name or creator.username + + list_data.append({ + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "employee_name": employee_name, + "agent_id": record.agent_id, + "agent_name": agent_name, + "category_id": record.category_id, + "category_name": category_name, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "customer_name": record.customer_name, + "order_no": record.order_no, + "remark": record.remark, + "created_by": record.created_by, + "created_by_name": created_by_name, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "list": list_data, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{record_id}", response_model=dict) +def get_performance_detail( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩详情""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 普通员工只能看自己的业绩 + if current_user.role == "employee": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee and record.employee_id != employee.id: + raise HTTPException(status_code=403, detail="无权查看该业绩记录") + + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp: + employee_name = emp.user.name if emp.user else None + + agent_name = None + if record.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == record.agent_id).first() + if agent: + agent_name = agent.company_name + + category_name = "" + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + created_by_name = "" + if record.created_by: + creator = db.query(User).filter(User.id == record.created_by).first() + if creator: + created_by_name = creator.name or creator.username + + return { + "code": 200, + "message": "success", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "employee_name": employee_name, + "agent_id": record.agent_id, + "agent_name": agent_name, + "category_id": record.category_id, + "category_name": category_name, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "customer_name": record.customer_name, + "order_no": record.order_no, + "remark": record.remark, + "created_by": record.created_by, + "created_by_name": created_by_name, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_performance( + data: PerformanceCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """创建业绩记录""" + # 验证员工或代理是否存在 + if data.record_type == "employee" and data.employee_id: + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="员工不存在") + elif data.record_type == "agent" and data.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == data.agent_id).first() + if not agent: + raise HTTPException(status_code=400, detail="代理不存在") + + # 验证产品分类是否存在 + category = db.query(ProductCategory).filter(ProductCategory.id == data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="产品分类不存在") + + # 创建记录 + record = PerformanceRecord( + record_type=data.record_type, + employee_id=data.employee_id, + agent_id=data.agent_id, + category_id=data.category_id, + amount=data.amount, + record_date=data.record_date, + customer_name=data.customer_name, + order_no=data.order_no, + remark=data.remark, + created_by=current_user.id + ) + db.add(record) + db.commit() + db.refresh(record) + + # 记录操作日志 + log_operation(db, current_user.id, "CREATE_PERFORMANCE", "performance", record.id, + new_value=f"创建业绩记录: 金额={data.amount}, 日期={data.record_date}") + + return { + "code": 200, + "message": "业绩记录创建成功", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "agent_id": record.agent_id, + "category_id": record.category_id, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "created_at": record.created_at.isoformat() if record.created_at else None + } + } + + +@router.put("/{record_id}", response_model=dict) +def update_performance( + record_id: int, + data: PerformanceUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """更新业绩记录""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 记录旧值 + old_value = f"amount={record.amount}, date={record.record_date}, category_id={record.category_id}" + + # 更新字段 + if data.record_type is not None: + record.record_type = data.record_type + if data.employee_id is not None: + record.employee_id = data.employee_id + if data.agent_id is not None: + record.agent_id = data.agent_id + if data.category_id is not None: + # 验证产品分类是否存在 + category = db.query(ProductCategory).filter(ProductCategory.id == data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="产品分类不存在") + record.category_id = data.category_id + if data.amount is not None: + record.amount = data.amount + if data.record_date is not None: + record.record_date = data.record_date + if data.customer_name is not None: + record.customer_name = data.customer_name + if data.order_no is not None: + record.order_no = data.order_no + if data.remark is not None: + record.remark = data.remark + + db.commit() + db.refresh(record) + + # 记录操作日志 + new_value = f"amount={record.amount}, date={record.record_date}, category_id={record.category_id}" + log_operation(db, current_user.id, "UPDATE_PERFORMANCE", "performance", record_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "业绩记录更新成功", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "agent_id": record.agent_id, + "category_id": record.category_id, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + } + } + + +@router.delete("/{record_id}", response_model=dict) +def delete_performance( + record_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除业绩记录""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 记录旧值 + old_value = f"删除业绩记录: 金额={record.amount}, 日期={record.record_date}" + + db.delete(record) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_PERFORMANCE", "performance", record_id, + old_value=old_value) + + return { + "code": 200, + "message": "业绩记录删除成功", + "data": None + } diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..6feeaf4 --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,150 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from fastapi.responses import StreamingResponse + +from app.database import get_db +from app.models import User +from app.routers.auth import get_current_user, get_current_admin +from app.services.report_service import ( + export_employee_report, + export_company_report, + export_performance_report +) + +router = APIRouter(prefix="/reports", tags=["报表导出"]) + + +@router.get("/employee/{employee_id}/excel") +def export_employee_excel( + employee_id: int, + period: str = Query(..., description="计算周期: monthly/quarterly/half_yearly/yearly"), + year: int = Query(..., description="年份"), + month: Optional[int] = Query(None, description="月份(月度周期需要)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """导出员工收益Excel报表""" + # 权限检查:非管理员只能导出自己的报表 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or employee.id != employee_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,只能导出自己的报表" + ) + + try: + excel_bytes, filename = export_employee_report( + db=db, + employee_id=employee_id, + period=period, + year=year, + month=month + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.get("/company/excel") +def export_company_excel( + period: str = Query(..., description="计算周期: monthly/quarterly/half_yearly/yearly"), + year: int = Query(..., description="年份"), + month: Optional[int] = Query(None, description="月份(月度周期需要)"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """导出公司收益Excel报表(仅管理员)""" + try: + excel_bytes, filename = export_company_report( + db=db, + period=period, + year=year, + month=month + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.get("/performance/excel") +def export_performance_excel( + start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"), + employee_id: Optional[int] = Query(None, description="员工ID"), + agent_id: Optional[int] = Query(None, description="代理ID"), + category_id: Optional[int] = Query(None, description="分类ID"), + record_type: Optional[str] = Query(None, description="记录类型: employee/agent"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """导出业绩报表Excel""" + from datetime import datetime + + # 构建筛选条件 + filters = {} + + if start_date: + try: + filters['start_date'] = datetime.strptime(start_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="开始日期格式错误,应为YYYY-MM-DD") + + if end_date: + try: + filters['end_date'] = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="结束日期格式错误,应为YYYY-MM-DD") + + # 权限检查:非管理员只能查看自己的业绩 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + filters['employee_id'] = employee.id + else: + if employee_id: + filters['employee_id'] = employee_id + + if agent_id: + filters['agent_id'] = agent_id + if category_id: + filters['category_id'] = category_id + if record_type: + filters['record_type'] = record_type + + try: + excel_bytes, filename = export_performance_report( + db=db, + filters=filters + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..80156d5 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,88 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.database import get_db +from app.models import Setting +from app.routers.auth import get_current_admin + +router = APIRouter(prefix="/settings", tags=["系统设置"]) + + +# Pydantic模型 +class SettingItem(BaseModel): + key: str + value: str + + +class SettingsUpdate(BaseModel): + settings: List[SettingItem] + + +@router.get("", response_model=dict) +def get_settings(db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """获取所有设置(按分组)""" + settings = db.query(Setting).order_by(Setting.group_name, Setting.sort_order).all() + + # 按分组组织 + result = {} + for setting in settings: + group = setting.group_name or "general" + if group not in result: + result[group] = {} + result[group][setting.setting_key] = setting.setting_value + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.put("", response_model=dict) +def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """批量更新设置""" + updated_count = 0 + + for item in data.settings: + setting = db.query(Setting).filter(Setting.setting_key == item.key).first() + if setting: + setting.setting_value = item.value + updated_count += 1 + else: + # 如果不存在则创建 + new_setting = Setting( + setting_key=item.key, + setting_value=item.value, + description="", + group_name="general" + ) + db.add(new_setting) + updated_count += 1 + + db.commit() + + return { + "code": 200, + "message": f"成功更新 {updated_count} 项设置", + "data": None + } + + +@router.get("/value/{key}", response_model=dict) +def get_setting_value(key: str, db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """获取单个设置值""" + setting = db.query(Setting).filter(Setting.setting_key == key).first() + if not setting: + raise HTTPException(status_code=404, detail="设置项不存在") + + return { + "code": 200, + "message": "success", + "data": { + "key": setting.setting_key, + "value": setting.setting_value, + "description": setting.description + } + } diff --git a/backend/app/services/__pycache__/calculate_service.cpython-312.pyc b/backend/app/services/__pycache__/calculate_service.cpython-312.pyc new file mode 100644 index 0000000..c3007e7 Binary files /dev/null and b/backend/app/services/__pycache__/calculate_service.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/report_service.cpython-312.pyc b/backend/app/services/__pycache__/report_service.cpython-312.pyc new file mode 100644 index 0000000..42e7970 Binary files /dev/null and b/backend/app/services/__pycache__/report_service.cpython-312.pyc differ diff --git a/backend/app/services/calculate_service.py b/backend/app/services/calculate_service.py new file mode 100644 index 0000000..d3eeede --- /dev/null +++ b/backend/app/services/calculate_service.py @@ -0,0 +1,604 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Optional, Dict, List, Any +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ +import json + +from app.models import ( + Employee, SecondaryAgent, ProductCategory, PerformanceRecord, + CalculationResult, User +) + + +def get_period_dates(period: str, year: int, month: Optional[int] = None, + quarter: Optional[int] = None) -> tuple: + """ + 根据周期类型获取开始和结束日期 + + Returns: + tuple: (start_date, end_date, period_key) + """ + if period == "monthly": + if month is None: + raise ValueError("月度周期需要提供month参数") + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + period_key = f"{year}-{month:02d}" + + elif period == "quarterly": + if quarter is None: + raise ValueError("季度周期需要提供quarter参数") + start_month = (quarter - 1) * 3 + 1 + end_month = quarter * 3 + 1 + start_date = date(year, start_month, 1) + if end_month > 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, end_month, 1) + period_key = f"{year}-Q{quarter}" + + elif period == "half_yearly": + start_date = date(year, 1, 1) + end_date = date(year, 7, 1) + period_key = f"{year}-H1" + + elif period == "yearly": + start_date = date(year, 1, 1) + end_date = date(year + 1, 1, 1) + period_key = f"{year}" + + else: + raise ValueError(f"不支持的周期类型: {period}") + + return start_date, end_date, period_key + + +def get_target_by_period(employee: Employee, period: str) -> Decimal: + """根据周期类型获取员工目标""" + if period == "monthly": + return employee.monthly_target or Decimal("0") + elif period == "quarterly": + return employee.quarterly_target or Decimal("0") + elif period == "half_yearly": + return employee.half_year_target or Decimal("0") + elif period == "yearly": + return employee.yearly_target or Decimal("0") + return Decimal("0") + + +def get_rebate_rate_by_period(category: ProductCategory, period: str) -> Decimal: + """根据周期类型获取返点比例""" + if period == "monthly": + return category.monthly_rebate or Decimal("0") + elif period == "quarterly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + elif period == "half_yearly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + elif period == "yearly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + return Decimal("0") + + +def calculate_employee_income( + db: Session, + employee_id: int, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None, + save_result: bool = True +) -> Dict[str, Any]: + """ + 计算员工在指定周期的收益 + + 计算逻辑: + 1. 获取员工业绩数据(根据period筛选) + 2. 计算完成率 = 总业绩 / 目标 + 3. 绩效奖金 = 1000 * min(完成率, 1.0) 如果完成率>=50%,否则0 + 4. 个人提成 = 业绩按分类汇总 * 各分类提成比例 + 5. 代理提成 = 二级代理业绩总和 * 0.01 + 6. 总收入 = 底薪 + 绩效奖金 + 个人提成 + 代理提成 + + Args: + db: 数据库会话 + employee_id: 员工ID + period: 周期类型 (monthly/quarterly/half_yearly/yearly) + year: 年份 + month: 月份(月度周期需要) + quarter: 季度(季度周期需要) + save_result: 是否保存计算结果到数据库 + + Returns: + 计算结果字典 + """ + # 获取员工信息 + employee = db.query(Employee).filter(Employee.id == employee_id).first() + if not employee: + raise ValueError(f"员工不存在: {employee_id}") + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取目标金额 + target_amount = get_target_by_period(employee, period) + + # 1. 获取员工业绩数据(个人业绩) + personal_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "employee", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 按分类汇总个人业绩 + category_performance = {} + total_personal_performance = Decimal("0") + + for record in personal_records: + category_id = record.category_id + amount = record.amount or Decimal("0") + total_personal_performance += amount + + if category_id not in category_performance: + category_performance[category_id] = { + "amount": Decimal("0"), + "category_name": record.category.name if record.category else "未知分类" + } + category_performance[category_id]["amount"] += amount + + # 2. 获取二级代理业绩数据 + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "agent", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + total_agent_performance = Decimal("0") + for record in agent_records: + total_agent_performance += record.amount or Decimal("0") + + # 计算总业绩 + total_performance = total_personal_performance + total_agent_performance + + # 3. 计算完成率 + completion_rate = Decimal("0") + if target_amount > 0: + completion_rate = (total_performance / target_amount) * 100 + + # 4. 计算绩效奖金 + performance_bonus = Decimal("0") + if completion_rate >= 50: + rate = min(completion_rate / 100, Decimal("1.0")) + performance_bonus = Decimal("1000") * rate + + # 5. 计算个人提成 + personal_commission = Decimal("0") + commission_details = [] + + for category_id, data in category_performance.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + if category: + commission_rate = category.commission_rate or Decimal("0") + commission = data["amount"] * commission_rate + personal_commission += commission + commission_details.append({ + "category_id": category_id, + "category_name": data["category_name"], + "amount": float(data["amount"]), + "commission_rate": float(commission_rate), + "commission": float(commission) + }) + + # 6. 计算代理提成(二级代理业绩的1%) + agent_commission_rate = Decimal("0.01") + agent_commission = total_agent_performance * agent_commission_rate + + # 7. 计算总收入 + base_salary = employee.base_salary or Decimal("0") + total_income = base_salary + performance_bonus + personal_commission + agent_commission + + # 计算公司相关数据 + company_rebate = Decimal("0") + for category_id, data in category_performance.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + if category: + rebate_rate = get_rebate_rate_by_period(category, period) + company_rebate += data["amount"] * rebate_rate + + # 公司成本 + company_cost = total_income # 员工成本 + + # 代理分成成本 + agent_share_cost = Decimal("0") + for record in agent_records: + if record.agent: + share_rate = record.agent.profit_share_rate or Decimal("0.60") + agent_share_cost += (record.amount or Decimal("0")) * share_rate + + company_cost += agent_share_cost + + # 公司利润 + company_profit = company_rebate - company_cost + + # 构建详情JSON + detail_json = { + "personal_performance": { + "total": float(total_personal_performance), + "by_category": commission_details + }, + "agent_performance": { + "total": float(total_agent_performance), + "commission_rate": float(agent_commission_rate), + "commission": float(agent_commission) + }, + "target": { + "amount": float(target_amount), + "completion_rate": float(completion_rate) + }, + "bonus_calculation": { + "threshold": 50, + "max_bonus": 1000, + "actual_bonus": float(performance_bonus) + } + } + + result = { + "employee_id": employee_id, + "employee_name": employee.user.name if employee.user else "", + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_performance": float(total_performance), + "target_amount": float(target_amount), + "completion_rate": float(completion_rate), + "base_salary": float(base_salary), + "performance_bonus": float(performance_bonus), + "personal_commission": float(personal_commission), + "agent_commission": float(agent_commission), + "total_income": float(total_income), + "company_rebate": float(company_rebate), + "company_cost": float(company_cost), + "company_profit": float(company_profit), + "agent_performance": float(total_agent_performance), + "agent_share_amount": float(agent_share_cost), + "detail": detail_json + } + + # 保存计算结果到数据库 + if save_result: + calc_result = CalculationResult( + employee_id=employee_id, + calc_period=period, + calc_year=year, + calc_month=month, + calc_quarter=quarter, + period_start_date=start_date, + period_end_date=end_date, + total_performance=total_performance, + target_amount=target_amount, + completion_rate=completion_rate, + base_salary=base_salary, + performance_bonus=performance_bonus, + personal_commission=personal_commission, + agent_commission=agent_commission, + total_income=total_income, + company_rebate=company_rebate, + company_cost=company_cost, + company_profit=company_profit, + agent_performance=total_agent_performance, + agent_share_amount=agent_share_cost, + detail_json=json.dumps(detail_json, ensure_ascii=False) + ) + db.add(calc_result) + db.commit() + db.refresh(calc_result) + result["calculation_id"] = calc_result.id + + return result + + +def calculate_company_profit( + db: Session, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None, + save_result: bool = False +) -> Dict[str, Any]: + """ + 计算公司在指定周期的收益 + + 计算逻辑: + 1. 获取所有员工业绩 + 2. 公司返点 = 业绩按分类汇总 * 各分类返点比例 + 3. 公司成本 = 所有员工(底薪+绩效+提成) + 代理分成 + 4. 公司利润 = 返点 - 成本 + + Args: + db: 数据库会话 + period: 周期类型 + year: 年份 + month: 月份 + quarter: 季度 + save_result: 是否保存结果 + + Returns: + 计算结果字典 + """ + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取所有员工 + employees = db.query(Employee).join(User).filter(User.status == 1).all() + + total_company_rebate = Decimal("0") + total_company_cost = Decimal("0") + total_agent_share = Decimal("0") + employee_results = [] + + # 获取所有业绩记录 + all_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 按分类汇总业绩 + category_totals = {} + for record in all_records: + if record.record_type == "employee": + cat_id = record.category_id + if cat_id not in category_totals: + category_totals[cat_id] = Decimal("0") + category_totals[cat_id] += record.amount or Decimal("0") + + # 计算公司返点 + rebate_details = [] + for cat_id, amount in category_totals.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == cat_id).first() + if category: + rebate_rate = get_rebate_rate_by_period(category, period) + rebate = amount * rebate_rate + total_company_rebate += rebate + rebate_details.append({ + "category_id": cat_id, + "category_name": category.name, + "amount": float(amount), + "rebate_rate": float(rebate_rate), + "rebate": float(rebate) + }) + + # 计算每个员工的收益和代理分成 + for employee in employees: + try: + emp_result = calculate_employee_income( + db, employee.id, period, year, month, quarter, save_result=False + ) + total_company_cost += Decimal(str(emp_result["total_income"])) + total_agent_share += Decimal(str(emp_result["agent_share_amount"])) + employee_results.append({ + "employee_id": employee.id, + "employee_name": emp_result["employee_name"], + "total_income": emp_result["total_income"], + "agent_share": emp_result["agent_share_amount"] + }) + except Exception as e: + # 跳过计算失败的员工 + continue + + # 总成本 + total_cost = total_company_cost + total_agent_share + + # 公司利润 + company_profit = total_company_rebate - total_cost + + result = { + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_rebate": float(total_company_rebate), + "total_employee_cost": float(total_company_cost), + "total_agent_share": float(total_agent_share), + "total_cost": float(total_cost), + "company_profit": float(company_profit), + "employee_count": len(employee_results), + "rebate_details": rebate_details, + "employee_details": employee_results + } + + return result + + +def calculate_agent_profit( + db: Session, + agent_id: int, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None +) -> Dict[str, Any]: + """ + 计算二级代理在指定周期的收益 + + 计算逻辑: + 代理分成 = 代理业绩 * 代理分佣比例(默认60%) + + Args: + db: 数据库会话 + agent_id: 代理ID + period: 周期类型 + year: 年份 + month: 月份 + quarter: 季度 + + Returns: + 计算结果字典 + """ + # 获取代理信息 + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + if not agent: + raise ValueError(f"代理不存在: {agent_id}") + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取代理业绩 + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.agent_id == agent_id, + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + total_performance = Decimal("0") + performance_details = [] + + for record in agent_records: + amount = record.amount or Decimal("0") + total_performance += amount + performance_details.append({ + "record_id": record.id, + "date": record.record_date.isoformat() if record.record_date else None, + "amount": float(amount), + "customer_name": record.customer_name, + "order_no": record.order_no + }) + + # 计算代理分成 + profit_share_rate = agent.profit_share_rate or Decimal("0.60") + profit_share_amount = total_performance * profit_share_rate + + result = { + "agent_id": agent_id, + "agent_name": agent.company_name, + "contact_name": agent.contact_name, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee and agent.employee.user else "", + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_performance": float(total_performance), + "profit_share_rate": float(profit_share_rate), + "profit_share_amount": float(profit_share_amount), + "performance_count": len(agent_records), + "performance_details": performance_details + } + + return result + + +def get_calculation_history( + db: Session, + employee_id: Optional[int] = None, + period: Optional[str] = None, + year: Optional[int] = None, + page: int = 1, + page_size: int = 20 +) -> Dict[str, Any]: + """获取计算历史列表""" + query = db.query(CalculationResult) + + if employee_id: + query = query.filter(CalculationResult.employee_id == employee_id) + if period: + query = query.filter(CalculationResult.calc_period == period) + if year: + query = query.filter(CalculationResult.calc_year == year) + + total = query.count() + + results = query.order_by( + CalculationResult.calc_year.desc(), + CalculationResult.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for result in results: + items.append({ + "id": result.id, + "employee_id": result.employee_id, + "employee_name": result.employee.user.name if result.employee and result.employee.user else "", + "calc_period": result.calc_period, + "calc_year": result.calc_year, + "calc_month": result.calc_month, + "calc_quarter": result.calc_quarter, + "period_start_date": result.period_start_date.isoformat() if result.period_start_date else None, + "period_end_date": result.period_end_date.isoformat() if result.period_end_date else None, + "total_performance": float(result.total_performance) if result.total_performance else 0, + "target_amount": float(result.target_amount) if result.target_amount else 0, + "completion_rate": float(result.completion_rate) if result.completion_rate else 0, + "total_income": float(result.total_income) if result.total_income else 0, + "company_profit": float(result.company_profit) if result.company_profit else 0, + "created_at": result.created_at.isoformat() if result.created_at else None + }) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + + +def get_calculation_detail(db: Session, calculation_id: int) -> Optional[Dict[str, Any]]: + """获取计算历史详情""" + result = db.query(CalculationResult).filter(CalculationResult.id == calculation_id).first() + if not result: + return None + + detail = None + if result.detail_json: + try: + detail = json.loads(result.detail_json) + except: + detail = None + + return { + "id": result.id, + "employee_id": result.employee_id, + "employee_name": result.employee.user.name if result.employee and result.employee.user else "", + "calc_period": result.calc_period, + "calc_year": result.calc_year, + "calc_month": result.calc_month, + "calc_quarter": result.calc_quarter, + "period_start_date": result.period_start_date.isoformat() if result.period_start_date else None, + "period_end_date": result.period_end_date.isoformat() if result.period_end_date else None, + "total_performance": float(result.total_performance) if result.total_performance else 0, + "target_amount": float(result.target_amount) if result.target_amount else 0, + "completion_rate": float(result.completion_rate) if result.completion_rate else 0, + "base_salary": float(result.base_salary) if result.base_salary else 0, + "performance_bonus": float(result.performance_bonus) if result.performance_bonus else 0, + "personal_commission": float(result.personal_commission) if result.personal_commission else 0, + "agent_commission": float(result.agent_commission) if result.agent_commission else 0, + "total_income": float(result.total_income) if result.total_income else 0, + "company_rebate": float(result.company_rebate) if result.company_rebate else 0, + "company_cost": float(result.company_cost) if result.company_cost else 0, + "company_profit": float(result.company_profit) if result.company_profit else 0, + "agent_performance": float(result.agent_performance) if result.agent_performance else 0, + "agent_share_amount": float(result.agent_share_amount) if result.agent_share_amount else 0, + "detail": detail, + "created_at": result.created_at.isoformat() if result.created_at else None + } diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..bde19a3 --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,369 @@ +import pandas as pd +import io +from datetime import date, datetime +from typing import Optional, Dict, List, Any +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ + +from app.models import Employee, SecondaryAgent, ProductCategory, PerformanceRecord, User +from app.services.calculate_service import ( + calculate_employee_income, calculate_company_profit, + get_period_dates, get_rebate_rate_by_period +) + + +def export_employee_report( + db: Session, + employee_id: int, + period: str, + year: int, + month: Optional[int] = None +) -> tuple: + """ + 导出员工收益明细Excel + + Returns: + tuple: (excel_bytes, filename) + """ + # 计算员工收益 + result = calculate_employee_income(db, employee_id, period, year, month, save_result=False) + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month) + + # 获取业绩明细 + personal_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "employee", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "agent", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 收益汇总 + summary_data = { + '项目': [ + '员工姓名', + '计算周期', + '业绩总额', + '目标金额', + '完成率(%)', + '底薪', + '绩效奖金', + '个人提成', + '代理提成', + '总收入', + '公司返点', + '公司成本', + '公司利润' + ], + '金额': [ + result['employee_name'], + result['period_key'], + result['total_performance'], + result['target_amount'], + f"{result['completion_rate']:.2f}", + result['base_salary'], + result['performance_bonus'], + result['personal_commission'], + result['agent_commission'], + result['total_income'], + result['company_rebate'], + result['company_cost'], + result['company_profit'] + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='收益汇总', index=False) + + # Sheet 2: 个人业绩明细 + if personal_records: + personal_data = [] + for record in personal_records: + personal_data.append({ + '日期': record.record_date, + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '产品分类': record.category.name if record.category else '', + '业绩金额': float(record.amount or 0), + '提成比例': float(record.category.commission_rate or 0) if record.category else 0, + '提成金额': float(record.amount or 0) * float(record.category.commission_rate or 0) if record.category else 0 + }) + df_personal = pd.DataFrame(personal_data) + df_personal.to_excel(writer, sheet_name='个人业绩明细', index=False) + else: + pd.DataFrame({'提示': ['该周期内无个人业绩记录']}).to_excel(writer, sheet_name='个人业绩明细', index=False) + + # Sheet 3: 代理业绩明细 + if agent_records: + agent_data = [] + for record in agent_records: + agent = record.agent + agent_data.append({ + '日期': record.record_date, + '代理公司': agent.company_name if agent else '', + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '产品分类': record.category.name if record.category else '', + '业绩金额': float(record.amount or 0), + '分佣比例': float(agent.profit_share_rate or 0.6) if agent else 0.6, + '分佣金额': float(record.amount or 0) * float(agent.profit_share_rate or 0.6) if agent else float(record.amount or 0) * 0.6 + }) + df_agent = pd.DataFrame(agent_data) + df_agent.to_excel(writer, sheet_name='代理业绩明细', index=False) + else: + pd.DataFrame({'提示': ['该周期内无代理业绩记录']}).to_excel(writer, sheet_name='代理业绩明细', index=False) + + # Sheet 4: 提成明细 + if result.get('detail') and result['detail'].get('personal_performance'): + commission_data = [] + for item in result['detail']['personal_performance'].get('by_category', []): + commission_data.append({ + '产品分类': item['category_name'], + '业绩金额': item['amount'], + '提成比例': item['commission_rate'], + '提成金额': item['commission'] + }) + if commission_data: + df_commission = pd.DataFrame(commission_data) + df_commission.to_excel(writer, sheet_name='提成明细', index=False) + + output.seek(0) + filename = f"employee_report_{employee_id}_{period_key}.xlsx" + return output.getvalue(), filename + + +def export_company_report( + db: Session, + period: str, + year: int, + month: Optional[int] = None +) -> tuple: + """ + 导出公司收益汇总Excel + + Returns: + tuple: (excel_bytes, filename) + """ + # 计算公司收益 + result = calculate_company_profit(db, period, year, month) + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month) + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 公司收益汇总 + summary_data = { + '项目': [ + '计算周期', + '员工数量', + '总返点收入', + '员工成本', + '代理分成', + '总成本', + '公司利润' + ], + '金额': [ + result['period_key'], + result['employee_count'], + result['total_rebate'], + result['total_employee_cost'], + result['total_agent_share'], + result['total_cost'], + result['company_profit'] + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='公司收益汇总', index=False) + + # Sheet 2: 返点明细 + if result.get('rebate_details'): + rebate_data = [] + for item in result['rebate_details']: + rebate_data.append({ + '产品分类': item['category_name'], + '业绩金额': item['amount'], + '返点比例': item['rebate_rate'], + '返点金额': item['rebate'] + }) + df_rebate = pd.DataFrame(rebate_data) + df_rebate.to_excel(writer, sheet_name='返点明细', index=False) + + # Sheet 3: 员工成本明细 + if result.get('employee_details'): + emp_data = [] + for item in result['employee_details']: + emp_data.append({ + '员工ID': item['employee_id'], + '员工姓名': item['employee_name'], + '员工收入': item['total_income'], + '代理分成': item['agent_share'] + }) + df_emp = pd.DataFrame(emp_data) + df_emp.to_excel(writer, sheet_name='员工成本明细', index=False) + + output.seek(0) + filename = f"company_report_{period_key}.xlsx" + return output.getvalue(), filename + + +def export_performance_report( + db: Session, + filters: Dict[str, Any] +) -> tuple: + """ + 导出业绩报表Excel + + Args: + filters: 筛选条件 + - start_date: 开始日期 + - end_date: 结束日期 + - employee_id: 员工ID(可选) + - agent_id: 代理ID(可选) + - category_id: 分类ID(可选) + - record_type: 记录类型(employee/agent) + + Returns: + tuple: (excel_bytes, filename) + """ + # 构建查询 + query = db.query(PerformanceRecord) + + # 应用筛选条件 + if filters.get('start_date'): + query = query.filter(PerformanceRecord.record_date >= filters['start_date']) + if filters.get('end_date'): + query = query.filter(PerformanceRecord.record_date <= filters['end_date']) + if filters.get('employee_id'): + query = query.filter(PerformanceRecord.employee_id == filters['employee_id']) + if filters.get('agent_id'): + query = query.filter(PerformanceRecord.agent_id == filters['agent_id']) + if filters.get('category_id'): + query = query.filter(PerformanceRecord.category_id == filters['category_id']) + if filters.get('record_type'): + query = query.filter(PerformanceRecord.record_type == filters['record_type']) + + records = query.order_by(PerformanceRecord.record_date.desc()).all() + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 业绩明细 + if records: + data = [] + total_amount = 0 + for record in records: + amount = float(record.amount or 0) + total_amount += amount + + employee_name = "" + if record.employee and record.employee.user: + employee_name = record.employee.user.name + + agent_name = "" + if record.agent: + agent_name = record.agent.company_name + + category_name = "" + if record.category: + category_name = record.category.name + + data.append({ + 'ID': record.id, + '记录类型': '员工业绩' if record.record_type == 'employee' else '代理业绩', + '日期': record.record_date, + '员工': employee_name, + '代理': agent_name, + '产品分类': category_name, + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '业绩金额': amount, + '备注': record.remark or '' + }) + + df = pd.DataFrame(data) + df.to_excel(writer, sheet_name='业绩明细', index=False) + + # Sheet 2: 汇总统计 + summary_data = { + '统计项': [ + '记录总数', + '业绩总额', + '平均单笔业绩', + '员工业绩笔数', + '代理业绩笔数' + ], + '数值': [ + len(records), + total_amount, + round(total_amount / len(records), 2) if records else 0, + len([r for r in records if r.record_type == 'employee']), + len([r for r in records if r.record_type == 'agent']) + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='汇总统计', index=False) + + # Sheet 3: 按员工汇总 + emp_summary = {} + for record in records: + emp_name = record.employee.user.name if record.employee and record.employee.user else '未知' + if emp_name not in emp_summary: + emp_summary[emp_name] = {'count': 0, 'amount': 0} + emp_summary[emp_name]['count'] += 1 + emp_summary[emp_name]['amount'] += float(record.amount or 0) + + emp_data = [] + for name, stats in emp_summary.items(): + emp_data.append({ + '员工': name, + '业绩笔数': stats['count'], + '业绩总额': stats['amount'] + }) + df_emp = pd.DataFrame(emp_data) + df_emp.to_excel(writer, sheet_name='按员工汇总', index=False) + + # Sheet 4: 按分类汇总 + cat_summary = {} + for record in records: + cat_name = record.category.name if record.category else '未知' + if cat_name not in cat_summary: + cat_summary[cat_name] = {'count': 0, 'amount': 0} + cat_summary[cat_name]['count'] += 1 + cat_summary[cat_name]['amount'] += float(record.amount or 0) + + cat_data = [] + for name, stats in cat_summary.items(): + cat_data.append({ + '产品分类': name, + '业绩笔数': stats['count'], + '业绩总额': stats['amount'] + }) + df_cat = pd.DataFrame(cat_data) + df_cat.to_excel(writer, sheet_name='按分类汇总', index=False) + + else: + pd.DataFrame({'提示': ['无业绩记录']}).to_excel(writer, sheet_name='业绩明细', index=False) + + output.seek(0) + + # 生成文件名 + date_str = datetime.now().strftime('%Y%m%d') + filename = f"performance_report_{date_str}.xlsx" + + return output.getvalue(), filename diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..bbbaa88 --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +数据库初始化脚本 +""" +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.database import init_db, SessionLocal +from app.init_data import init_default_data + + +def main(): + print("🚀 开始初始化数据库...") + print("=" * 50) + + # 1. 创建表结构 + print("\n📦 创建数据库表...") + init_db() + print("✅ 数据库表创建完成") + + # 2. 初始化默认数据 + print("\n📝 初始化默认数据...") + db = SessionLocal() + try: + init_default_data(db) + finally: + db.close() + + print("\n" + "=" * 50) + print("🎉 数据库初始化完成!") + print("\n你可以使用以下命令启动后端服务:") + print(" uvicorn app.main:app --reload") + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2e29268 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +sqlalchemy==2.0.25 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pandas==2.1.4 +openpyxl==3.1.2 +reportlab==4.0.9 +python-dateutil==2.8.2 +aiosqlite==0.19.0 diff --git a/backend/sales_management.db b/backend/sales_management.db new file mode 100644 index 0000000..c022767 Binary files /dev/null and b/backend/sales_management.db differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e3ed4cf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 销售业绩与收益计算管理系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b180898 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1938 @@ +{ + "name": "sales-management-system", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sales-management-system", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-echarts": "^6.6.0", + "vue-router": "^4.2.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/resize-detector/-/resize-detector-0.3.0.tgz", + "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "6.7.3", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-6.7.3.tgz", + "integrity": "sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..33aa272 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "sales-management-system", + "version": "1.0.0", + "description": "销售业绩与收益计算管理系统", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-echarts": "^6.6.0", + "vue-router": "^4.2.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8a00770 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/api/agents.js b/frontend/src/api/agents.js new file mode 100644 index 0000000..9a41c84 --- /dev/null +++ b/frontend/src/api/agents.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 获取代理列表 +export function getAgentList(params) { + return request({ + url: '/agents', + method: 'get', + params + }) +} + +// 获取代理详情 +export function getAgentDetail(id) { + return request({ + url: `/agents/${id}`, + method: 'get' + }) +} + +// 创建代理 +export function createAgent(data) { + return request({ + url: '/agents', + method: 'post', + data + }) +} + +// 更新代理 +export function updateAgent(id, data) { + return request({ + url: `/agents/${id}`, + method: 'put', + data + }) +} + +// 删除代理 +export function deleteAgent(id) { + return request({ + url: `/agents/${id}`, + method: 'delete' + }) +} diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..af1fef8 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,34 @@ +import request from '@/utils/request' + +// 登录 +export function login(data) { + // 使用form-data格式,符合OAuth2标准 + const formData = new URLSearchParams() + formData.append('username', data.username) + formData.append('password', data.password) + + return request({ + url: '/auth/login', + method: 'post', + data: formData.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) +} + +// 获取用户信息 +export function getUserInfo() { + return request({ + url: '/auth/me', + method: 'get' + }) +} + +// 退出登录 +export function logout() { + return request({ + url: '/auth/logout', + method: 'post' + }) +} diff --git a/frontend/src/api/calculate.js b/frontend/src/api/calculate.js new file mode 100644 index 0000000..e39f987 --- /dev/null +++ b/frontend/src/api/calculate.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 计算员工收益 +export function calculateEmployee(data) { + return request({ + url: '/calculate/employee', + method: 'post', + data + }) +} + +// 计算公司收益 +export function calculateCompany(data) { + return request({ + url: '/calculate/company', + method: 'post', + data + }) +} + +// 计算代理收益 +export function calculateAgent(agentId, data) { + return request({ + url: `/calculate/agent/${agentId}`, + method: 'post', + data + }) +} + +// 获取计算历史 +export function getCalculationHistory(params) { + return request({ + url: '/calculate/history', + method: 'get', + params + }) +} + +// 获取计算历史详情 +export function getCalculationDetail(id) { + return request({ + url: `/calculate/history/${id}`, + method: 'get' + }) +} + +// 保存计算结果 +export function saveCalculationResult(id) { + return request({ + url: `/calculate/history/${id}/save`, + method: 'post' + }) +} diff --git a/frontend/src/api/categories.js b/frontend/src/api/categories.js new file mode 100644 index 0000000..c60bd36 --- /dev/null +++ b/frontend/src/api/categories.js @@ -0,0 +1,62 @@ +import request from '@/utils/request' + +// 获取分类列表 +export function getCategoryList(params) { + return request({ + url: '/categories', + method: 'get', + params + }) +} + +// 获取分类详情 +export function getCategoryDetail(id) { + return request({ + url: `/categories/${id}`, + method: 'get' + }) +} + +// 创建分类 +export function createCategory(data) { + return request({ + url: '/categories', + method: 'post', + data + }) +} + +// 更新分类 +export function updateCategory(id, data) { + return request({ + url: `/categories/${id}`, + method: 'put', + data + }) +} + +// 删除分类 +export function deleteCategory(id) { + return request({ + url: `/categories/${id}`, + method: 'delete' + }) +} + +// 预览导入 +export function previewImport(data) { + return request({ + url: '/categories/import', + method: 'post', + data + }) +} + +// 确认导入 +export function confirmImport(data) { + return request({ + url: '/categories/import/confirm', + method: 'post', + params: data // batch_id作为查询参数 + }) +} diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js new file mode 100644 index 0000000..6638cac --- /dev/null +++ b/frontend/src/api/dashboard.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 获取仪表盘汇总数据 +export function getDashboardSummary() { + return request({ + url: '/dashboard/summary', + method: 'get' + }) +} + +// 获取图表数据 +export function getDashboardChart(type, params = {}) { + return request({ + url: '/dashboard/chart', + method: 'get', + params: { type, ...params } + }) +} diff --git a/frontend/src/api/employees.js b/frontend/src/api/employees.js new file mode 100644 index 0000000..63ceb42 --- /dev/null +++ b/frontend/src/api/employees.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 获取员工列表 +export function getEmployeeList(params) { + return request({ + url: '/employees', + method: 'get', + params + }) +} + +// 获取员工详情 +export function getEmployeeDetail(id) { + return request({ + url: `/employees/${id}`, + method: 'get' + }) +} + +// 创建员工 +export function createEmployee(data) { + return request({ + url: '/employees', + method: 'post', + data + }) +} + +// 更新员工 +export function updateEmployee(id, data) { + return request({ + url: `/employees/${id}`, + method: 'put', + data + }) +} + +// 删除员工 +export function deleteEmployee(id) { + return request({ + url: `/employees/${id}`, + method: 'delete' + }) +} + +// 更新员工目标 +export function updateEmployeeTargets(id, data) { + return request({ + url: `/employees/${id}/targets`, + method: 'put', + data + }) +} diff --git a/frontend/src/api/performance.js b/frontend/src/api/performance.js new file mode 100644 index 0000000..18a10ae --- /dev/null +++ b/frontend/src/api/performance.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +// 获取业绩列表 +export function getPerformanceList(params) { + return request({ + url: '/performance', + method: 'get', + params + }) +} + +// 创建业绩 +export function createPerformance(data) { + return request({ + url: '/performance', + method: 'post', + data + }) +} + +// 更新业绩 +export function updatePerformance(id, data) { + return request({ + url: `/performance/${id}`, + method: 'put', + data + }) +} + +// 删除业绩 +export function deletePerformance(id) { + return request({ + url: `/performance/${id}`, + method: 'delete' + }) +} + +// 批量导入业绩 +export function importPerformance(data) { + return request({ + url: '/performance/import', + method: 'post', + data + }) +} diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js new file mode 100644 index 0000000..64e9c19 --- /dev/null +++ b/frontend/src/api/settings.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 获取所有设置 +export function getSettings() { + return request({ + url: '/settings', + method: 'get' + }) +} + +// 获取单个设置 +export function getSetting(key) { + return request({ + url: `/settings/${key}`, + method: 'get' + }) +} + +// 更新设置 +export function updateSetting(key, value) { + return request({ + url: `/settings/${key}`, + method: 'put', + data: { value } + }) +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue new file mode 100644 index 0000000..863efce --- /dev/null +++ b/frontend/src/components/Header.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..3c46c74 --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..8b2cdd4 --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..f96c95e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..cd22679 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,87 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/store/modules/user' +import Layout from '@/components/Layout.vue' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true } + }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + children: [ + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘', icon: 'DataLine' } + }, + { + path: '/employees', + name: 'Employees', + component: () => import('@/views/employees/EmployeeList.vue'), + meta: { title: '员工管理', icon: 'User' } + }, + { + path: '/agents', + name: 'Agents', + component: () => import('@/views/agents/AgentList.vue'), + meta: { title: '二级代理', icon: 'UserFilled' } + }, + { + path: '/categories', + name: 'Categories', + component: () => import('@/views/categories/CategoryList.vue'), + meta: { title: '产品分类', icon: 'Grid' } + }, + { + path: '/performance', + name: 'Performance', + component: () => import('@/views/performance/PerformanceList.vue'), + meta: { title: '业绩录入', icon: 'Edit' } + }, + { + path: '/calculate', + name: 'CalculateEmployee', + component: () => import('@/views/calculate/CalculateEmployee.vue'), + meta: { title: '员工收益计算', icon: 'Calculator' } + }, + { + path: '/calculate/company', + name: 'CalculateCompany', + component: () => import('@/views/calculate/CalculateCompany.vue'), + meta: { title: '公司收益计算', icon: 'OfficeBuilding', adminOnly: true } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/settings/Settings.vue'), + meta: { title: '设置中心', icon: 'Setting' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 - 权限控制 +router.beforeEach((to, from, next) => { + const userStore = useUserStore() + + if (!to.meta.public && !userStore.token) { + next('/login') + } else if (to.path === '/login' && userStore.token) { + next('/') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js new file mode 100644 index 0000000..f00b209 --- /dev/null +++ b/frontend/src/store/index.js @@ -0,0 +1,3 @@ +import { createPinia } from 'pinia' + +export default createPinia() diff --git a/frontend/src/store/modules/app.js b/frontend/src/store/modules/app.js new file mode 100644 index 0000000..5acce48 --- /dev/null +++ b/frontend/src/store/modules/app.js @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useAppStore = defineStore('app', () => { + // State + const sidebarCollapsed = ref(false) + const loading = ref(false) + + // Actions + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + const setLoading = (status) => { + loading.value = status + } + + return { + sidebarCollapsed, + loading, + toggleSidebar, + setLoading + } +}) diff --git a/frontend/src/store/modules/user.js b/frontend/src/store/modules/user.js new file mode 100644 index 0000000..429eadd --- /dev/null +++ b/frontend/src/store/modules/user.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { getToken, setToken, removeToken } from '@/utils/auth' + +export const useUserStore = defineStore('user', () => { + // State + const token = ref(getToken()) + const userInfo = ref(null) + + // Getters + const isLoggedIn = computed(() => !!token.value) + const username = computed(() => userInfo.value?.username || '') + + // Actions + const setTokenValue = (newToken) => { + token.value = newToken + setToken(newToken) + } + + const setUserInfo = (info) => { + userInfo.value = info + } + + const logout = () => { + token.value = null + userInfo.value = null + removeToken() + } + + return { + token, + userInfo, + isLoggedIn, + username, + setTokenValue, + setUserInfo, + logout + } +}) diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js new file mode 100644 index 0000000..bd18b21 --- /dev/null +++ b/frontend/src/utils/auth.js @@ -0,0 +1,17 @@ +const TOKEN_KEY = 'sales_management_token' + +export const getToken = () => { + return localStorage.getItem(TOKEN_KEY) +} + +export const setToken = (token) => { + localStorage.setItem(TOKEN_KEY, token) +} + +export const removeToken = () => { + localStorage.removeItem(TOKEN_KEY) +} + +export const isAuthenticated = () => { + return !!getToken() +} diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..fe6ff2a --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,72 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useUserStore } from '@/store/modules/user' + +// 创建axios实例 +const request = axios.create({ + baseURL: '/api/v1', + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const userStore = useUserStore() + if (userStore.token) { + config.headers.Authorization = `Bearer ${userStore.token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const { data } = response + + // 根据后端返回的状态码处理 + if (data.code !== 200) { + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message)) + } + + return data + }, + (error) => { + const { response } = error + + if (response) { + switch (response.status) { + case 401: + ElMessage.error('登录已过期,请重新登录') + const userStore = useUserStore() + userStore.logout() + window.location.href = '/login' + break + case 403: + ElMessage.error('没有权限访问') + break + case 404: + ElMessage.error('请求的资源不存在') + break + case 500: + ElMessage.error('服务器错误') + break + default: + ElMessage.error(response.data?.message || '网络错误') + } + } else { + ElMessage.error('网络连接失败') + } + + return Promise.reject(error) + } +) + +export default request diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..8a7c833 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..7e8fe3b --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/agents/AgentForm.vue b/frontend/src/views/agents/AgentForm.vue new file mode 100644 index 0000000..c403371 --- /dev/null +++ b/frontend/src/views/agents/AgentForm.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/frontend/src/views/agents/AgentList.vue b/frontend/src/views/agents/AgentList.vue new file mode 100644 index 0000000..5a61b8b --- /dev/null +++ b/frontend/src/views/agents/AgentList.vue @@ -0,0 +1,268 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/calculate/CalculateCompany.vue b/frontend/src/views/calculate/CalculateCompany.vue new file mode 100644 index 0000000..01d4f0d --- /dev/null +++ b/frontend/src/views/calculate/CalculateCompany.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/frontend/src/views/calculate/CalculateEmployee.vue b/frontend/src/views/calculate/CalculateEmployee.vue new file mode 100644 index 0000000..2674e80 --- /dev/null +++ b/frontend/src/views/calculate/CalculateEmployee.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/views/categories/CategoryForm.vue b/frontend/src/views/categories/CategoryForm.vue new file mode 100644 index 0000000..6f2bdd8 --- /dev/null +++ b/frontend/src/views/categories/CategoryForm.vue @@ -0,0 +1,205 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/categories/CategoryImport.vue b/frontend/src/views/categories/CategoryImport.vue new file mode 100644 index 0000000..dd44809 --- /dev/null +++ b/frontend/src/views/categories/CategoryImport.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/frontend/src/views/categories/CategoryList.vue b/frontend/src/views/categories/CategoryList.vue new file mode 100644 index 0000000..60a46e0 --- /dev/null +++ b/frontend/src/views/categories/CategoryList.vue @@ -0,0 +1,289 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeDetail.vue b/frontend/src/views/employees/EmployeeDetail.vue new file mode 100644 index 0000000..9d98f6d --- /dev/null +++ b/frontend/src/views/employees/EmployeeDetail.vue @@ -0,0 +1,51 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeForm.vue b/frontend/src/views/employees/EmployeeForm.vue new file mode 100644 index 0000000..530ff9b --- /dev/null +++ b/frontend/src/views/employees/EmployeeForm.vue @@ -0,0 +1,234 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeList.vue b/frontend/src/views/employees/EmployeeList.vue new file mode 100644 index 0000000..a2b5114 --- /dev/null +++ b/frontend/src/views/employees/EmployeeList.vue @@ -0,0 +1,267 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/performance/PerformanceList.vue b/frontend/src/views/performance/PerformanceList.vue new file mode 100644 index 0000000..1d5b2a2 --- /dev/null +++ b/frontend/src/views/performance/PerformanceList.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue new file mode 100644 index 0000000..9425f9b --- /dev/null +++ b/frontend/src/views/settings/Settings.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..2056e13 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '/api') + } + } + } +})