From f3196f3e87df5bb8c330a77201b04aa7f2aecb55 Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Wed, 4 Mar 2026 00:47:41 +0800 Subject: [PATCH] init --- src/api/auth_routes.py | 193 ++++ src/api/inventory_routes.py | 769 ++++++++++++++++ src/api/routes.py | 21 +- src/main.py | 57 +- src/models/database.py | 230 +++++ src/services/auth_service.py | 145 +++ static/index.html | 24 + static/style.css | 468 ++++++++++ static/vue-app.js | 1672 +++++++++++++++++++++------------- 9 files changed, 2929 insertions(+), 650 deletions(-) create mode 100644 src/api/auth_routes.py create mode 100644 src/api/inventory_routes.py create mode 100644 src/services/auth_service.py create mode 100644 static/index.html diff --git a/src/api/auth_routes.py b/src/api/auth_routes.py new file mode 100644 index 0000000..476d26d --- /dev/null +++ b/src/api/auth_routes.py @@ -0,0 +1,193 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import timedelta + +from database.database import get_db_session +from services.auth_service import ( + authenticate_user, + create_access_token, + create_user, + get_user_by_username, + get_user_by_email, + get_current_active_user, + get_current_admin_user +) +from models.database import User +from config.settings import settings + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + full_name: Optional[str] = None + + +class UserResponse(BaseModel): + id: int + username: str + email: str + full_name: Optional[str] + is_active: bool + is_superuser: bool + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str + user: UserResponse + + +class LoginRequest(BaseModel): + username: str + password: str + + +@router.post("/login", response_model=Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db_session: AsyncSession = Depends(get_db_session) +): + user = await authenticate_user(db_session, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + return Token( + access_token=access_token, + token_type="bearer", + user=UserResponse.from_orm(user) + ) + + +@router.post("/login/json", response_model=Token) +async def login_json( + login_data: LoginRequest, + db_session: AsyncSession = Depends(get_db_session) +): + user = await authenticate_user(db_session, login_data.username, login_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + return Token( + access_token=access_token, + token_type="bearer", + user=UserResponse.from_orm(user) + ) + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register( + user_data: UserCreate, + db_session: AsyncSession = Depends(get_db_session) +): + existing_user = await get_user_by_username(db_session, user_data.username) + if existing_user: + raise HTTPException(status_code=400, detail="用户名已存在") + + existing_email = await get_user_by_email(db_session, user_data.email) + if existing_email: + raise HTTPException(status_code=400, detail="邮箱已被注册") + + user = await create_user( + db_session=db_session, + username=user_data.username, + email=user_data.email, + password=user_data.password, + full_name=user_data.full_name + ) + + return UserResponse.from_orm(user) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info( + current_user: User = Depends(get_current_active_user) +): + return UserResponse.from_orm(current_user) + + +@router.post("/logout") +async def logout(): + return {"message": "已登出"} + + +@router.get("/users", response_model=list[UserResponse]) +async def list_users( + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_current_admin_user) +): + from sqlalchemy import select + result = await db_session.execute(select(User)) + users = result.scalars().all() + return [UserResponse.from_orm(u) for u in users] + + +@router.put("/users/{user_id}/toggle-active", response_model=UserResponse) +async def toggle_user_active( + user_id: int, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_current_admin_user) +): + from sqlalchemy import select + result = await db_session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + if user.id == admin_user.id: + raise HTTPException(status_code=400, detail="不能禁用自己的账户") + + user.is_active = not user.is_active + await db_session.commit() + await db_session.refresh(user) + + return UserResponse.from_orm(user) + + +@router.put("/users/{user_id}/toggle-admin", response_model=UserResponse) +async def toggle_user_admin( + user_id: int, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_current_admin_user) +): + from sqlalchemy import select + result = await db_session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + if user.id == admin_user.id: + raise HTTPException(status_code=400, detail="不能修改自己的管理员权限") + + user.is_superuser = not user.is_superuser + await db_session.commit() + await db_session.refresh(user) + + return UserResponse.from_orm(user) diff --git a/src/api/inventory_routes.py b/src/api/inventory_routes.py new file mode 100644 index 0000000..2b89183 --- /dev/null +++ b/src/api/inventory_routes.py @@ -0,0 +1,769 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_ +from sqlalchemy.orm import selectinload +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +import uuid + +from database.database import get_db_session +from services.auth_service import get_current_active_user, get_current_admin_user +from models.database import ( + User, Product, Supplier, Customer, Warehouse, Inventory, + StockMovement, PurchaseOrder, PurchaseOrderItem, + SalesOrder, SalesOrderItem +) + +router = APIRouter(prefix="/api/inventory", tags=["进销存"]) + + +def generate_order_no(prefix: str) -> str: + date_str = datetime.now().strftime("%Y%m%d%H%M%S") + random_str = uuid.uuid4().hex[:4].upper() + return f"{prefix}{date_str}{random_str}" + + +class ProductCreate(BaseModel): + sku: str + name: str + description: Optional[str] = None + category: Optional[str] = None + unit: str = "件" + cost_price: float = 0 + sale_price: float = 0 + min_stock: int = 0 + max_stock: int = 1000 + + +class ProductResponse(BaseModel): + id: int + sku: str + name: str + description: Optional[str] + category: Optional[str] + unit: str + cost_price: float + sale_price: float + min_stock: int + max_stock: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class SupplierCreate(BaseModel): + code: Optional[str] = None + name: str + contact_person: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + address: Optional[str] = None + + +class SupplierResponse(BaseModel): + id: int + code: Optional[str] + name: str + contact_person: Optional[str] + phone: Optional[str] + email: Optional[str] + is_active: bool + + class Config: + from_attributes = True + + +class CustomerCreate(BaseModel): + code: Optional[str] = None + name: str + contact_person: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + address: Optional[str] = None + + +class CustomerResponse(BaseModel): + id: int + code: Optional[str] + name: str + contact_person: Optional[str] + phone: Optional[str] + email: Optional[str] + is_active: bool + + class Config: + from_attributes = True + + +class WarehouseCreate(BaseModel): + code: Optional[str] = None + name: str + address: Optional[str] = None + manager: Optional[str] = None + phone: Optional[str] = None + + +class WarehouseResponse(BaseModel): + id: int + code: Optional[str] + name: str + address: Optional[str] + manager: Optional[str] + is_active: bool + is_default: bool + + class Config: + from_attributes = True + + +class InventoryResponse(BaseModel): + id: int + product_id: int + product_name: str + product_sku: str + warehouse_id: int + warehouse_name: str + quantity: int + locked_quantity: int + available_quantity: int + + class Config: + from_attributes = True + + +class StockMovementCreate(BaseModel): + product_id: int + warehouse_id: int + movement_type: str + quantity: int + unit_price: Optional[float] = None + remark: Optional[str] = None + + +class StockMovementResponse(BaseModel): + id: int + product_name: str + movement_type: str + quantity: int + before_quantity: int + after_quantity: int + reference_no: Optional[str] + remark: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class PurchaseOrderItemCreate(BaseModel): + product_id: int + quantity: int + unit_price: float + remark: Optional[str] = None + + +class PurchaseOrderCreate(BaseModel): + supplier_id: int + expected_date: Optional[datetime] = None + remark: Optional[str] = None + items: List[PurchaseOrderItemCreate] + + +class PurchaseOrderResponse(BaseModel): + id: int + order_no: str + supplier_name: str + order_date: datetime + expected_date: Optional[datetime] + status: str + total_amount: float + paid_amount: float + remark: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class SalesOrderItemCreate(BaseModel): + product_id: int + quantity: int + unit_price: float + remark: Optional[str] = None + + +class SalesOrderCreate(BaseModel): + customer_id: int + delivery_date: Optional[datetime] = None + remark: Optional[str] = None + items: List[SalesOrderItemCreate] + + +class SalesOrderResponse(BaseModel): + id: int + order_no: str + customer_name: str + order_date: datetime + delivery_date: Optional[datetime] + status: str + total_amount: float + received_amount: float + remark: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +@router.get("/dashboard") +async def get_dashboard( + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + product_count = await db_session.scalar(select(func.count(Product.id)).where(Product.is_active == True)) + supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) + customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) + warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) + + total_stock = await db_session.scalar(select(func.sum(Inventory.quantity))) or 0 + total_value = await db_session.scalar( + select(func.sum(Inventory.quantity * Product.cost_price)) + .join(Product, Inventory.product_id == Product.id) + ) or 0 + + pending_purchase = await db_session.scalar( + select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending") + ) + pending_sales = await db_session.scalar( + select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending") + ) + + low_stock_products = await db_session.execute( + select(Product, Inventory) + .join(Inventory, Product.id == Inventory.product_id) + .where(Inventory.quantity <= Product.min_stock) + .limit(10) + ) + low_stock = [ + {"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock} + for p, i in low_stock_products.all() + ] + + return { + "product_count": product_count, + "supplier_count": supplier_count, + "customer_count": customer_count, + "warehouse_count": warehouse_count, + "total_stock": total_stock, + "total_value": round(total_value, 2), + "pending_purchase": pending_purchase, + "pending_sales": pending_sales, + "low_stock_products": low_stock + } + + +@router.get("/products", response_model=List[ProductResponse]) +async def list_products( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + category: Optional[str] = None, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = select(Product).where(Product.is_active == True) + + if search: + query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%"))) + if category: + query = query.where(Product.category == category) + + query = query.offset(skip).limit(limit).order_by(Product.created_at.desc()) + result = await db_session.execute(query) + products = result.scalars().all() + return [ProductResponse.from_orm(p) for p in products] + + +@router.post("/products", response_model=ProductResponse, status_code=201) +async def create_product( + product_data: ProductCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="SKU已存在") + + product = Product(**product_data.dict()) + db_session.add(product) + await db_session.commit() + await db_session.refresh(product) + return ProductResponse.from_orm(product) + + +@router.put("/products/{product_id}", response_model=ProductResponse) +async def update_product( + product_id: int, + product_data: ProductCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute(select(Product).where(Product.id == product_id)) + product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + + for key, value in product_data.dict().items(): + setattr(product, key, value) + + await db_session.commit() + await db_session.refresh(product) + return ProductResponse.from_orm(product) + + +@router.delete("/products/{product_id}") +async def delete_product( + product_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_admin_user) +): + result = await db_session.execute(select(Product).where(Product.id == product_id)) + product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + + product.is_active = False + await db_session.commit() + return {"message": "产品已删除"} + + +@router.get("/suppliers", response_model=List[SupplierResponse]) +async def list_suppliers( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = select(Supplier).where(Supplier.is_active == True) + if search: + query = query.where(Supplier.name.ilike(f"%{search}%")) + query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc()) + result = await db_session.execute(query) + return [SupplierResponse.from_orm(s) for s in result.scalars().all()] + + +@router.post("/suppliers", response_model=SupplierResponse, status_code=201) +async def create_supplier( + supplier_data: SupplierCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + data = supplier_data.dict() + if not data.get("code"): + data["code"] = f"S{datetime.now().strftime('%Y%m%d%H%M%S')}" + + supplier = Supplier(**data) + db_session.add(supplier) + await db_session.commit() + await db_session.refresh(supplier) + return SupplierResponse.from_orm(supplier) + + +@router.get("/customers", response_model=List[CustomerResponse]) +async def list_customers( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = select(Customer).where(Customer.is_active == True) + if search: + query = query.where(Customer.name.ilike(f"%{search}%")) + query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc()) + result = await db_session.execute(query) + return [CustomerResponse.from_orm(c) for c in result.scalars().all()] + + +@router.post("/customers", response_model=CustomerResponse, status_code=201) +async def create_customer( + customer_data: CustomerCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + data = customer_data.dict() + if not data.get("code"): + data["code"] = f"C{datetime.now().strftime('%Y%m%d%H%M%S')}" + + customer = Customer(**data) + db_session.add(customer) + await db_session.commit() + await db_session.refresh(customer) + return CustomerResponse.from_orm(customer) + + +@router.get("/warehouses", response_model=List[WarehouseResponse]) +async def list_warehouses( + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute( + select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc()) + ) + return [WarehouseResponse.from_orm(w) for w in result.scalars().all()] + + +@router.post("/warehouses", response_model=WarehouseResponse, status_code=201) +async def create_warehouse( + warehouse_data: WarehouseCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + data = warehouse_data.dict() + if not data.get("code"): + data["code"] = f"W{datetime.now().strftime('%Y%m%d%H%M%S')}" + + warehouse = Warehouse(**data) + db_session.add(warehouse) + await db_session.commit() + await db_session.refresh(warehouse) + return WarehouseResponse.from_orm(warehouse) + + +@router.get("/inventory", response_model=List[InventoryResponse]) +async def list_inventory( + warehouse_id: Optional[int] = None, + product_id: Optional[int] = None, + low_stock: bool = False, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = ( + select(Inventory, Product, Warehouse) + .join(Product, Inventory.product_id == Product.id) + .join(Warehouse, Inventory.warehouse_id == Warehouse.id) + .where(Product.is_active == True) + .where(Warehouse.is_active == True) + ) + + if warehouse_id: + query = query.where(Inventory.warehouse_id == warehouse_id) + if product_id: + query = query.where(Inventory.product_id == product_id) + if low_stock: + query = query.where(Inventory.quantity <= Product.min_stock) + + query = query.offset(skip).limit(limit) + result = await db_session.execute(query) + + inventory_list = [] + for inv, product, warehouse in result.all(): + inventory_list.append(InventoryResponse( + id=inv.id, + product_id=inv.product_id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=inv.warehouse_id, + warehouse_name=warehouse.name, + quantity=inv.quantity, + locked_quantity=inv.locked_quantity, + available_quantity=inv.available_quantity + )) + + return inventory_list + + +@router.post("/stock-movements", response_model=StockMovementResponse, status_code=201) +async def create_stock_movement( + movement_data: StockMovementCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if movement_data.movement_type not in ["in", "out", "adjust"]: + raise HTTPException(status_code=400, detail="无效的变动类型") + + result = await db_session.execute( + select(Inventory) + .where(Inventory.product_id == movement_data.product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + ) + inventory = result.scalar_one_or_none() + + if not inventory: + if movement_data.movement_type == "out": + raise HTTPException(status_code=400, detail="库存不足") + inventory = Inventory( + product_id=movement_data.product_id, + warehouse_id=movement_data.warehouse_id, + quantity=0 + ) + db_session.add(inventory) + await db_session.flush() + + before_qty = inventory.quantity + + if movement_data.movement_type == "in": + inventory.quantity += movement_data.quantity + elif movement_data.movement_type == "out": + if inventory.quantity < movement_data.quantity: + raise HTTPException(status_code=400, detail="库存不足") + inventory.quantity -= movement_data.quantity + else: + inventory.quantity = movement_data.quantity + + after_qty = inventory.quantity + + movement = StockMovement( + product_id=movement_data.product_id, + warehouse_id=movement_data.warehouse_id, + movement_type=movement_data.movement_type, + quantity=movement_data.quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_no=generate_order_no("SM"), + unit_price=movement_data.unit_price, + total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None, + remark=movement_data.remark, + operator_id=current_user.id + ) + db_session.add(movement) + await db_session.commit() + + product = await db_session.execute(select(Product).where(Product.id == movement_data.product_id)) + product = product.scalar_one() + + return StockMovementResponse( + id=movement.id, + product_name=product.name, + movement_type=movement.movement_type, + quantity=movement.quantity, + before_quantity=movement.before_quantity, + after_quantity=movement.after_quantity, + reference_no=movement.reference_no, + remark=movement.remark, + created_at=movement.created_at + ) + + +@router.get("/stock-movements", response_model=List[StockMovementResponse]) +async def list_stock_movements( + product_id: Optional[int] = None, + movement_type: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = ( + select(StockMovement, Product) + .join(Product, StockMovement.product_id == Product.id) + .order_by(StockMovement.created_at.desc()) + ) + + if product_id: + query = query.where(StockMovement.product_id == product_id) + if movement_type: + query = query.where(StockMovement.movement_type == movement_type) + + query = query.offset(skip).limit(limit) + result = await db_session.execute(query) + + movements = [] + for movement, product in result.all(): + movements.append(StockMovementResponse( + id=movement.id, + product_name=product.name, + movement_type=movement.movement_type, + quantity=movement.quantity, + before_quantity=movement.before_quantity, + after_quantity=movement.after_quantity, + reference_no=movement.reference_no, + remark=movement.remark, + created_at=movement.created_at + )) + + return movements + + +@router.get("/purchase-orders", response_model=List[PurchaseOrderResponse]) +async def list_purchase_orders( + status: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = ( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .order_by(PurchaseOrder.created_at.desc()) + ) + + if status: + query = query.where(PurchaseOrder.status == status) + + query = query.offset(skip).limit(limit) + result = await db_session.execute(query) + + orders = [] + for order, supplier in result.all(): + orders.append(PurchaseOrderResponse( + id=order.id, + order_no=order.order_no, + supplier_name=supplier.name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at + )) + + return orders + + +@router.post("/purchase-orders", response_model=PurchaseOrderResponse, status_code=201) +async def create_purchase_order( + order_data: PurchaseOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order = PurchaseOrder( + order_no=generate_order_no("PO"), + supplier_id=order_data.supplier_id, + expected_date=order_data.expected_date, + remark=order_data.remark, + operator_id=current_user.id, + status="draft" + ) + db_session.add(order) + await db_session.flush() + + total_amount = 0 + for item_data in order_data.items: + item = PurchaseOrderItem( + order_id=order.id, + product_id=item_data.product_id, + quantity=item_data.quantity, + unit_price=item_data.unit_price, + amount=item_data.quantity * item_data.unit_price, + remark=item_data.remark + ) + db_session.add(item) + total_amount += item.amount + + order.total_amount = total_amount + await db_session.commit() + await db_session.refresh(order) + + supplier = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) + supplier = supplier.scalar_one() + + return PurchaseOrderResponse( + id=order.id, + order_no=order.order_no, + supplier_name=supplier.name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at + ) + + +@router.get("/sales-orders", response_model=List[SalesOrderResponse]) +async def list_sales_orders( + status: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = ( + select(SalesOrder, Customer) + .join(Customer, SalesOrder.customer_id == Customer.id) + .order_by(SalesOrder.created_at.desc()) + ) + + if status: + query = query.where(SalesOrder.status == status) + + query = query.offset(skip).limit(limit) + result = await db_session.execute(query) + + orders = [] + for order, customer in result.all(): + orders.append(SalesOrderResponse( + id=order.id, + order_no=order.order_no, + customer_name=customer.name, + order_date=order.order_date, + delivery_date=order.delivery_date, + status=order.status, + total_amount=order.total_amount, + received_amount=order.received_amount, + remark=order.remark, + created_at=order.created_at + )) + + return orders + + +@router.post("/sales-orders", response_model=SalesOrderResponse, status_code=201) +async def create_sales_order( + order_data: SalesOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order = SalesOrder( + order_no=generate_order_no("SO"), + customer_id=order_data.customer_id, + delivery_date=order_data.delivery_date, + remark=order_data.remark, + operator_id=current_user.id, + status="draft" + ) + db_session.add(order) + await db_session.flush() + + total_amount = 0 + for item_data in order_data.items: + item = SalesOrderItem( + order_id=order.id, + product_id=item_data.product_id, + quantity=item_data.quantity, + unit_price=item_data.unit_price, + amount=item_data.quantity * item_data.unit_price, + remark=item_data.remark + ) + db_session.add(item) + total_amount += item.amount + + order.total_amount = total_amount + await db_session.commit() + await db_session.refresh(order) + + customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) + customer = customer.scalar_one() + + return SalesOrderResponse( + id=order.id, + order_no=order.order_no, + customer_name=customer.name, + order_date=order.order_date, + delivery_date=order.delivery_date, + status=order.status, + total_amount=order.total_amount, + received_amount=order.received_amount, + remark=order.remark, + created_at=order.created_at + ) diff --git a/src/api/routes.py b/src/api/routes.py index fe9ee86..3bdeceb 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -21,35 +21,16 @@ logger = get_logger(__name__) router = APIRouter() -# 服务实例 stp_parser = STPParser() geometry_analyzer = GeometryAnalyzer() file_handler = FileHandler() html_generator = HTMLGenerator() -# 初始化模具生成器(可配置不同材料的收缩率) -mold_generator = MoldCavityGenerator(shrinkage_rate=0.005) # ABS材料 +mold_generator = MoldCavityGenerator(shrinkage_rate=0.005) mesh_generator = MeshGenerator(quality="medium") -# 内存中的任务存储 tasks = {} -@router.get("/") -@router.post("/") -async def read_root(request: Request): - """主页面""" - from fastapi.templating import Jinja2Templates - import os - # 简化路径配置,直接使用当前工作目录下的templates文件夹 - templates_dir = os.path.join(os.getcwd(), "templates") - templates = Jinja2Templates(directory=templates_dir) - return templates.TemplateResponse("index.html", { - "request": request, - "pythonocc_available": True, - "version": "3.0.0" - }) - - @router.get("/health") @router.post("/health") async def health(): diff --git a/src/main.py b/src/main.py index 97b7753..0778dae 100644 --- a/src/main.py +++ b/src/main.py @@ -40,17 +40,17 @@ from fastapi.templating import Jinja2Templates import asyncio from api.routes import router +from api.auth_routes import router as auth_router +from api.inventory_routes import router as inventory_router from utils.logger import setup_logging from database.init_db import init_database -# 设置日志 setup_logging() -# 创建FastAPI应用 app = FastAPI( - title="模具几何分析服务", - description="基于PythonOCC的STP文件几何分析和模具设计建议服务", - version="3.0.0" + title="Gemold - 模具制造管理系统", + description="模具制造行业综合管理平台,包含模具分析、进销存管理等功能", + version="4.0.0" ) # 启动时初始化数据库和RustFS @@ -96,7 +96,8 @@ import os static_dir = os.path.join(os.getcwd(), "static") app.mount("/static", StaticFiles(directory=static_dir), name="static") -# 注册路由 +app.include_router(auth_router) +app.include_router(inventory_router) app.include_router(router) @@ -106,25 +107,47 @@ async def health(): from database.database import db_manager return { "status": "healthy", - "service": "mold-geometry-analysis", + "service": "gemold", + "version": "4.0.0", "database_connected": db_manager.is_connected } +@app.get("/") +async def root(): + from fastapi.responses import FileResponse + return FileResponse(os.path.join(os.getcwd(), "static", "index.html")) + + +@app.get("/moldinsight") +async def moldinsight(): + from fastapi.responses import FileResponse + return FileResponse(os.path.join(os.getcwd(), "static", "index.html")) + + +@app.get("/inventory") +async def inventory(): + from fastapi.responses import FileResponse + return FileResponse(os.path.join(os.getcwd(), "static", "index.html")) + + +@app.get("/users") +async def users(): + from fastapi.responses import FileResponse + return FileResponse(os.path.join(os.getcwd(), "static", "index.html")) + + if __name__ == "__main__": import uvicorn - - # 从配置文件获取端口配置 from config.settings import settings - print("启动模具几何分析服务 v3.0...") - print(f"访问 http://localhost:{settings.PORT} 使用网页界面") - print("新增功能:") - print(" - STP文件解析为JSON数据") - print(" - 数据存储到PostgreSQL数据库") - print(" - 自动生成3D可视化HTML页面") - print(" - 源文件、JSON数据、HTML文件统一管理") - print(f"调试接口: http://localhost:{settings.PORT}/debug/tasks") + print("启动 Gemold 模具制造管理系统 v4.0...") + print(f"访问 http://localhost:{settings.PORT}") + print("功能模块:") + print(" - 首页仪表盘") + print(" - 用户管理") + print(" - MoldInsight 模具分析") + print(" - 进销存管理") uvicorn.run( "main:app", diff --git a/src/models/database.py b/src/models/database.py index 5a2c8a4..5b43895 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -362,3 +362,233 @@ class SystemLog(Base): def __repr__(self): return f"" + +class Product(Base): + """产品表""" + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + sku = Column(String(50), unique=True, index=True, nullable=False) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + category = Column(String(100), nullable=True) + unit = Column(String(20), default="件") + cost_price = Column(Float, default=0) + sale_price = Column(Float, default=0) + min_stock = Column(Integer, default=0) + max_stock = Column(Integer, default=1000) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + inventory = relationship("Inventory", back_populates="product", uselist=False) + stock_movements = relationship("StockMovement", back_populates="product") + + def __repr__(self): + return f"" + + +class Supplier(Base): + """供应商表""" + __tablename__ = "suppliers" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(50), unique=True, index=True) + name = Column(String(200), nullable=False) + contact_person = Column(String(100), nullable=True) + phone = Column(String(50), nullable=True) + email = Column(String(100), nullable=True) + address = Column(Text, nullable=True) + bank_name = Column(String(100), nullable=True) + bank_account = Column(String(50), nullable=True) + tax_number = Column(String(50), nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + purchase_orders = relationship("PurchaseOrder", back_populates="supplier") + + def __repr__(self): + return f"" + + +class Customer(Base): + """客户表""" + __tablename__ = "customers" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(50), unique=True, index=True) + name = Column(String(200), nullable=False) + contact_person = Column(String(100), nullable=True) + phone = Column(String(50), nullable=True) + email = Column(String(100), nullable=True) + address = Column(Text, nullable=True) + bank_name = Column(String(100), nullable=True) + bank_account = Column(String(50), nullable=True) + tax_number = Column(String(50), nullable=True) + credit_limit = Column(Float, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + sales_orders = relationship("SalesOrder", back_populates="customer") + + def __repr__(self): + return f"" + + +class Warehouse(Base): + """仓库表""" + __tablename__ = "warehouses" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(50), unique=True, index=True) + name = Column(String(200), nullable=False) + address = Column(Text, nullable=True) + manager = Column(String(100), nullable=True) + phone = Column(String(50), nullable=True) + is_active = Column(Boolean, default=True) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now()) + + inventories = relationship("Inventory", back_populates="warehouse") + + def __repr__(self): + return f"" + + +class Inventory(Base): + """库存表""" + __tablename__ = "inventory" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True) + warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True) + quantity = Column(Integer, default=0) + locked_quantity = Column(Integer, default=0) + batch_number = Column(String(50), nullable=True) + location = Column(String(100), nullable=True) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + product = relationship("Product", back_populates="inventory") + warehouse = relationship("Warehouse", back_populates="inventories") + + def __repr__(self): + return f"" + + @property + def available_quantity(self): + return self.quantity - self.locked_quantity + + +class StockMovement(Base): + """库存变动记录表""" + __tablename__ = "stock_movements" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True) + warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False) + movement_type = Column(String(20), nullable=False) + quantity = Column(Integer, nullable=False) + before_quantity = Column(Integer, default=0) + after_quantity = Column(Integer, default=0) + reference_type = Column(String(50), nullable=True) + reference_id = Column(Integer, nullable=True) + reference_no = Column(String(50), nullable=True) + unit_price = Column(Float, nullable=True) + total_amount = Column(Float, nullable=True) + remark = Column(Text, nullable=True) + operator_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now(), index=True) + + product = relationship("Product", back_populates="stock_movements") + + def __repr__(self): + return f"" + + +class PurchaseOrder(Base): + """采购订单表""" + __tablename__ = "purchase_orders" + + id = Column(Integer, primary_key=True, index=True) + order_no = Column(String(50), unique=True, index=True, nullable=False) + supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True) + order_date = Column(DateTime, default=func.now()) + expected_date = Column(DateTime, nullable=True) + status = Column(String(20), default="draft") + total_amount = Column(Float, default=0) + paid_amount = Column(Float, default=0) + remark = Column(Text, nullable=True) + operator_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + supplier = relationship("Supplier", back_populates="purchase_orders") + items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class PurchaseOrderItem(Base): + """采购订单明细表""" + __tablename__ = "purchase_order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False) + received_quantity = Column(Integer, default=0) + unit_price = Column(Float, nullable=False) + amount = Column(Float, nullable=False) + remark = Column(Text, nullable=True) + + order = relationship("PurchaseOrder", back_populates="items") + + def __repr__(self): + return f"" + + +class SalesOrder(Base): + """销售订单表""" + __tablename__ = "sales_orders" + + id = Column(Integer, primary_key=True, index=True) + order_no = Column(String(50), unique=True, index=True, nullable=False) + customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True) + order_date = Column(DateTime, default=func.now()) + delivery_date = Column(DateTime, nullable=True) + status = Column(String(20), default="draft") + total_amount = Column(Float, default=0) + received_amount = Column(Float, default=0) + remark = Column(Text, nullable=True) + operator_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + customer = relationship("Customer", back_populates="sales_orders") + items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class SalesOrderItem(Base): + """销售订单明细表""" + __tablename__ = "sales_order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False) + delivered_quantity = Column(Integer, default=0) + unit_price = Column(Float, nullable=False) + amount = Column(Float, nullable=False) + remark = Column(Text, nullable=True) + + order = relationship("SalesOrder", back_populates="items") + + def __repr__(self): + return f"" + diff --git a/src/services/auth_service.py b/src/services/auth_service.py new file mode 100644 index 0000000..59eaa61 --- /dev/null +++ b/src/services/auth_service.py @@ -0,0 +1,145 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from config.settings import settings +from database.database import get_db_session +from models.database import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + + +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: + 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 + + +async def get_current_user( + token: Optional[str] = Depends(oauth2_scheme), + db_session: AsyncSession = Depends(get_db_session) +) -> Optional[User]: + if not token: + return None + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无法验证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db_session.execute( + select(User).where(User.username == username) + ) + user = result.scalar_one_or_none() + + if user is None: + raise credentials_exception + if not user.is_active: + raise HTTPException(status_code=400, detail="用户已被禁用") + + return user + + +async def get_current_active_user( + current_user: Optional[User] = Depends(get_current_user) +) -> User: + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + return current_user + + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + return current_user + + +async def authenticate_user(db_session: AsyncSession, username: str, password: str) -> Optional[User]: + result = await db_session.execute( + select(User).where(User.username == username) + ) + user = result.scalar_one_or_none() + + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + + user.last_login = datetime.utcnow() + await db_session.commit() + + return user + + +async def create_user( + db_session: AsyncSession, + username: str, + email: str, + password: str, + full_name: Optional[str] = None, + is_superuser: bool = False +) -> User: + hashed_password = get_password_hash(password) + user = User( + username=username, + email=email, + hashed_password=hashed_password, + full_name=full_name, + is_superuser=is_superuser, + is_active=True + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +async def get_user_by_username(db_session: AsyncSession, username: str) -> Optional[User]: + result = await db_session.execute( + select(User).where(User.username == username) + ) + return result.scalar_one_or_none() + + +async def get_user_by_email(db_session: AsyncSession, email: str) -> Optional[User]: + result = await db_session.execute( + select(User).where(User.email == email) + ) + return result.scalar_one_or_none() diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..c44aa84 --- /dev/null +++ b/static/index.html @@ -0,0 +1,24 @@ + + + + + + Gemold - 模具制造管理系统 + + + + + + +
+
+
+ 加载中... +
+
+ + + + + + diff --git a/static/style.css b/static/style.css index 104e2cd..0a62696 100644 --- a/static/style.css +++ b/static/style.css @@ -1307,3 +1307,471 @@ a:hover { white-space: nowrap; border: 0; } + +.badge-primary { + background: var(--primary-100); + color: var(--primary-700); +} + +.text-warning { + color: var(--warning); +} + +.user-section { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.user-info { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.user-name { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); +} + +.admin-badge { + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + background: var(--primary-100); + color: var(--primary-700); + border-radius: var(--radius-sm); +} + +.btn-logout { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.btn-logout:hover { + background: var(--gray-100); + color: var(--text-primary); +} + +.btn-login { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: white; + background: var(--gradient-primary); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.btn-login:hover { + opacity: 0.9; +} + +.auth-page { + min-height: calc(100vh - 200px); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-8); +} + +.auth-card { + width: 100%; + max-width: 400px; + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + padding: var(--space-8); +} + +.auth-header { + text-align: center; + margin-bottom: var(--space-8); +} + +.auth-logo { + width: 60px; + height: 60px; + background: var(--gradient-primary); + border-radius: var(--radius-xl); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: white; + margin: 0 auto var(--space-4); + box-shadow: var(--shadow-lg); +} + +.auth-header h1 { + font-size: var(--text-2xl); + margin-bottom: var(--space-2); +} + +.auth-header p { + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +.auth-form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.form-group label { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-secondary); +} + +.form-group input { + padding: var(--space-3) var(--space-4); + font-size: var(--text-base); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + transition: all var(--duration-fast) var(--ease-default); +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-500); + box-shadow: 0 0 0 3px var(--primary-100); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.error-message { + padding: var(--space-3); + background: var(--error-bg); + color: var(--error); + font-size: var(--text-sm); + border-radius: var(--radius-md); + text-align: center; +} + +.btn-full { + width: 100%; +} + +.auth-footer { + margin-top: var(--space-6); + text-align: center; + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +.auth-footer a { + color: var(--primary-600); + font-weight: var(--font-medium); + cursor: pointer; +} + +.auth-footer a:hover { + color: var(--primary-700); +} + +.page-container { + max-width: 1200px; + margin: 0 auto; +} + +.page-header { + margin-bottom: var(--space-6); +} + +.page-header h1 { + font-size: var(--text-3xl); + margin-bottom: var(--space-2); +} + +.page-header p { + font-size: var(--text-base); + color: var(--text-tertiary); +} + +.section { + margin-top: var(--space-8); +} + +.section-title { + font-size: var(--text-xl); + margin-bottom: var(--space-4); +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12); + gap: var(--space-4); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--gray-200); + border-top-color: var(--primary-500); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.error-state { + text-align: center; + padding: var(--space-12); + color: var(--error); +} + +.tabs { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-6); + border-bottom: 1px solid var(--border-light); + padding-bottom: var(--space-2); + overflow-x: auto; +} + +.tab { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-tertiary); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + white-space: nowrap; +} + +.tab:hover { + color: var(--text-primary); +} + +.tab.active { + color: var(--primary-600); + border-bottom-color: var(--primary-600); +} + +.table-container { + background: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.action-buttons { + display: flex; + gap: var(--space-2); +} + +.btn-sm { + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--font-medium); + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover { + background: var(--accent-600); +} + +.btn-warning { + background: var(--warning-bg); + color: var(--warning); +} + +.btn-warning:hover { + background: var(--warning); + color: white; +} + +.btn-back { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-secondary); + background: transparent; + border: none; + cursor: pointer; + margin-bottom: var(--space-4); +} + +.btn-back:hover { + color: var(--text-primary); +} + +.result-container { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.result-header { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.result-header h2 { + font-size: var(--text-2xl); +} + +.result-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-6); +} + +.result-card { + background: var(--bg-primary); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.result-card h3 { + font-size: var(--text-lg); + margin-bottom: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--border-light); +} + +.info-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.info-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.info-label { + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +.info-value { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); +} + +.viewer-section { + background: var(--bg-primary); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.viewer-section h3 { + font-size: var(--text-lg); + margin-bottom: var(--space-4); +} + +.viewer-frame { + width: 100%; + height: 500px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.quick-actions { + margin-top: var(--space-8); +} + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-4); +} + +.action-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-6); + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); +} + +.action-card:hover { + border-color: var(--primary-300); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.action-icon { + font-size: 1.5rem; +} + +.action-label { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-secondary); +} + +.nav-icon { + margin-right: var(--space-2); +} + +.upload-section { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin-bottom: var(--space-8); +} + +.file-info { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + background: var(--bg-primary); + border-radius: var(--radius-md); + border: 1px solid var(--border-light); +} + +.file-name { + font-weight: var(--font-medium); + color: var(--text-primary); +} + +.file-size { + font-size: var(--text-sm); + color: var(--text-tertiary); +} diff --git a/static/vue-app.js b/static/vue-app.js index 76b43c2..8928c27 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1,17 +1,17 @@ /** - * STP模具几何分析中心 - Vue3单页应用 - * 版本: 5.0.0 - Modern Minimal UI - * 设计灵感: Linear, Notion, Vercel, Stripe + * Gemold - 模具制造管理系统 + * 版本: 4.0.0 */ const { createApp, ref, computed, onMounted, reactive, watch, nextTick } = Vue; const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter; const appState = reactive({ - health: null, + user: null, + token: null, loading: false, notifications: [], - theme: 'modern' + initialized: false }); function formatFileSize(bytes) { @@ -38,37 +38,25 @@ function formatDateTime(dateString) { } } -function statusText(status) { - const map = { - pending: "排队中", - processing: "处理中", - completed: "已完成", - failed: "失败", - }; - return map[status] || status || "未知"; +function formatDate(dateString) { + if (!dateString) return "N/A"; + try { + return new Date(dateString).toLocaleDateString('zh-CN'); + } catch { + return dateString; + } } -function getStatusClass(status) { - const classMap = { - pending: "badge-warning", - processing: "badge-info", - completed: "badge-success", - failed: "badge-error" - }; - return classMap[status] || "badge-neutral"; +function formatCurrency(amount) { + if (amount === null || amount === undefined) return "¥0.00"; + return "¥" + Number(amount).toFixed(2); } let notificationId = 0; function addNotification(message, type = 'info') { const id = ++notificationId; - const notification = { - id, - message, - type, - timestamp: new Date(), - visible: true - }; + const notification = { id, message, type, timestamp: new Date(), visible: true }; appState.notifications.push(notification); setTimeout(() => { @@ -77,9 +65,7 @@ function addNotification(message, type = 'info') { appState.notifications[index].visible = false; setTimeout(() => { const idx = appState.notifications.findIndex(n => n.id === id); - if (idx > -1) { - appState.notifications.splice(idx, 1); - } + if (idx > -1) appState.notifications.splice(idx, 1); }, 300); } }, 5000); @@ -92,51 +78,117 @@ function handleApiError(error, context = '') { return message; } +async function apiRequest(url, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (appState.token) { + headers['Authorization'] = `Bearer ${appState.token}`; + } + + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401) { + appState.user = null; + appState.token = null; + localStorage.removeItem('token'); + localStorage.removeItem('user'); + throw new Error('登录已过期,请重新登录'); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '请求失败' })); + throw new Error(error.detail || '请求失败'); + } + + return response.json(); +} + +function saveAuth(token, user) { + appState.token = token; + appState.user = user; + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(user)); +} + +function clearAuth() { + appState.token = null; + appState.user = null; + localStorage.removeItem('token'); + localStorage.removeItem('user'); +} + +function initAuth() { + const token = localStorage.getItem('token'); + const userStr = localStorage.getItem('user'); + + if (token && userStr) { + try { + appState.token = token; + appState.user = JSON.parse(userStr); + } catch { + clearAuth(); + } + } + appState.initialized = true; +} + const App = { setup() { const route = useRoute(); const router = useRouter(); - const isActive = (pathPrefix) => - computed(() => route.path === pathPrefix || route.path.startsWith(pathPrefix)); - - const loadHealth = async () => { - try { - const res = await fetch("/health", { method: "POST" }); - if (res.ok) { - appState.health = await res.json(); - } - } catch (error) { - appState.health = null; + const menuItems = computed(() => { + const items = [ + { path: '/', label: '首页', icon: '⌂' }, + { path: '/moldinsight', label: 'MoldInsight', icon: '◈' }, + { path: '/inventory', label: '进销存', icon: '⊞' } + ]; + + if (appState.user?.is_superuser) { + items.push({ path: '/users', label: '用户管理', icon: '👤' }); } + + return items; + }); + + const isActive = (path) => { + if (path === '/') return route.path === '/'; + return route.path.startsWith(path); }; - const dismissNotification = (id) => { - const index = appState.notifications.findIndex(n => n.id === id); - if (index > -1) { - appState.notifications[index].visible = false; - setTimeout(() => { - const idx = appState.notifications.findIndex(n => n.id === id); - if (idx > -1) { - appState.notifications.splice(idx, 1); - } - }, 300); - } + const handleLogout = async () => { + try { + await apiRequest('/api/auth/logout', { method: 'POST' }); + } catch {} + clearAuth(); + addNotification('已退出登录', 'success'); + router.push('/login'); }; onMounted(() => { - loadHealth(); - setInterval(loadHealth, 30000); + initAuth(); }); return { route, router, appState, + menuItems, isActive, - dismissNotification, - getStatusClass, - statusText + handleLogout, + dismissNotification: (id) => { + const index = appState.notifications.findIndex(n => n.id === id); + if (index > -1) { + appState.notifications[index].visible = false; + setTimeout(() => { + const idx = appState.notifications.findIndex(n => n.id === id); + if (idx > -1) appState.notifications.splice(idx, 1); + }, 300); + } + } }; }, template: ` @@ -161,28 +213,37 @@ const App = {
-
@@ -197,48 +258,447 @@ const App = {
`, }; -const DashboardView = { +const LoginView = { setup() { const router = useRouter(); + const state = reactive({ + isLogin: true, + username: '', + password: '', + email: '', + full_name: '', + loading: false, + error: '' + }); + onMounted(() => { + if (appState.user) { + router.push('/'); + } + }); + + const handleSubmit = async () => { + if (!state.username || !state.password) { + state.error = '请填写用户名和密码'; + return; + } + + state.loading = true; + state.error = ''; + + try { + if (state.isLogin) { + const res = await fetch('/api/auth/login/json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: state.username, + password: state.password + }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || '登录失败'); + } + + const data = await res.json(); + saveAuth(data.access_token, data.user); + addNotification('登录成功', 'success'); + router.push('/'); + } else { + if (!state.email) { + state.error = '请填写邮箱'; + state.loading = false; + return; + } + + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: state.username, + password: state.password, + email: state.email, + full_name: state.full_name || null + }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || '注册失败'); + } + + addNotification('注册成功,请登录', 'success'); + state.isLogin = true; + } + } catch (e) { + state.error = e.message; + addNotification(e.message, 'error'); + } finally { + state.loading = false; + } + }; + + return { state, handleSubmit }; + }, + template: ` +
+
+
+ +

{{ state.isLogin ? '登录' : '注册' }}

+

{{ state.isLogin ? '登录到 Gemold 系统' : '创建新账户' }}

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
{{ state.error }}
+ + +
+ + +
+
+ ` +}; + +const HomeView = { + setup() { + const router = useRouter(); + const state = reactive({ + stats: null, + loading: true + }); + + const loadStats = async () => { + try { + const [inventoryStats, health] = await Promise.all([ + apiRequest('/api/inventory/dashboard').catch(() => null), + apiRequest('/health').catch(() => null) + ]); + state.stats = { inventory: inventoryStats, health }; + } catch (e) { + handleApiError(e, '加载统计数据'); + } finally { + state.loading = false; + } + }; + + onMounted(() => { + if (!appState.user) { + router.push('/login'); + return; + } + loadStats(); + }); + + return { state, formatNumber, formatCurrency, appState }; + }, + template: ` +
+ + +
+
+ 加载中... +
+ +
+
+
📦
+
+
{{ state.stats?.inventory?.product_count || 0 }}
+
产品数量
+
+
+ +
+
📊
+
+
{{ state.stats?.inventory?.total_stock || 0 }}
+
库存总量
+
+
+ +
+
💰
+
+
{{ formatCurrency(state.stats?.inventory?.total_value || 0) }}
+
库存价值
+
+
+ +
+
◈
+
+
{{ state.stats?.health?.total_tasks || 0 }}
+
分析任务
+
+
+ +
+
🏭
+
+
{{ state.stats?.inventory?.supplier_count || 0 }}
+
供应商
+
+
+ +
+
👥
+
+
{{ state.stats?.inventory?.customer_count || 0 }}
+
客户
+
+
+
+ +
+

低库存预警

+
+ + + + + + + + + + + + + + + + + +
SKU产品名称当前库存最低库存
{{ item.sku }}{{ item.name }}{{ item.quantity }}{{ item.min_stock }}
+
+
+ +
+

快速操作

+
+ + + + +
+
+
+ ` +}; + +const UsersView = { + setup() { + const router = useRouter(); + const state = reactive({ + users: [], + loading: true + }); + + const loadUsers = async () => { + try { + state.users = await apiRequest('/api/auth/users'); + } catch (e) { + handleApiError(e, '加载用户列表'); + } finally { + state.loading = false; + } + }; + + const toggleActive = async (user) => { + try { + const updated = await apiRequest(`/api/auth/users/${user.id}/toggle-active`, { method: 'PUT' }); + const index = state.users.findIndex(u => u.id === user.id); + if (index > -1) state.users[index] = updated; + addNotification(`用户 ${user.username} 已${updated.is_active ? '启用' : '禁用'}`, 'success'); + } catch (e) { + handleApiError(e, '切换用户状态'); + } + }; + + const toggleAdmin = async (user) => { + try { + const updated = await apiRequest(`/api/auth/users/${user.id}/toggle-admin`, { method: 'PUT' }); + const index = state.users.findIndex(u => u.id === user.id); + if (index > -1) state.users[index] = updated; + addNotification(`用户 ${user.username} ${updated.is_superuser ? '已设为管理员' : '已取消管理员'}`, 'success'); + } catch (e) { + handleApiError(e, '切换管理员权限'); + } + }; + + onMounted(() => { + if (!appState.user?.is_superuser) { + router.push('/'); + return; + } + loadUsers(); + }); + + return { state, appState, toggleActive, toggleAdmin, formatDateTime }; + }, + template: ` +
+ + +
+
+ 加载中... +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
用户名邮箱姓名状态角色注册时间操作
{{ user.username }}{{ user.email }}{{ user.full_name || '-' }} + + {{ user.is_active ? '正常' : '禁用' }} + + + + {{ user.is_superuser ? '管理员' : '普通用户' }} + + {{ formatDateTime(user.created_at) }} +
+ + +
+
+
+
+ ` +}; + +const MoldInsightView = { + setup() { + const router = useRouter(); const state = reactive({ selectedFile: null, uploading: false, error: "", currentTask: null, polling: false, - historySummary: null, dragOver: false, - progress: 0 + progress: 0, + history: null }); - const totalFiles = computed(() => state.historySummary?.total_files || 0); - const totalRecords = computed(() => - (state.historySummary?.files || []).reduce( - (sum, f) => sum + (f.record_count || 0), - 0 - ) - ); - const latestFile = computed(() => - (state.historySummary?.files || [])[0] || null - ); + const loadHistory = async () => { + try { + state.history = await apiRequest('/history'); + } catch (e) { + console.error('加载历史记录失败:', e); + } + }; const handleFileChange = (event) => { const file = event.target.files[0]; @@ -247,8 +707,7 @@ const DashboardView = { }; const validateAndSelectFile = (file) => { - if (!file.name.toLowerCase().endsWith(".stp") && - !file.name.toLowerCase().endsWith(".step")) { + if (!file.name.toLowerCase().endsWith(".stp") && !file.name.toLowerCase().endsWith(".step")) { state.error = "请选择 STP 或 STEP 格式文件"; state.selectedFile = null; return; @@ -258,29 +717,16 @@ const DashboardView = { state.selectedFile = null; return; } - state.error = ""; state.selectedFile = file; addNotification(`已选择文件: ${file.name}`, 'success'); }; - const handleDragOver = (event) => { - event.preventDefault(); - state.dragOver = true; - }; - - const handleDragLeave = (event) => { - event.preventDefault(); - state.dragOver = false; - }; - const handleDrop = (event) => { event.preventDefault(); state.dragOver = false; const files = event.dataTransfer.files; - if (files.length > 0) { - validateAndSelectFile(files[0]); - } + if (files.length > 0) validateAndSelectFile(files[0]); }; const uploadFile = async () => { @@ -295,23 +741,16 @@ const DashboardView = { try { const res = await fetch("/upload", { method: "POST", - body: formData, + headers: appState.token ? { 'Authorization': `Bearer ${appState.token}` } : {}, + body: formData }); - if (!res.ok) { - throw new Error(`上传失败: ${res.status} ${res.statusText}`); - } + if (!res.ok) throw new Error(`上传失败: ${res.status}`); const data = await res.json(); - state.currentTask = { - task_id: data.task_id, - status: "processing", - filename: data.file_info?.filename, - file_size: data.file_info?.size, - }; - addNotification(`文件上传成功,开始分析...`, 'success'); + state.currentTask = { task_id: data.task_id, status: "processing", filename: data.file_info?.filename }; + addNotification('文件上传成功,开始分析...', 'success'); startPolling(data.task_id); } catch (e) { - const errorMsg = handleApiError(e, '文件上传'); - state.error = errorMsg; + state.error = handleApiError(e, '文件上传'); } finally { state.uploading = false; } @@ -321,565 +760,572 @@ const DashboardView = { state.polling = true; state.progress = 10; let pollCount = 0; - const maxPolls = 300; const poll = async () => { try { pollCount++; state.progress = Math.min(90, 10 + pollCount * 0.5); - const res = await fetch(`/status/${taskId}`, { method: "POST" }); - - if (res.status === 404) { - state.polling = false; - state.error = "任务不存在或已被删除"; - addNotification("任务不存在或已被删除", 'error'); - return; - } - - if (!res.ok) { - throw new Error(`查询任务状态失败: ${res.status} ${res.statusText}`); - } - - const task = await res.json(); + const task = await apiRequest(`/status/${taskId}`, { method: 'POST' }); state.currentTask = task; if (task.status === "completed") { state.polling = false; state.progress = 100; - addNotification(`分析完成,正在跳转...`, 'success'); - setTimeout(() => { - router.push(`/result/${task.task_id}`); - }, 1000); - } else if (task.status === "failed") { - state.polling = false; - state.progress = 0; - const errorMsg = task.error || "未知错误"; - state.error = `分析失败: ${errorMsg}`; - addNotification(`分析失败: ${errorMsg}`, 'error'); - } else if (pollCount >= maxPolls) { - state.polling = false; - state.progress = 0; - state.error = "任务处理超时,请稍后查看结果"; - addNotification("任务处理超时", 'warning'); - } else { - setTimeout(poll, 1000); + addNotification('分析完成', 'success'); + router.push(`/moldinsight/result/${taskId}`); + return; } + + if (task.status === "failed") { + state.polling = false; + state.error = task.error || "分析失败"; + addNotification('分析失败', 'error'); + return; + } + + if (pollCount < 300) setTimeout(poll, 2000); } catch (e) { state.polling = false; - state.progress = 0; - const errorMsg = handleApiError(e, '任务轮询'); - state.error = errorMsg; + state.error = handleApiError(e, '轮询状态'); } }; + poll(); }; - const loadHistorySummary = async () => { - try { - const res = await fetch("/api/history", { method: "POST" }); - if (res.ok) { - state.historySummary = await res.json(); - } - } catch (e) { - state.historySummary = null; - } - }; - - const clearFile = () => { - state.selectedFile = null; - state.error = ""; - }; - onMounted(() => { - loadHistorySummary(); + if (!appState.user) { + router.push('/login'); + return; + } + loadHistory(); }); - return { - state, - totalFiles, - totalRecords, - latestFile, - handleFileChange, - handleDragOver, - handleDragLeave, - handleDrop, - uploadFile, - clearFile, - formatFileSize, - statusText, - formatDateTime, - getStatusClass + return { + state, + handleFileChange, + handleDrop, + uploadFile, + formatFileSize, + formatDateTime }; }, template: ` -
-
-
-
📁
-
{{ totalFiles }}
-
已分析文件
-
-
-
📊
-
{{ totalRecords }}
-
处理记录
-
-
-
⚡
-
{{ state.polling ? '...' : '就绪' }}
-
系统状态
-
-
-
🎯
-
{{ latestFile ? '1' : '0' }}
-
最近文件
-
+
+ - -
-
-
-
-
📤
- 上传文件 -
-
-
- - -
- - -
- -

{{ state.error }}

- -
-
- 当前任务 - - {{ statusText(state.currentTask.status) }} - -
-
-
-
-
{{ state.currentTask.filename }}
-
-
-
- -
-
-
-
📊
- 项目总览 -
-
-
-
-
📄
-
-
{{ latestFile.filename }}
-
- {{ latestFile.record_count }} 条记录 - {{ formatDateTime(latestFile.last_upload) }} -
-
-
- -
-
📁
-
暂无历史记录
-
上传一个 STP 文件开始分析
-
- - - 查看历史记录 - -
-
-
-
- `, -}; - -const HistoryView = { - setup() { - const router = useRouter(); - const files = ref([]); - const loading = ref(true); - const error = ref(""); - const expanded = ref({}); - - const loadHistory = async () => { - loading.value = true; - error.value = ""; - try { - const res = await fetch("/api/history", { method: "POST" }); - if (!res.ok) throw new Error("获取历史记录失败"); - const data = await res.json(); - files.value = data.files || []; - } catch (e) { - error.value = e.message || "加载失败"; - } finally { - loading.value = false; - } - }; - - const toggleExpand = async (filename) => { - expanded.value[filename] = !expanded.value[filename]; - if (expanded.value[filename]) { - const file = files.value.find((f) => f.filename === filename); - if (!file.records) { - try { - const res = await fetch( - `/api/history/${encodeURIComponent(filename)}`, - { method: "POST" } - ); - if (!res.ok) throw new Error("加载记录失败"); - file.records = await res.json(); - } catch (e) { - error.value = e.message || "加载记录失败"; - } - } - } - }; - - const openResult = (taskId) => { - router.push(`/result/${taskId}`); - }; - - onMounted(loadHistory); - - return { - files, - loading, - error, - expanded, - toggleExpand, - openResult, - formatFileSize, - formatDateTime, - statusText, - }; - }, - template: ` -
-
-
- 📋 历史记录 -
-
- -
-
- 加载中... -
- -

{{ error }}

- -
-
📁
-
暂无历史记录
-
上传文件后这里会显示分析历史
-
- - -
+
-
-
-
{{ file.filename }}
-
- {{ file.record_count }} 条记录 · {{ formatDateTime(file.last_upload) }} -
-
- + +
📁
+
+ 点击选择或拖拽文件 + 支持 .stp, .step 格式,最大 100MB
- - -
-
-
- 加载中... -
-
- 暂无详细记录 -
- -
-
📊
-
-
{{ record.task_id.slice(0, 8) }}...
-
- {{ formatDateTime(record.upload_time) }} - {{ formatFileSize(record.file_size) }} -
-
- - {{ statusText(record.status) }} - -
-
-
-
- + +
+ {{ state.selectedFile.name }} + {{ formatFileSize(state.selectedFile.size) }} +
+ +
{{ state.error }}
+ + + +
+
+
+
+ +
+

最近分析

+
+ + + + + + + + + + + + + + + + + + + +
文件名大小状态分析时间操作
{{ file.filename }}{{ formatFileSize(file.file_size) }} + + {{ file.status }} + + {{ formatDateTime(file.created_at) }} + +
+
+
- `, + ` }; const ResultView = { setup() { const route = useRoute(); - const task = ref(null); - const loading = ref(true); - const error = ref(""); - - const taskId = computed(() => route.params.taskId); + const router = useRouter(); + const state = reactive({ + task: null, + loading: true, + error: '' + }); const loadTask = async () => { - loading.value = true; - error.value = ""; try { - const res = await fetch(`/status/${taskId.value}`, { method: "POST" }); - if (!res.ok) throw new Error("获取任务数据失败"); - const data = await res.json(); - task.value = data; + state.task = await apiRequest(`/status/${route.params.taskId}`, { method: 'POST' }); } catch (e) { - error.value = e.message || "加载失败"; + state.error = handleApiError(e, '加载任务详情'); } finally { - loading.value = false; + state.loading = false; } }; - const geometry = computed(() => task.value?.geometry_data || null); - const keyInfo = computed(() => task.value?.key_info || null); - const meshSummary = computed(() => task.value?.mesh_summary || null); + onMounted(() => { + if (!appState.user) { + router.push('/login'); + return; + } + loadTask(); + }); - onMounted(loadTask); - - return { - task, - loading, - error, - taskId, - geometry, - keyInfo, - meshSummary, - formatNumber, - formatFileSize, - formatDateTime, - statusText, - }; + return { state, formatFileSize, formatDateTime, formatNumber }; }, template: ` -
-
-
- 📊 分析结果 -
- 返回仪表盘 +
+ - -
-
- 加载中... + +
+
+ 加载中...
- -

{{ error }}

- -
-
-
-
-
📝
- 任务信息 -
- - {{ statusText(task.status) }} - -
-
-
-
-
📄
-
-
{{ task.filename || 'N/A' }}
-
文件名
-
-
-
-
💾
-
-
{{ task.file_size ? formatFileSize(task.file_size) : 'N/A' }}
-
文件大小
-
-
-
-
🆔
-
-
{{ task.task_id }}
-
任务 ID
-
-
-
-
📅
-
-
{{ task.upload_time ? formatDateTime(task.upload_time) : 'N/A' }}
-
上传时间
-
-
-
-
- 错误: {{ task.error }} -
-
+ +
+

{{ state.error }}

+
+ +
+
+

{{ state.task.filename }}

+ + {{ state.task.status }} +
- +
-
-
📐
-
几何属性
-
-
-
-
体积
-
{{ geometry.volume ? formatNumber(geometry.volume) : 'N/A' }} mm³
+

文件信息

+
+
+ 文件大小 + {{ formatFileSize(state.task.file_size) }}
-
-
表面积
-
{{ geometry.surface_area ? formatNumber(geometry.surface_area) : 'N/A' }} mm²
+
+ 分析时间 + {{ formatDateTime(state.task.completed_at) }}
-
无几何数据
-
- -
-
-
📦
-
边界框
-
-
-
-
尺寸
-
- {{ geometry.bounding_box.dimensions[0].toFixed(2) }} × - {{ geometry.bounding_box.dimensions[1].toFixed(2) }} × - {{ geometry.bounding_box.dimensions[2].toFixed(2) }} mm -
-
-
-
无边界框数据
-
- -
-
-
🔺
-
拓扑结构
-
-
-
-
面 / 边 / 顶点
-
{{ geometry.topology.faces || 0 }} / {{ geometry.topology.edges || 0 }} / {{ geometry.topology.vertices || 0 }}
-
-
-
无拓扑数据
-
-
- -
-
-
-
🔧
- 工艺参数 -
-
-
-
-
📉
-
-
{{ keyInfo.metadata.shrinkage_rate }}
-
收缩率
-
+ +
+

几何数据

+
+
+ 顶点数 + {{ formatNumber(state.task.geometry_data.vertex_count) }}
-
-
📐
-
-
{{ keyInfo.metadata.draft_angle }}°
-
拔模角
-
+
+ 面数 + {{ formatNumber(state.task.geometry_data.face_count) }}
-
-
💪
-
-
{{ keyInfo.manufacturing_info.estimated_clamping_force }}
-
预估夹紧力
-
+
+ 边数 + {{ formatNumber(state.task.geometry_data.edge_count) }}
+ +
+

3D 预览

+ +
- `, + ` +}; + +const InventoryView = { + setup() { + const router = useRouter(); + const route = useRoute(); + const state = reactive({ + activeTab: 'dashboard', + dashboard: null, + products: [], + suppliers: [], + customers: [], + warehouses: [], + inventory: [], + movements: [], + loading: false + }); + + const loadDashboard = async () => { + state.loading = true; + try { + state.dashboard = await apiRequest('/api/inventory/dashboard'); + } catch (e) { + handleApiError(e, '加载仪表盘'); + } finally { + state.loading = false; + } + }; + + const loadProducts = async () => { + state.loading = true; + try { + state.products = await apiRequest('/api/inventory/products'); + } catch (e) { + handleApiError(e, '加载产品'); + } finally { + state.loading = false; + } + }; + + const loadSuppliers = async () => { + state.loading = true; + try { + state.suppliers = await apiRequest('/api/inventory/suppliers'); + } catch (e) { + handleApiError(e, '加载供应商'); + } finally { + state.loading = false; + } + }; + + const loadCustomers = async () => { + state.loading = true; + try { + state.customers = await apiRequest('/api/inventory/customers'); + } catch (e) { + handleApiError(e, '加载客户'); + } finally { + state.loading = false; + } + }; + + const loadInventory = async () => { + state.loading = true; + try { + state.inventory = await apiRequest('/api/inventory/inventory'); + } catch (e) { + handleApiError(e, '加载库存'); + } finally { + state.loading = false; + } + }; + + const loadMovements = async () => { + state.loading = true; + try { + state.movements = await apiRequest('/api/inventory/stock-movements'); + } catch (e) { + handleApiError(e, '加载变动记录'); + } finally { + state.loading = false; + } + }; + + const switchTab = (tab) => { + state.activeTab = tab; + switch (tab) { + case 'dashboard': loadDashboard(); break; + case 'products': loadProducts(); break; + case 'suppliers': loadSuppliers(); break; + case 'customers': loadCustomers(); break; + case 'inventory': loadInventory(); break; + case 'movements': loadMovements(); break; + } + }; + + onMounted(() => { + if (!appState.user) { + router.push('/login'); + return; + } + loadDashboard(); + }); + + return { + state, + switchTab, + formatNumber, + formatCurrency, + formatDateTime + }; + }, + template: ` +
+ + +
+ + + + + + +
+ +
+
+ 加载中... +
+ +
+
+
+
📦
+
+
{{ state.dashboard?.product_count || 0 }}
+
产品数量
+
+
+
+
📊
+
+
{{ state.dashboard?.total_stock || 0 }}
+
库存总量
+
+
+
+
💰
+
+
{{ formatCurrency(state.dashboard?.total_value || 0) }}
+
库存价值
+
+
+
+
🏭
+
+
{{ state.dashboard?.supplier_count || 0 }}
+
供应商
+
+
+
+
👥
+
+
{{ state.dashboard?.customer_count || 0 }}
+
客户
+
+
+
+
🏪
+
+
{{ state.dashboard?.warehouse_count || 0 }}
+
仓库
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
SKU名称分类单位成本价销售价
{{ product.sku }}{{ product.name }}{{ product.category || '-' }}{{ product.unit }}{{ formatCurrency(product.cost_price) }}{{ formatCurrency(product.sale_price) }}
+
+ +
+ + + + + + + + + + + + + + + + + + + +
SKU产品仓库数量可用
{{ item.product_sku }}{{ item.product_name }}{{ item.warehouse_name }}{{ item.quantity }}{{ item.available_quantity }}
+
+ +
+ + + + + + + + + + + + + + + + + + + +
编码名称联系人电话邮箱
{{ supplier.code }}{{ supplier.name }}{{ supplier.contact_person || '-' }}{{ supplier.phone || '-' }}{{ supplier.email || '-' }}
+
+ +
+ + + + + + + + + + + + + + + + + + + +
编码名称联系人电话邮箱
{{ customer.code }}{{ customer.name }}{{ customer.contact_person || '-' }}{{ customer.phone || '-' }}{{ customer.email || '-' }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
产品类型数量变动前变动后时间
{{ movement.product_name }} + + {{ movement.movement_type === 'in' ? '入库' : movement.movement_type === 'out' ? '出库' : '调整' }} + + {{ movement.quantity }}{{ movement.before_quantity }}{{ movement.after_quantity }}{{ formatDateTime(movement.created_at) }}
+
+
+
+ ` }; const routes = [ - { path: "/", component: DashboardView }, - { path: "/history", component: HistoryView }, - { path: "/result/:taskId", component: ResultView }, + { path: "/", component: HomeView }, + { path: "/login", component: LoginView }, + { path: "/users", component: UsersView }, + { path: "/moldinsight", component: MoldInsightView }, + { path: "/moldinsight/result/:taskId", component: ResultView }, + { path: "/inventory", component: InventoryView } ]; const router = createRouter({ history: createWebHistory(), - routes, + routes +}); + +router.beforeEach((to, from, next) => { + const publicPages = ['/login']; + const authRequired = !publicPages.includes(to.path); + + if (authRequired && !appState.user) { + return next('/login'); + } + + if (to.path === '/login' && appState.user) { + return next('/'); + } + + next(); }); const app = createApp(App);