[feat]:[FL-206][新增维度管理功能]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -5,6 +5,7 @@ from .v1.admin_files import router as admin_files_router
|
||||
from .v1.ai_chat import router as ai_chat_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.dimensions import router as dimensions_router
|
||||
from .v1.documents import router as documents_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
from .v1.fault_recurrence import router as fault_recurrence_router
|
||||
@@ -29,6 +30,7 @@ v1_router.include_router(admin_router)
|
||||
v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(ai_chat_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(dimensions_router)
|
||||
v1_router.include_router(documents_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(scheduled_tasks_router)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
|
||||
from ...schemas.dimension_item import (
|
||||
DimensionItemCreateRequest,
|
||||
DimensionItemListResponse,
|
||||
DimensionItemSummary,
|
||||
DimensionItemTreeNode,
|
||||
DimensionItemUpdateRequest,
|
||||
)
|
||||
from ...services.dimension_item_service import (
|
||||
create_dimension_item,
|
||||
delete_dimension_item,
|
||||
get_dimension_item_by_id,
|
||||
get_dimension_tree,
|
||||
list_dimension_items,
|
||||
serialize_dimension_item,
|
||||
update_dimension_item,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dimensions", tags=["dimensions"], dependencies=[Depends(require_enabled_menu_route)])
|
||||
|
||||
|
||||
@router.get("", response_model=DimensionItemListResponse)
|
||||
def get_dimension_item_list(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
keyword: str | None = Query(default=None),
|
||||
dimension_type: str | None = Query(default=None),
|
||||
enabled: bool | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemListResponse:
|
||||
return list_dimension_items(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
keyword=keyword,
|
||||
dimension_type=dimension_type,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[DimensionItemTreeNode])
|
||||
def get_dimension_item_tree(
|
||||
dimension_type: str | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[DimensionItemTreeNode]:
|
||||
return get_dimension_tree(db, dimension_type=dimension_type)
|
||||
|
||||
|
||||
@router.post("", response_model=DimensionItemSummary)
|
||||
def create_dimension_item_endpoint(
|
||||
payload: DimensionItemCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
created = create_dimension_item(db, payload, actor=current_user.user)
|
||||
if not created:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="维度项编码已存在或父节点不存在")
|
||||
return created
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=DimensionItemSummary)
|
||||
def update_dimension_item_endpoint(
|
||||
item_id: str,
|
||||
payload: DimensionItemUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
updated = update_dimension_item(db, item_id, payload, actor=current_user.user)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或更新失败")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def delete_dimension_item_endpoint(
|
||||
item_id: str,
|
||||
_: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_dimension_item(db, item_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或存在子节点")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=DimensionItemSummary)
|
||||
def get_dimension_item_detail(
|
||||
item_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在")
|
||||
return serialize_dimension_item(item)
|
||||
@@ -560,6 +560,7 @@ def init_db() -> None:
|
||||
atp_asset,
|
||||
audit_log,
|
||||
auth_session,
|
||||
dimension_item,
|
||||
elevation,
|
||||
file_storage,
|
||||
fl_analysis,
|
||||
|
||||
@@ -4,13 +4,14 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import ai_chat, atp_asset, audit_log, auth_session, dimension_item, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"ai_chat",
|
||||
"atp_asset",
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
"dimension_item",
|
||||
"document",
|
||||
"elevation",
|
||||
"file_storage",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
|
||||
class DimensionItem(Base):
|
||||
__tablename__ = "dimension_item"
|
||||
__table_args__ = (
|
||||
Index("idx_dimension_item_type", "dimension_type"),
|
||||
Index("idx_dimension_item_parent", "parent_id"),
|
||||
Index("idx_dimension_item_code", "code"),
|
||||
Index("idx_dimension_item_enabled", "is_enabled"),
|
||||
Index("idx_dimension_item_sort", "sort_order"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
dimension_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
parent_id: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DimensionItemSummary(BaseModel):
|
||||
id: str
|
||||
dimension_type: str
|
||||
code: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class DimensionItemTreeNode(BaseModel):
|
||||
id: str
|
||||
dimension_type: str
|
||||
code: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
children: list[DimensionItemTreeNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DimensionItemListResponse(BaseModel):
|
||||
items: list[DimensionItemSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class DimensionItemCreateRequest(BaseModel):
|
||||
dimension_type: str = Field(min_length=1, max_length=64)
|
||||
code: str = Field(min_length=1, max_length=128)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
is_enabled: bool = True
|
||||
sort_order: int = Field(default=0, ge=0, le=1_000_000)
|
||||
|
||||
|
||||
class DimensionItemUpdateRequest(BaseModel):
|
||||
code: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
is_enabled: bool | None = None
|
||||
sort_order: int | None = Field(default=None, ge=0, le=1_000_000)
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.base import utcnow
|
||||
from ..models.dimension_item import DimensionItem
|
||||
from ..models.user import User
|
||||
from ..schemas.dimension_item import (
|
||||
DimensionItemCreateRequest,
|
||||
DimensionItemListResponse,
|
||||
DimensionItemSummary,
|
||||
DimensionItemTreeNode,
|
||||
DimensionItemUpdateRequest,
|
||||
)
|
||||
from .push_service import publish_topic
|
||||
|
||||
DIMENSION_ITEM_TOPIC = "admin.dimension-items"
|
||||
|
||||
|
||||
def serialize_dimension_item(item: DimensionItem) -> DimensionItemSummary:
|
||||
return DimensionItemSummary(
|
||||
id=item.id,
|
||||
dimension_type=item.dimension_type,
|
||||
code=item.code,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
is_enabled=item.is_enabled,
|
||||
sort_order=item.sort_order,
|
||||
create_date=item.create_date,
|
||||
create_user=item.create_user,
|
||||
update_date=item.update_date,
|
||||
update_user=item.update_user,
|
||||
)
|
||||
|
||||
|
||||
def list_dimension_items(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
keyword: str | None,
|
||||
dimension_type: str | None,
|
||||
enabled: bool | None,
|
||||
) -> DimensionItemListResponse:
|
||||
stmt = select(DimensionItem)
|
||||
total_stmt = select(func.count()).select_from(DimensionItem)
|
||||
|
||||
if dimension_type:
|
||||
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
total_stmt = total_stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
|
||||
normalized_keyword = (keyword or "").strip()
|
||||
if normalized_keyword:
|
||||
like = f"%{normalized_keyword}%"
|
||||
predicate = or_(
|
||||
DimensionItem.code.ilike(like),
|
||||
DimensionItem.name.ilike(like),
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
total_stmt = total_stmt.where(predicate)
|
||||
|
||||
if enabled is not None:
|
||||
stmt = stmt.where(DimensionItem.is_enabled == enabled)
|
||||
total_stmt = total_stmt.where(DimensionItem.is_enabled == enabled)
|
||||
|
||||
total = int(db.scalar(total_stmt) or 0)
|
||||
stmt = stmt.order_by(DimensionItem.sort_order, DimensionItem.create_date.desc()).limit(limit).offset(offset)
|
||||
items = list(db.scalars(stmt).all())
|
||||
|
||||
return DimensionItemListResponse(
|
||||
items=[serialize_dimension_item(item) for item in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def get_dimension_tree(
|
||||
db: Session,
|
||||
dimension_type: str | None = None,
|
||||
) -> list[DimensionItemTreeNode]:
|
||||
stmt = select(DimensionItem).order_by(DimensionItem.sort_order, DimensionItem.create_date)
|
||||
|
||||
if dimension_type:
|
||||
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
|
||||
items = list(db.scalars(stmt).all())
|
||||
|
||||
item_map: dict[str, DimensionItemTreeNode] = {}
|
||||
for item in items:
|
||||
item_map[item.id] = DimensionItemTreeNode(
|
||||
id=item.id,
|
||||
dimension_type=item.dimension_type,
|
||||
code=item.code,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
is_enabled=item.is_enabled,
|
||||
sort_order=item.sort_order,
|
||||
create_date=item.create_date,
|
||||
create_user=item.create_user,
|
||||
update_date=item.update_date,
|
||||
update_user=item.update_user,
|
||||
children=[],
|
||||
)
|
||||
|
||||
root_nodes: list[DimensionItemTreeNode] = []
|
||||
for node in item_map.values():
|
||||
if node.parent_id and node.parent_id in item_map:
|
||||
item_map[node.parent_id].children.append(node)
|
||||
else:
|
||||
root_nodes.append(node)
|
||||
|
||||
return root_nodes
|
||||
|
||||
|
||||
def get_dimension_item_by_id(db: Session, item_id: str) -> DimensionItem | None:
|
||||
return db.scalar(select(DimensionItem).where(DimensionItem.id == item_id))
|
||||
|
||||
|
||||
def create_dimension_item(
|
||||
db: Session,
|
||||
payload: DimensionItemCreateRequest,
|
||||
actor: User,
|
||||
) -> DimensionItemSummary | None:
|
||||
existing = db.scalar(
|
||||
select(DimensionItem).where(
|
||||
DimensionItem.dimension_type == payload.dimension_type,
|
||||
DimensionItem.code == payload.code,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return None
|
||||
|
||||
if payload.parent_id:
|
||||
parent = get_dimension_item_by_id(db, payload.parent_id)
|
||||
if not parent:
|
||||
return None
|
||||
|
||||
item = DimensionItem(
|
||||
dimension_type=payload.dimension_type,
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
parent_id=payload.parent_id,
|
||||
description=payload.description,
|
||||
is_enabled=payload.is_enabled,
|
||||
sort_order=payload.sort_order,
|
||||
create_user=actor.id,
|
||||
update_user=actor.id,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return serialize_dimension_item(item)
|
||||
|
||||
|
||||
def update_dimension_item(
|
||||
db: Session,
|
||||
item_id: str,
|
||||
payload: DimensionItemUpdateRequest,
|
||||
actor: User,
|
||||
) -> DimensionItemSummary | None:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if payload.code is not None:
|
||||
existing = db.scalar(
|
||||
select(DimensionItem).where(
|
||||
DimensionItem.dimension_type == item.dimension_type,
|
||||
DimensionItem.code == payload.code,
|
||||
DimensionItem.id != item_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return None
|
||||
item.code = payload.code
|
||||
|
||||
if payload.name is not None:
|
||||
item.name = payload.name
|
||||
|
||||
if payload.parent_id is not None:
|
||||
if payload.parent_id:
|
||||
parent = get_dimension_item_by_id(db, payload.parent_id)
|
||||
if not parent:
|
||||
return None
|
||||
if payload.parent_id == item_id:
|
||||
return None
|
||||
item.parent_id = payload.parent_id
|
||||
|
||||
if payload.description is not None:
|
||||
item.description = payload.description
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
item.is_enabled = payload.is_enabled
|
||||
|
||||
if payload.sort_order is not None:
|
||||
item.sort_order = payload.sort_order
|
||||
|
||||
item.update_date = utcnow()
|
||||
item.update_user = actor.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return serialize_dimension_item(item)
|
||||
|
||||
|
||||
def delete_dimension_item(db: Session, item_id: str) -> bool:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
children = db.scalars(select(DimensionItem).where(DimensionItem.parent_id == item_id)).all()
|
||||
if children:
|
||||
return False
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return True
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Migration: Add dimension_item table for dimension management
|
||||
-- Date: 2026-06-28
|
||||
-- Description: Create dimension_item table to support managing voltage levels, tower types, scenarios, and arrester combinations in a tree structure
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimension_item (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
dimension_type VARCHAR(64) NOT NULL,
|
||||
code VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id VARCHAR(32),
|
||||
description TEXT,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
create_user VARCHAR(64),
|
||||
update_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
update_user VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dimension_item_type ON dimension_item(dimension_type);
|
||||
CREATE INDEX idx_dimension_item_parent ON dimension_item(parent_id);
|
||||
CREATE INDEX idx_dimension_item_code ON dimension_item(code);
|
||||
CREATE INDEX idx_dimension_item_enabled ON dimension_item(is_enabled);
|
||||
CREATE INDEX idx_dimension_item_sort ON dimension_item(sort_order);
|
||||
CREATE INDEX idx_dimension_item_create_date ON dimension_item(create_date);
|
||||
CREATE INDEX idx_dimension_item_create_user ON dimension_item(create_user);
|
||||
CREATE INDEX idx_dimension_item_update_user ON dimension_item(update_user);
|
||||
|
||||
-- Notes:
|
||||
-- - dimension_type values: 'voltage_level', 'tower_type', 'scenario', 'arrester_combination'
|
||||
-- - parent_id references dimension_item.id for tree structure (NULL for root nodes)
|
||||
-- - code should be unique within the same dimension_type
|
||||
-- - sort_order determines display order (lower values first)
|
||||
@@ -0,0 +1,669 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
type MenuProps,
|
||||
} from "antd";
|
||||
import { MoreOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type { DimensionItem, DimensionItemListResponse, DimensionItemTreeNode } from "@/types/dimension";
|
||||
|
||||
type CreateDimensionValues = {
|
||||
dimension_type: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parent_id?: string;
|
||||
description?: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
type EditDimensionValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
parent_id?: string;
|
||||
description?: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
const DIMENSION_TYPES = [
|
||||
{ value: "voltage_level", label: "电压等级" },
|
||||
{ value: "tower_type", label: "塔型" },
|
||||
{ value: "scenario", label: "场景" },
|
||||
{ value: "arrester_combination", label: "避雷器组合" },
|
||||
];
|
||||
|
||||
function dimensionTypeLabel(type: string): string {
|
||||
const found = DIMENSION_TYPES.find((t) => t.value === type);
|
||||
return found ? found.label : type;
|
||||
}
|
||||
|
||||
function statusLabel(enabled: boolean): string {
|
||||
return enabled ? "启用" : "禁用";
|
||||
}
|
||||
|
||||
const DIMENSIONS_TABLE_MIN_SCROLL_Y = 180;
|
||||
|
||||
export default function AdminDimensionsPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createForm] = Form.useForm<CreateDimensionValues>();
|
||||
const [editForm] = Form.useForm<EditDimensionValues>();
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<DimensionItem | null>(null);
|
||||
const [selectedDimensionType, setSelectedDimensionType] = useState<string | undefined>(undefined);
|
||||
const [viewMode, setViewMode] = useState<"table" | "tree">("tree");
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 50 });
|
||||
const [tableScrollY, setTableScrollY] = useState(DIMENSIONS_TABLE_MIN_SCROLL_Y);
|
||||
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const canManage = hasPermission("dimension.manage");
|
||||
const canRead = hasPermission("dimension.read") || canManage;
|
||||
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
|
||||
|
||||
const dimensionsQueryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(paginationPageSize));
|
||||
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
|
||||
if (selectedDimensionType) {
|
||||
params.set("dimension_type", selectedDimensionType);
|
||||
}
|
||||
return params.toString();
|
||||
}, [paginationCurrent, paginationPageSize, selectedDimensionType]);
|
||||
|
||||
const dimensionsPath = `/api/v1/dimensions?${dimensionsQueryParams}`;
|
||||
const treeQueryParams = selectedDimensionType ? `?dimension_type=${selectedDimensionType}` : "";
|
||||
const treePath = `/api/v1/dimensions/tree${treeQueryParams}`;
|
||||
|
||||
const loadDimensions = useCallback(async () => {
|
||||
const response = await fetchWithAuth(dimensionsPath);
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return (await response.json()) as DimensionItemListResponse;
|
||||
}, [fetchWithAuth, dimensionsPath]);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const response = await fetchWithAuth(treePath);
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return (await response.json()) as DimensionItemTreeNode[];
|
||||
}, [fetchWithAuth, treePath]);
|
||||
|
||||
const dimensionsQuery = useQuery({
|
||||
queryKey: ["admin.dimensions", dimensionsQueryParams],
|
||||
queryFn: loadDimensions,
|
||||
enabled: !!user && canRead && viewMode === "table",
|
||||
});
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: ["admin.dimensions.tree", treeQueryParams],
|
||||
queryFn: loadTree,
|
||||
enabled: !!user && canRead && viewMode === "tree",
|
||||
});
|
||||
|
||||
useTopicSubscription(
|
||||
"admin.dimension-items",
|
||||
useCallback(() => {
|
||||
if (!user || !canRead) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin.dimensions"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin.dimensions.tree"] });
|
||||
}, [canRead, queryClient, user]),
|
||||
);
|
||||
|
||||
const dimensions = useMemo(() => dimensionsQuery.data?.items ?? [], [dimensionsQuery.data?.items]);
|
||||
const treeData = useMemo(() => treeQuery.data ?? [], [treeQuery.data]);
|
||||
|
||||
const refreshData = async () => {
|
||||
await queryClient.refetchQueries({ queryKey: ["admin.dimensions"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["admin.dimensions.tree"] });
|
||||
};
|
||||
|
||||
const createDimensionMutation = useMutation({
|
||||
mutationFn: async (values: CreateDimensionValues) => {
|
||||
const response = await fetchWithAuth("/api/v1/dimensions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<DimensionItem>;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setSuccess("维度项已创建");
|
||||
setError("");
|
||||
createForm.resetFields();
|
||||
setCreateModalOpen(false);
|
||||
await refreshData();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setSuccess("");
|
||||
setError(candidate instanceof Error ? candidate.message : "创建维度项失败");
|
||||
},
|
||||
});
|
||||
|
||||
const updateDimensionMutation = useMutation({
|
||||
mutationFn: async ({ itemId, payload }: { itemId: string; payload: EditDimensionValues }) => {
|
||||
const response = await fetchWithAuth(`/api/v1/dimensions/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<DimensionItem>;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setSuccess("维度项已更新");
|
||||
setEditingItem(null);
|
||||
editForm.resetFields();
|
||||
await refreshData();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setSuccess("");
|
||||
setError(candidate instanceof Error ? candidate.message : "更新维度项失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDimensionMutation = useMutation({
|
||||
mutationFn: async (itemId: string) => {
|
||||
const response = await fetchWithAuth(`/api/v1/dimensions/${itemId}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<{ success: boolean }>;
|
||||
},
|
||||
onMutate: (itemId) => {
|
||||
setDeletingId(itemId);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setSuccess("维度项已删除");
|
||||
await refreshData();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setSuccess("");
|
||||
setError(candidate instanceof Error ? candidate.message : "删除维度项失败");
|
||||
},
|
||||
onSettled: () => setDeletingId(null),
|
||||
});
|
||||
|
||||
const handleCreateDimension = async (values: CreateDimensionValues) => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
createDimensionMutation.mutate(values);
|
||||
};
|
||||
|
||||
const openEditModal = (item: DimensionItem) => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setEditingItem(item);
|
||||
editForm.setFieldsValue({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
parent_id: item.parent_id || undefined,
|
||||
description: item.description || undefined,
|
||||
is_enabled: item.is_enabled,
|
||||
sort_order: item.sort_order,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
if (updateDimensionMutation.isPending) return;
|
||||
setEditingItem(null);
|
||||
editForm.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmitEdit = async (values: EditDimensionValues) => {
|
||||
if (!editingItem) return;
|
||||
updateDimensionMutation.mutate({ itemId: editingItem.id, payload: values });
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
createForm.resetFields();
|
||||
if (selectedDimensionType) {
|
||||
createForm.setFieldsValue({ dimension_type: selectedDimensionType });
|
||||
}
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
if (createDimensionMutation.isPending) return;
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
};
|
||||
|
||||
const queryError = (dimensionsQuery.error instanceof Error ? dimensionsQuery.error.message : "") ||
|
||||
(treeQuery.error instanceof Error ? treeQuery.error.message : "");
|
||||
const anyError = error || queryError;
|
||||
|
||||
useToastFeedback({
|
||||
errorMessage: anyError,
|
||||
successMessage: success,
|
||||
clearError: () => setError(""),
|
||||
clearSuccess: () => setSuccess(""),
|
||||
});
|
||||
|
||||
const buildTreeData = (nodes: DimensionItemTreeNode[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
key: node.id,
|
||||
title: (
|
||||
<Space>
|
||||
<Typography.Text strong>{node.name}</Typography.Text>
|
||||
<Typography.Text type="secondary">({node.code})</Typography.Text>
|
||||
<Tag color={node.is_enabled ? "green" : "default"}>{statusLabel(node.is_enabled)}</Tag>
|
||||
</Space>
|
||||
),
|
||||
children: node.children ? buildTreeData(node.children) : [],
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<DimensionItem> = [
|
||||
{
|
||||
title: "维度类型",
|
||||
dataIndex: "dimension_type",
|
||||
width: 120,
|
||||
render: (value: string) => dimensionTypeLabel(value),
|
||||
},
|
||||
{
|
||||
title: "编码",
|
||||
dataIndex: "code",
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: "名称",
|
||||
dataIndex: "name",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "父节点ID",
|
||||
dataIndex: "parent_id",
|
||||
width: 120,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
width: 200,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_enabled",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "green" : "default"}>{statusLabel(value)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "排序",
|
||||
dataIndex: "sort_order",
|
||||
width: 80,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 160,
|
||||
render: (_value, row) => {
|
||||
const deleteLoading = deletingId === row.id;
|
||||
const rowBusy = deleteLoading;
|
||||
|
||||
const moreMenuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "toggle-status",
|
||||
label: row.is_enabled ? "禁用" : "启用",
|
||||
disabled: rowBusy,
|
||||
onClick: () => {
|
||||
updateDimensionMutation.mutate({
|
||||
itemId: row.id,
|
||||
payload: {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
parent_id: row.parent_id || undefined,
|
||||
description: row.description || undefined,
|
||||
is_enabled: !row.is_enabled,
|
||||
sort_order: row.sort_order,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={rowBusy || !canManage}
|
||||
onClick={() => openEditModal(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title={`确认删除维度项 ${row.name}(${row.code})?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: deleteLoading }}
|
||||
onConfirm={() => deleteDimensionMutation.mutate(row.id)}
|
||||
disabled={rowBusy || !canManage}
|
||||
>
|
||||
<Button danger size="small" loading={deleteLoading} disabled={rowBusy || !canManage}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
|
||||
<Button size="small" disabled={rowBusy || !canManage} icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<Spin tip="初始化中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
|
||||
<p className="text-sm text-[var(--gray-11)]">请先登录后再访问维度管理页面。</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
|
||||
<p className="text-sm text-[var(--gray-11)]">你没有访问该页面的权限(需要 `dimension.read` 或 `dimension.manage`)。</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Card
|
||||
title="维度管理"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
options={[
|
||||
{ value: "tree", label: "树形视图" },
|
||||
{ value: "table", label: "表格视图" },
|
||||
]}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
{canManage && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新增维度项
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="维度类型">
|
||||
<Select
|
||||
value={selectedDimensionType}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
options={DIMENSION_TYPES}
|
||||
onChange={(value) => {
|
||||
setSelectedDimensionType(value);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<div style={{ minHeight: 300 }}>
|
||||
{treeQuery.isLoading ? (
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
) : treeData.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="未找到符合筛选条件的维度项。"
|
||||
/>
|
||||
) : (
|
||||
<Tree
|
||||
treeData={buildTreeData(treeData)}
|
||||
defaultExpandAll
|
||||
showLine
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={tableScrollAnchorRef}>
|
||||
<Table<DimensionItem>
|
||||
rowKey="id"
|
||||
dataSource={dimensions}
|
||||
columns={columns}
|
||||
loading={dimensionsQuery.isLoading}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: Math.max(dimensionsQuery.data?.total ?? 0, 1),
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100, 200],
|
||||
showTotal: () => `共 ${dimensionsQuery.data?.total ?? 0} 条`,
|
||||
hideOnSinglePage: false,
|
||||
style: { marginBottom: 0 },
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination({ current: page, pageSize });
|
||||
},
|
||||
}}
|
||||
scroll={{ y: tableScrollY }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="未找到符合筛选条件的维度项。"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增维度项"
|
||||
open={createModalOpen}
|
||||
destroyOnClose
|
||||
onCancel={closeCreateModal}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={createDimensionMutation.isPending}
|
||||
>
|
||||
<Form<CreateDimensionValues>
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateDimension}
|
||||
autoComplete="off"
|
||||
initialValues={{ is_enabled: true, sort_order: 0 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="维度类型"
|
||||
name="dimension_type"
|
||||
rules={[{ required: true, message: "请选择维度类型" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择维度类型"
|
||||
options={DIMENSION_TYPES}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="编码"
|
||||
name="code"
|
||||
rules={[
|
||||
{ required: true, message: "请输入编码" },
|
||||
{ min: 1, message: "编码至少 1 位" },
|
||||
{ max: 128, message: "编码不能超过 128 位" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如 110kv" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "请输入名称" },
|
||||
{ min: 1, message: "名称至少 1 位" },
|
||||
{ max: 255, message: "名称不能超过 255 位" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如 110千伏" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="父节点ID" name="parent_id">
|
||||
<Input placeholder="留空表示顶级节点" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea rows={3} placeholder="可选" maxLength={2000} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="启用状态" name="is_enabled" valuePropName="checked">
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: "启用" },
|
||||
{ value: false, label: "禁用" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="排序"
|
||||
name="sort_order"
|
||||
rules={[{ required: true, message: "请输入排序值" }]}
|
||||
>
|
||||
<Input type="number" placeholder="0" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingItem ? `编辑维度项:${editingItem.name}(${editingItem.code})` : "编辑维度项"}
|
||||
open={!!editingItem}
|
||||
destroyOnClose
|
||||
onCancel={closeEditModal}
|
||||
onOk={() => editForm.submit()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={updateDimensionMutation.isPending}
|
||||
>
|
||||
<Form<EditDimensionValues>
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmitEdit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="编码"
|
||||
name="code"
|
||||
rules={[
|
||||
{ required: true, message: "请输入编码" },
|
||||
{ min: 1, message: "编码至少 1 位" },
|
||||
{ max: 128, message: "编码不能超过 128 位" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如 110kv" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "请输入名称" },
|
||||
{ min: 1, message: "名称至少 1 位" },
|
||||
{ max: 255, message: "名称不能超过 255 位" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如 110千伏" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="父节点ID" name="parent_id">
|
||||
<Input placeholder="留空表示顶级节点" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea rows={3} placeholder="可选" maxLength={2000} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="启用状态" name="is_enabled" valuePropName="checked">
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: "启用" },
|
||||
{ value: false, label: "禁用" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="排序"
|
||||
name="sort_order"
|
||||
rules={[{ required: true, message: "请输入排序值" }]}
|
||||
>
|
||||
<Input type="number" placeholder="0" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type DimensionItem = {
|
||||
id: string;
|
||||
dimension_type: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
description: string | null;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
update_date: string;
|
||||
update_user: string | null;
|
||||
};
|
||||
|
||||
export type DimensionItemTreeNode = DimensionItem & {
|
||||
children: DimensionItemTreeNode[];
|
||||
};
|
||||
|
||||
export type DimensionItemListResponse = {
|
||||
items: DimensionItem[];
|
||||
total: number;
|
||||
};
|
||||
Reference in New Issue
Block a user