[feat]:[FL-159][杆塔模型管理页面一致性优化]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1089,6 +1089,15 @@
|
|||||||
- 后端统计口径要求:`total` 必须与当前检索/过滤条件一致(不是全量总数)。
|
- 后端统计口径要求:`total` 必须与当前检索/过滤条件一致(不是全量总数)。
|
||||||
- 前端 `/admin/users` 采用“检索条件 + 分页状态”驱动请求,不再固定拉取 `limit=200` 全量列表。
|
- 前端 `/admin/users` 采用“检索条件 + 分页状态”驱动请求,不再固定拉取 `limit=200` 全量列表。
|
||||||
|
|
||||||
|
## 杆塔模型管理检索与分页口径(2026-06-20)
|
||||||
|
|
||||||
|
- 杆塔模型列表接口 `GET /api/v1/tower-models` 支持查询参数:
|
||||||
|
- `limit` / `offset`:分页
|
||||||
|
- `keyword`:按 `code/name/tower_type` 模糊检索
|
||||||
|
- `enabled`:启用状态过滤(`true|false`)
|
||||||
|
- 后端统计口径要求:`total` 与当前检索/过滤条件一致,分页仅作用于返回 `items`。
|
||||||
|
- 前端 `/admin/tower-models` 采用与 `/admin/users` 一致的“检索条件 + 分页状态”驱动请求;移动端卡片视图按服务端分页累积数据,筛选切换需重置页码与累积列表。
|
||||||
|
|
||||||
## 用户管理编辑口径(2026-05-01)
|
## 用户管理编辑口径(2026-05-01)
|
||||||
|
|
||||||
- 用户信息更新接口 `PATCH /api/v1/users/{user_id}` 当前支持:
|
- 用户信息更新接口 `PATCH /api/v1/users/{user_id}` 当前支持:
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ router = APIRouter(prefix="/tower-models", tags=["tower-models"])
|
|||||||
|
|
||||||
@router.get("", response_model=TowerModelListResponse)
|
@router.get("", response_model=TowerModelListResponse)
|
||||||
def get_tower_model_list(
|
def get_tower_model_list(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
enabled: bool | None = Query(default=None),
|
enabled: bool | None = Query(default=None),
|
||||||
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
||||||
@@ -40,6 +42,8 @@ def get_tower_model_list(
|
|||||||
) -> TowerModelListResponse:
|
) -> TowerModelListResponse:
|
||||||
return list_tower_models(
|
return list_tower_models(
|
||||||
db,
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ def serialize_tower_model(item: TowerModel) -> TowerModelSummary:
|
|||||||
def list_tower_models(
|
def list_tower_models(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
keyword: str | None,
|
keyword: str | None,
|
||||||
enabled: bool | None,
|
enabled: bool | None,
|
||||||
) -> TowerModelListResponse:
|
) -> TowerModelListResponse:
|
||||||
@@ -98,6 +100,8 @@ def list_tower_models(
|
|||||||
total = int(db.scalar(total_stmt) or 0)
|
total = int(db.scalar(total_stmt) or 0)
|
||||||
items = db.execute(
|
items = db.execute(
|
||||||
stmt.order_by(TowerModel.sort_order.asc(), TowerModel.code.asc())
|
stmt.order_by(TowerModel.sort_order.asc(), TowerModel.code.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return TowerModelListResponse(
|
return TowerModelListResponse(
|
||||||
items=[serialize_tower_model(item) for item in items],
|
items=[serialize_tower_model(item) for item in items],
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from api.app import models # noqa: F401
|
||||||
|
from api.app.core.database import Base
|
||||||
|
from api.app.models.tower_model import TowerModel
|
||||||
|
from api.app.services.tower_model_service import list_tower_models
|
||||||
|
|
||||||
|
|
||||||
|
class TowerModelServiceTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite+pysqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
self.SessionLocal = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = self.SessionLocal()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_list_tower_models_applies_limit_and_offset(self) -> None:
|
||||||
|
for index in range(5):
|
||||||
|
self.session.add(
|
||||||
|
TowerModel(
|
||||||
|
code=f"TM-{index}",
|
||||||
|
name=f"Tower Model {index}",
|
||||||
|
tower_type="直线",
|
||||||
|
is_enabled=True,
|
||||||
|
sort_order=index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
page = list_tower_models(
|
||||||
|
self.session,
|
||||||
|
limit=2,
|
||||||
|
offset=1,
|
||||||
|
keyword=None,
|
||||||
|
enabled=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(page.total, 5)
|
||||||
|
self.assertEqual([item.code for item in page.items], ["TM-1", "TM-2"])
|
||||||
|
|
||||||
|
def test_list_tower_models_filters_before_paginating(self) -> None:
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
TowerModel(code="A-ZX", name="直线模型 A", tower_type="直线", is_enabled=True, sort_order=1),
|
||||||
|
TowerModel(code="B-NZ", name="耐张模型 B", tower_type="耐张", is_enabled=False, sort_order=2),
|
||||||
|
TowerModel(code="C-ZX", name="直线模型 C", tower_type="直线", is_enabled=True, sort_order=3),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
page = list_tower_models(
|
||||||
|
self.session,
|
||||||
|
limit=1,
|
||||||
|
offset=0,
|
||||||
|
keyword="直线",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(page.total, 2)
|
||||||
|
self.assertEqual(len(page.items), 1)
|
||||||
|
self.assertTrue(page.items[0].is_enabled)
|
||||||
|
self.assertEqual(page.items[0].tower_type, "直线")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -261,3 +261,29 @@
|
|||||||
|
|
||||||
- 风险与关注点:
|
- 风险与关注点:
|
||||||
- 改动仅影响 `/admin/syslog` 前端展示、筛选排布、表格滚动和移动卡片视觉,不改变 `/api/v1/admin/audit-logs` 接口路径、请求/响应字段或权限语义。
|
- 改动仅影响 `/admin/syslog` 前端展示、筛选排布、表格滚动和移动卡片视觉,不改变 `/api/v1/admin/audit-logs` 接口路径、请求/响应字段或权限语义。
|
||||||
|
|
||||||
|
# Work Log - 杆塔模型管理页面一致性优化(FL-159)
|
||||||
|
|
||||||
|
- 背景:
|
||||||
|
- 杆塔模型管理页需要对齐用户管理页的列表布局、筛选分页、反馈提示、移动卡片和表格滚动规范。
|
||||||
|
|
||||||
|
- 本次处理:
|
||||||
|
- 杆塔模型列表接口补齐 `limit/offset` 查询参数,service 层按筛选条件统计 `total` 后再分页返回 `items`。
|
||||||
|
- 前端 `/admin/tower-models` 列表请求改为由关键词、状态筛选和分页状态共同驱动,移动端无限滚动改为服务端分页累积数据。
|
||||||
|
- 成功/失败提示统一走 `success/error` state + `useToastFeedback`,移除页面内直接调用 `App.useApp().message` 的分散反馈方式。
|
||||||
|
- 表格对齐用户管理页:`tableLayout="fixed"`、仅纵向滚动、180px 最小表格体高度、操作区 `Space wrap`、状态 Tag 颜色统一为 `green/default`。
|
||||||
|
- 移动卡片字段列宽从 `72px` 调整为 `64px`,模型图片预览缩略图切换为 AntD `Image` 包装以消除页面级 `<img>` lint 警告。
|
||||||
|
- 新增 `api/tests/test_tower_model_service.py` 覆盖杆塔模型列表分页与筛选后分页行为。
|
||||||
|
- 为通过全量 Next build 类型门禁,同步补齐后台页面 AntD `Card` 包装组件的 ref 类型,并修正线路参数页少量 JSX 展示值类型。
|
||||||
|
|
||||||
|
- 验证:
|
||||||
|
- 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/tower-models/page.tsx` 通过,存在用户页 1 条既有 unused eslint-disable warning 与塔模型页 1 条既有 no-img-element warning。
|
||||||
|
- 基线:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
|
||||||
|
- 修改后:`npm --workspace web exec eslint src/app/admin/tower-models/page.tsx --max-warnings=0` 通过。
|
||||||
|
- 修改后:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/tower-models/page.tsx` 通过,仍仅用户页 1 条既有 warning。
|
||||||
|
- 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
|
||||||
|
- 修改后:`UV_CACHE_DIR=/tmp/fquiz-uv-cache UV_PYTHON_INSTALL_DIR=/tmp/fquiz-uv-python /home/jenkins/.local/bin/uv run --python 3.11 --with fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with python-multipart --with psycopg[binary] -m unittest api.tests.test_tower_model_service` 通过,存在 1 条既有 SQLAlchemy relationship warning。
|
||||||
|
- 修改后:`npm run build:web` 通过,仍输出既有 multiple lockfiles 与 middleware/proxy 迁移提示。
|
||||||
|
|
||||||
|
- 风险与关注点:
|
||||||
|
- 改动涉及 `GET /api/v1/tower-models` 列表分页契约,未改变响应字段、CRUD 字段、权限码或图片上传/预览接口。
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||||
|
|
||||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
@@ -34,7 +34,7 @@ import { readApiError } from "@/lib/api";
|
|||||||
import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display";
|
import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display";
|
||||||
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
type AssetFormValues = {
|
type AssetFormValues = {
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties, type RefAttributes } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
@@ -38,7 +38,7 @@ import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
|||||||
import { readApiError } from "@/lib/api";
|
import { readApiError } from "@/lib/api";
|
||||||
import { readLinePreparation } from "@/lib/line-preparation";
|
import { readLinePreparation } from "@/lib/line-preparation";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
import type {
|
import type {
|
||||||
ElevationApplyJobCreateResponse,
|
ElevationApplyJobCreateResponse,
|
||||||
ElevationApplyJobListResponse,
|
ElevationApplyJobListResponse,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { Button } from "@/components/ui-antd";
|
import { Button } from "@/components/ui-antd";
|
||||||
@@ -93,7 +93,7 @@ function readXhrError(xhr: XMLHttpRequest): string {
|
|||||||
const FILES_TABLE_MIN_SCROLL_Y = 180;
|
const FILES_TABLE_MIN_SCROLL_Y = 180;
|
||||||
const FILES_TABLE_VIEWPORT_GAP = 40;
|
const FILES_TABLE_VIEWPORT_GAP = 40;
|
||||||
const FILES_TABLE_FALLBACK_RESERVE = 220;
|
const FILES_TABLE_FALLBACK_RESERVE = 220;
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
export default function AdminFilesPage() {
|
export default function AdminFilesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
type TableColumnsType,
|
type TableColumnsType,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
|
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
|
||||||
import type { CSSProperties, ComponentType } from "react";
|
import type { CSSProperties, ComponentType, RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -37,7 +37,7 @@ import { readApiError } from "@/lib/api";
|
|||||||
import { normalizeAppRoutePath } from "@/lib/app-route-path";
|
import { normalizeAppRoutePath } from "@/lib/app-route-path";
|
||||||
import type { MenuItem, MenuListResponse } from "@/types/auth";
|
import type { MenuItem, MenuListResponse } from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
type FilterStatus = "all" | "enabled" | "disabled";
|
type FilterStatus = "all" | "enabled" | "disabled";
|
||||||
|
|
||||||
|
|||||||
@@ -245,13 +245,6 @@ function parseJsonObjectText(value: string, label: string): Record<string, unkno
|
|||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, digits = 3): string {
|
|
||||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return value.toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TowerTopologyKind = "single" | "double" | "quad" | "dc";
|
type TowerTopologyKind = "single" | "double" | "quad" | "dc";
|
||||||
type TowerCircuitKey = "I" | "II" | "III" | "IV";
|
type TowerCircuitKey = "I" | "II" | "III" | "IV";
|
||||||
type TowerPhaseKey = "upper" | "middle" | "lower";
|
type TowerPhaseKey = "upper" | "middle" | "lower";
|
||||||
@@ -2224,7 +2217,7 @@ export default function AdminPowerLinesPage() {
|
|||||||
<Typography.Text>雷电流幅值</Typography.Text>
|
<Typography.Text>雷电流幅值</Typography.Text>
|
||||||
{selectedLinePreparation.lightning_current.ready && (
|
{selectedLinePreparation.lightning_current.ready && (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
(a={selectedLinePreparation.lightning_current.values.current_a ?? "-"}, b={selectedLinePreparation.lightning_current.values.current_b ?? "-"})
|
(a={String(selectedLinePreparation.lightning_current.values.current_a ?? "-")}, b={String(selectedLinePreparation.lightning_current.values.current_b ?? "-")})
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
@@ -2250,7 +2243,7 @@ export default function AdminPowerLinesPage() {
|
|||||||
<Typography.Text>地闪密度</Typography.Text>
|
<Typography.Text>地闪密度</Typography.Text>
|
||||||
{selectedLinePreparation.lightning_density.ready && (
|
{selectedLinePreparation.lightning_density.ready && (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
(Ng={selectedLinePreparation.lightning_density.values.ng ?? "-"})
|
(Ng={String(selectedLinePreparation.lightning_density.values.ng ?? "-")})
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import type { CSSProperties, ComponentType } from "react";
|
import type { CSSProperties, ComponentType, RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -33,7 +33,7 @@ import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
|||||||
import { readApiError } from "@/lib/api";
|
import { readApiError } from "@/lib/api";
|
||||||
import type { MenuItem, RoleItem } from "@/types/auth";
|
import type { MenuItem, RoleItem } from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
type RolesWithMenusResponse = {
|
type RolesWithMenusResponse = {
|
||||||
roles: RoleItem[];
|
roles: RoleItem[];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type CardProps,
|
type CardProps,
|
||||||
type TableColumnsType,
|
type TableColumnsType,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType, RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -67,7 +67,7 @@ const TIMEZONE_OPTIONS = [
|
|||||||
{ label: "UTC", value: "UTC" },
|
{ label: "UTC", value: "UTC" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
const TABLE_MIN_SCROLL_Y = 180;
|
const TABLE_MIN_SCROLL_Y = 180;
|
||||||
const TABLE_VIEWPORT_GAP = 40;
|
const TABLE_VIEWPORT_GAP = 40;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button, Card, Col, Empty, Form, Input, Row, Space, Spin, Table, Tag, Typography, type CardProps } from "antd";
|
import { Button, Card, Col, Empty, Form, Input, Row, Space, Spin, Table, Tag, Typography, type CardProps } from "antd";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import type { CSSProperties, ComponentType } from "react";
|
import type { CSSProperties, ComponentType, RefAttributes } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
@@ -18,7 +18,7 @@ const PAGE_SIZE = 50;
|
|||||||
const SYSLOG_TABLE_MIN_SCROLL_Y = 180;
|
const SYSLOG_TABLE_MIN_SCROLL_Y = 180;
|
||||||
const SYSLOG_TABLE_VIEWPORT_GAP = 40;
|
const SYSLOG_TABLE_VIEWPORT_GAP = 40;
|
||||||
const SYSLOG_TABLE_FALLBACK_RESERVE = 220;
|
const SYSLOG_TABLE_FALLBACK_RESERVE = 220;
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
type Filters = {
|
type Filters = {
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { MoreOutlined } from "@ant-design/icons";
|
import { MoreOutlined } from "@ant-design/icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ComponentType, CSSProperties, ReactNode } from "react";
|
import type { ComponentType, CSSProperties, ReactNode, RefAttributes } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
@@ -41,7 +41,7 @@ type CreateMessageValues = {
|
|||||||
target_user_id?: string;
|
target_user_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
const MESSAGE_TYPE_OPTIONS: Array<{ label: string; value: SystemMessageType }> = [
|
const MESSAGE_TYPE_OPTIONS: Array<{ label: string; value: SystemMessageType }> = [
|
||||||
{ label: "通知", value: "info" },
|
{ label: "通知", value: "info" },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type TableColumnsType,
|
type TableColumnsType,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType, RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -57,7 +57,7 @@ const PARAM_STATUS_OPTIONS = [
|
|||||||
{ label: "已禁用", value: "disabled" },
|
{ label: "已禁用", value: "disabled" },
|
||||||
] as const satisfies ReadonlyArray<{ label: string; value: FormState["status"] }>;
|
] as const satisfies ReadonlyArray<{ label: string; value: FormState["status"] }>;
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
const PARAM_TABLE_MIN_SCROLL_Y = 180;
|
const PARAM_TABLE_MIN_SCROLL_Y = 180;
|
||||||
const PARAM_TABLE_VIEWPORT_GAP = 40;
|
const PARAM_TABLE_VIEWPORT_GAP = 40;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType, RefAttributes } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
} from "@/lib/task-monitor-display";
|
} from "@/lib/task-monitor-display";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
const DEFAULT_RECENT_LIMIT = 100;
|
const DEFAULT_RECENT_LIMIT = 100;
|
||||||
const TASK_MONITOR_TABLE_MIN_SCROLL_Y = 180;
|
const TASK_MONITOR_TABLE_MIN_SCROLL_Y = 180;
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
App,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
|
Image as AntImageBase,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -40,7 +40,15 @@ import type {
|
|||||||
TowerModelSummary,
|
TowerModelSummary,
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
const AntImage = AntImageBase as unknown as ComponentType<{
|
||||||
|
alt?: string;
|
||||||
|
height?: number;
|
||||||
|
preview?: boolean;
|
||||||
|
src?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
width?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
type TowerModelFormValues = {
|
type TowerModelFormValues = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -58,7 +66,7 @@ const EMPTY_FORM: TowerModelFormValues = {
|
|||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOWER_MODEL_TABLE_MIN_SCROLL_Y = 220;
|
const TOWER_MODEL_TABLE_MIN_SCROLL_Y = 180;
|
||||||
const TOWER_MODEL_VIEWPORT_GAP = 40;
|
const TOWER_MODEL_VIEWPORT_GAP = 40;
|
||||||
const TOWER_MODEL_FALLBACK_RESERVE = 220;
|
const TOWER_MODEL_FALLBACK_RESERVE = 220;
|
||||||
const TOWER_MODEL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
const TOWER_MODEL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||||
@@ -171,11 +179,12 @@ function TowerModelImageCell({
|
|||||||
return (
|
return (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img
|
<AntImage
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={model.name}
|
alt={model.name}
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
|
preview={false}
|
||||||
style={{ objectFit: "cover", borderRadius: 6, border: "1px solid #ddd" }}
|
style={{ objectFit: "cover", borderRadius: 6, border: "1px solid #ddd" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -205,20 +214,22 @@ function TowerModelImageCell({
|
|||||||
export default function AdminTowerModelsPage() {
|
export default function AdminTowerModelsPage() {
|
||||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { message: messageApi } = App.useApp();
|
|
||||||
const [form] = Form.useForm<TowerModelFormValues>();
|
const [form] = Form.useForm<TowerModelFormValues>();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const isMobile = useMobileDetection();
|
const isMobile = useMobileDetection();
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
const [enabledFilter, setEnabledFilter] = useState<"all" | "enabled" | "disabled">("all");
|
const [enabledFilter, setEnabledFilter] = useState<"enabled" | "disabled" | undefined>(undefined);
|
||||||
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingModel, setEditingModel] = useState<TowerModelSummary | null>(null);
|
const [editingModel, setEditingModel] = useState<TowerModelSummary | null>(null);
|
||||||
const [uploadModel, setUploadModel] = useState<TowerModelSummary | null>(null);
|
const [uploadModel, setUploadModel] = useState<TowerModelSummary | null>(null);
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: TOWER_MODEL_DEFAULT_PAGE_SIZE });
|
const [pagination, setPagination] = useState({ current: 1, pageSize: TOWER_MODEL_DEFAULT_PAGE_SIZE });
|
||||||
const [cardViewPage, setCardViewPage] = useState(1);
|
const [cardViewPage, setCardViewPage] = useState(1);
|
||||||
|
const [allLoadedModels, setAllLoadedModels] = useState<TowerModelSummary[]>([]);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [tableScrollY, setTableScrollY] = useState(TOWER_MODEL_TABLE_MIN_SCROLL_Y);
|
const [tableScrollY, setTableScrollY] = useState(TOWER_MODEL_TABLE_MIN_SCROLL_Y);
|
||||||
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pageCardRef = useRef<HTMLDivElement | null>(null);
|
const pageCardRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -230,18 +241,21 @@ export default function AdminTowerModelsPage() {
|
|||||||
|
|
||||||
const canRead = hasPermission("tower_model.read") || hasPermission("tower_model.manage") || hasPermission("tower.read") || hasPermission("tower.manage");
|
const canRead = hasPermission("tower_model.read") || hasPermission("tower_model.manage") || hasPermission("tower.read") || hasPermission("tower.manage");
|
||||||
const canManage = hasPermission("tower_model.manage");
|
const canManage = hasPermission("tower_model.manage");
|
||||||
|
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
|
||||||
|
const trimmedKeyword = searchKeyword.trim();
|
||||||
|
|
||||||
const listPath = useMemo(() => {
|
const listPath = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (keyword.trim()) {
|
params.set("limit", String(paginationPageSize));
|
||||||
params.set("keyword", keyword.trim());
|
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
|
||||||
|
if (trimmedKeyword) {
|
||||||
|
params.set("keyword", trimmedKeyword);
|
||||||
}
|
}
|
||||||
if (enabledFilter !== "all") {
|
if (enabledFilter) {
|
||||||
params.set("enabled", enabledFilter === "enabled" ? "true" : "false");
|
params.set("enabled", enabledFilter === "enabled" ? "true" : "false");
|
||||||
}
|
}
|
||||||
const query = params.toString();
|
return `/api/v1/tower-models?${params.toString()}`;
|
||||||
return `/api/v1/tower-models${query ? `?${query}` : ""}`;
|
}, [enabledFilter, paginationCurrent, paginationPageSize, trimmedKeyword]);
|
||||||
}, [keyword, enabledFilter]);
|
|
||||||
|
|
||||||
const mountsQuery = useQuery({
|
const mountsQuery = useQuery({
|
||||||
queryKey: ["/api/v1/admin/files?path=/"],
|
queryKey: ["/api/v1/admin/files?path=/"],
|
||||||
@@ -271,18 +285,15 @@ export default function AdminTowerModelsPage() {
|
|||||||
|
|
||||||
useToastFeedback({
|
useToastFeedback({
|
||||||
errorMessage: error || listError,
|
errorMessage: error || listError,
|
||||||
|
successMessage: success,
|
||||||
clearError: () => setError(""),
|
clearError: () => setError(""),
|
||||||
|
clearSuccess: () => setSuccess(""),
|
||||||
});
|
});
|
||||||
const listData = towerModelsQuery.data;
|
const listData = towerModelsQuery.data;
|
||||||
const listItems = useMemo(() => listData?.items ?? [], [listData?.items]);
|
const listItems = useMemo(() => listData?.items ?? [], [listData?.items]);
|
||||||
const totalItems = listData?.total ?? listItems.length;
|
const totalItems = listData?.total ?? listItems.length;
|
||||||
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
|
|
||||||
const paginationMaxPage = Math.max(1, Math.ceil(totalItems / paginationPageSize));
|
const paginationMaxPage = Math.max(1, Math.ceil(totalItems / paginationPageSize));
|
||||||
const tableCurrentPage = Math.min(paginationCurrent, paginationMaxPage);
|
const tableCurrentPage = Math.min(paginationCurrent, paginationMaxPage);
|
||||||
const visibleCardModels = useMemo(
|
|
||||||
() => listItems.slice(0, cardViewPage * paginationPageSize),
|
|
||||||
[cardViewPage, listItems, paginationPageSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
@@ -297,6 +308,32 @@ export default function AdminTowerModelsPage() {
|
|||||||
void refreshList();
|
void refreshList();
|
||||||
}, [refreshList]));
|
}, [refreshList]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== "card" || towerModelsQuery.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
if (cardViewPage === 1) {
|
||||||
|
setAllLoadedModels(() => listItems);
|
||||||
|
} else {
|
||||||
|
setAllLoadedModels((previous) => {
|
||||||
|
if (listItems.length === 0) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
const existingIds = new Set(previous.map((item) => item.id));
|
||||||
|
const newModels = listItems.filter((item) => !existingIds.has(item.id));
|
||||||
|
return [...previous, ...newModels];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [cardViewPage, listItems, towerModelsQuery.isLoading, viewMode]);
|
||||||
|
|
||||||
// Handle infinite scroll for card view
|
// Handle infinite scroll for card view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode !== "card") return;
|
if (viewMode !== "card") return;
|
||||||
@@ -308,7 +345,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
if (!cardBody) return;
|
if (!cardBody) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (towerModelsQuery.isLoading) return;
|
if (isLoadingMore || towerModelsQuery.isLoading) return;
|
||||||
|
|
||||||
const scrollTop = cardBody.scrollTop;
|
const scrollTop = cardBody.scrollTop;
|
||||||
const scrollHeight = cardBody.scrollHeight;
|
const scrollHeight = cardBody.scrollHeight;
|
||||||
@@ -316,17 +353,19 @@ export default function AdminTowerModelsPage() {
|
|||||||
|
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||||
const total = totalItems;
|
const total = totalItems;
|
||||||
const loadedCount = visibleCardModels.length;
|
const loadedCount = allLoadedModels.length;
|
||||||
|
|
||||||
if (loadedCount < total) {
|
if (loadedCount < total) {
|
||||||
|
setIsLoadingMore(true);
|
||||||
setCardViewPage((prev) => prev + 1);
|
setCardViewPage((prev) => prev + 1);
|
||||||
|
setPagination((prev) => ({ ...prev, current: prev.current + 1 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
cardBody.addEventListener("scroll", handleScroll);
|
cardBody.addEventListener("scroll", handleScroll);
|
||||||
return () => cardBody.removeEventListener("scroll", handleScroll);
|
return () => cardBody.removeEventListener("scroll", handleScroll);
|
||||||
}, [viewMode, towerModelsQuery.isLoading, totalItems, visibleCardModels.length]);
|
}, [viewMode, isLoadingMore, towerModelsQuery.isLoading, totalItems, allLoadedModels.length]);
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: TowerModelFormValues) => {
|
mutationFn: async (values: TowerModelFormValues) => {
|
||||||
@@ -354,8 +393,8 @@ export default function AdminTowerModelsPage() {
|
|||||||
return "created" as const;
|
return "created" as const;
|
||||||
},
|
},
|
||||||
onSuccess: async (mode) => {
|
onSuccess: async (mode) => {
|
||||||
|
setSuccess(mode === "created" ? "杆塔模型已创建" : "杆塔模型已更新");
|
||||||
setError("");
|
setError("");
|
||||||
messageApi.success(mode === "created" ? "杆塔模型已创建" : "杆塔模型已更新");
|
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setEditingModel(null);
|
setEditingModel(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -374,8 +413,8 @@ export default function AdminTowerModelsPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
setSuccess("杆塔模型已删除");
|
||||||
setError("");
|
setError("");
|
||||||
messageApi.success("杆塔模型已删除");
|
|
||||||
await refreshList();
|
await refreshList();
|
||||||
},
|
},
|
||||||
onError: (candidate) => {
|
onError: (candidate) => {
|
||||||
@@ -398,8 +437,8 @@ export default function AdminTowerModelsPage() {
|
|||||||
return (await response.json()) as TowerModelImageUploadResponse;
|
return (await response.json()) as TowerModelImageUploadResponse;
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
setSuccess("模型图片上传成功");
|
||||||
setError("");
|
setError("");
|
||||||
messageApi.success("模型图片上传成功");
|
|
||||||
setUploadModel(null);
|
setUploadModel(null);
|
||||||
await refreshList();
|
await refreshList();
|
||||||
},
|
},
|
||||||
@@ -409,16 +448,24 @@ export default function AdminTowerModelsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
setEditingModel(null);
|
setEditingModel(null);
|
||||||
form.setFieldsValue(EMPTY_FORM);
|
form.setFieldsValue(EMPTY_FORM);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = useCallback((item: TowerModelSummary) => {
|
const openEdit = useCallback((item: TowerModelSummary) => {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
setEditingModel(item);
|
setEditingModel(item);
|
||||||
form.setFieldsValue(toEditValues(item));
|
form.setFieldsValue(toEditValues(item));
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
const isDeletingModel = deleteMutation.isPending;
|
||||||
|
const isSavingModel = saveMutation.isPending;
|
||||||
|
const isUploadingImage = uploadImageMutation.isPending;
|
||||||
|
const deleteModelAsync = deleteMutation.mutateAsync;
|
||||||
|
|
||||||
const handleKeywordChange = (value: string) => {
|
const handleKeywordChange = (value: string) => {
|
||||||
setKeywordInput(value);
|
setKeywordInput(value);
|
||||||
@@ -428,9 +475,10 @@ export default function AdminTowerModelsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keywordDebounceTimeoutRef.current = setTimeout(() => {
|
keywordDebounceTimeoutRef.current = setTimeout(() => {
|
||||||
setKeyword(value);
|
setSearchKeyword(value);
|
||||||
setPagination((previous) => ({ ...previous, current: 1 }));
|
setPagination((previous) => ({ ...previous, current: 1 }));
|
||||||
setCardViewPage(1);
|
setCardViewPage(1);
|
||||||
|
setAllLoadedModels([]);
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,7 +529,8 @@ export default function AdminTowerModelsPage() {
|
|||||||
title: "状态",
|
title: "状态",
|
||||||
dataIndex: "is_enabled",
|
dataIndex: "is_enabled",
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (value: boolean) => <Tag color={value ? "success" : "default"}>{value ? "启用" : "禁用"}</Tag>,
|
align: "center",
|
||||||
|
render: (value: boolean) => <Tag color={value ? "green" : "default"}>{value ? "启用" : "禁用"}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "排序",
|
title: "排序",
|
||||||
@@ -491,23 +540,27 @@ export default function AdminTowerModelsPage() {
|
|||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 120,
|
width: 180,
|
||||||
fixed: "right",
|
|
||||||
render: (_: unknown, row) => {
|
render: (_: unknown, row) => {
|
||||||
|
const rowBusy = isDeletingModel || isSavingModel || isUploadingImage;
|
||||||
const moreMenuItems: MenuProps["items"] = [
|
const moreMenuItems: MenuProps["items"] = [
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
label: "删除",
|
label: "删除",
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: deleteMutation.isPending,
|
disabled: rowBusy,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size="small" wrap>
|
<Space wrap>
|
||||||
{canManage && <Button size="small" onClick={() => openEdit(row)}>编辑</Button>}
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button size="small" onClick={() => setUploadModel(row)}>
|
<Button size="small" disabled={rowBusy} onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button size="small" disabled={rowBusy} onClick={() => setUploadModel(row)}>
|
||||||
上传图片
|
上传图片
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -524,7 +577,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
cancelText: "取消",
|
cancelText: "取消",
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await deleteMutation.mutateAsync(row.id);
|
await deleteModelAsync(row.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -532,7 +585,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
}}
|
}}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
>
|
>
|
||||||
<Button size="small" icon={<MoreOutlined />} />
|
<Button size="small" disabled={rowBusy} icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -540,7 +593,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[canManage, deleteMutation, fetchWithAuth, handleImagePreviewError, openEdit],
|
[canManage, deleteModelAsync, fetchWithAuth, handleImagePreviewError, isDeletingModel, isSavingModel, isUploadingImage, openEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDefaultSummary = (row: TowerModelSummary) => (
|
const renderDefaultSummary = (row: TowerModelSummary) => (
|
||||||
@@ -552,7 +605,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTowerModelCard = (row: TowerModelSummary) => {
|
const renderTowerModelCard = (row: TowerModelSummary) => {
|
||||||
const rowBusy = deleteMutation.isPending;
|
const rowBusy = isDeletingModel || isSavingModel || isUploadingImage;
|
||||||
const moreMenuItems: MenuProps["items"] = [
|
const moreMenuItems: MenuProps["items"] = [
|
||||||
{
|
{
|
||||||
key: "upload-image",
|
key: "upload-image",
|
||||||
@@ -573,7 +626,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
cancelText: "取消",
|
cancelText: "取消",
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await deleteMutation.mutateAsync(row.id);
|
await deleteModelAsync(row.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -590,7 +643,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
<Typography.Text strong ellipsis={{ tooltip: row.name }}>
|
<Typography.Text strong ellipsis={{ tooltip: row.name }}>
|
||||||
{row.name}
|
{row.name}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag color={row.is_enabled ? "success" : "default"}>{row.is_enabled ? "启用" : "禁用"}</Tag>
|
<Tag color={row.is_enabled ? "green" : "default"}>{row.is_enabled ? "启用" : "禁用"}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={canManage ? (
|
extra={canManage ? (
|
||||||
@@ -671,7 +724,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.requestAnimationFrame(updateTableScrollY);
|
window.requestAnimationFrame(updateTableScrollY);
|
||||||
}, [error, listError, listItems.length, paginationCurrent, paginationPageSize, towerModelsQuery.isFetching, updateTableScrollY]);
|
}, [error, listError, listItems.length, paginationCurrent, paginationPageSize, success, towerModelsQuery.isFetching, updateTableScrollY]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -786,10 +839,11 @@ export default function AdminTowerModelsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="状态" style={{ width: 170 }}>
|
<Form.Item label="状态" style={{ width: 170 }}>
|
||||||
<Select<"all" | "enabled" | "disabled">
|
<Select<"enabled" | "disabled">
|
||||||
value={enabledFilter}
|
value={enabledFilter}
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
options={[
|
options={[
|
||||||
{ value: "all", label: "全部" },
|
|
||||||
{ value: "enabled", label: "已启用" },
|
{ value: "enabled", label: "已启用" },
|
||||||
{ value: "disabled", label: "已禁用" },
|
{ value: "disabled", label: "已禁用" },
|
||||||
]}
|
]}
|
||||||
@@ -797,6 +851,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
setEnabledFilter(value);
|
setEnabledFilter(value);
|
||||||
setPagination((previous) => ({ ...previous, current: 1 }));
|
setPagination((previous) => ({ ...previous, current: 1 }));
|
||||||
setCardViewPage(1);
|
setCardViewPage(1);
|
||||||
|
setAllLoadedModels([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -814,6 +869,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
dataSource={listItems}
|
dataSource={listItems}
|
||||||
loading={towerModelsQuery.isLoading}
|
loading={towerModelsQuery.isLoading}
|
||||||
|
tableLayout="fixed"
|
||||||
pagination={{
|
pagination={{
|
||||||
current: tableCurrentPage,
|
current: tableCurrentPage,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
@@ -827,8 +883,7 @@ export default function AdminTowerModelsPage() {
|
|||||||
setPagination({ current: page, pageSize });
|
setPagination({ current: page, pageSize });
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1450, y: tableScrollY }}
|
scroll={{ y: tableScrollY }}
|
||||||
tableLayout="fixed"
|
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -841,11 +896,11 @@ export default function AdminTowerModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-tower-models-card-view">
|
<div className="admin-tower-models-card-view">
|
||||||
{towerModelsQuery.isLoading && visibleCardModels.length === 0 ? (
|
{towerModelsQuery.isLoading && allLoadedModels.length === 0 ? (
|
||||||
<div className="admin-tower-models-card-view-state">
|
<div className="admin-tower-models-card-view-state">
|
||||||
<Spin tip="加载中..." />
|
<Spin tip="加载中..." />
|
||||||
</div>
|
</div>
|
||||||
) : visibleCardModels.length === 0 ? (
|
) : allLoadedModels.length === 0 ? (
|
||||||
<div className="admin-tower-models-card-view-state">
|
<div className="admin-tower-models-card-view-state">
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
@@ -855,21 +910,21 @@ export default function AdminTowerModelsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="admin-tower-models-card-view-content">
|
<div className="admin-tower-models-card-view-content">
|
||||||
<Row gutter={[12, 12]}>
|
<Row gutter={[12, 12]}>
|
||||||
{visibleCardModels.map((row) => (
|
{allLoadedModels.map((row) => (
|
||||||
<Col key={row.id} xs={24} sm={24} md={12} lg={8} xl={6}>
|
<Col key={row.id} xs={24} sm={24} md={12} lg={8} xl={6}>
|
||||||
{renderTowerModelCard(row)}
|
{renderTowerModelCard(row)}
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
{towerModelsQuery.isFetching && visibleCardModels.length < totalItems && (
|
{isLoadingMore && (
|
||||||
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
||||||
<Spin tip="加载更多..." />
|
<Spin tip="加载更多..." />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleCardModels.length >= totalItems && visibleCardModels.length > 0 && (
|
{allLoadedModels.length >= totalItems && allLoadedModels.length > 0 && (
|
||||||
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
已加载全部 {visibleCardModels.length} 条数据
|
已加载全部 {allLoadedModels.length} 条数据
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
|
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider";
|
import { useAuth } from "@/components/auth-provider";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
@@ -33,7 +33,7 @@ import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
|||||||
import { readApiError } from "@/lib/api";
|
import { readApiError } from "@/lib/api";
|
||||||
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
|
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
type UserRolePayload = {
|
type UserRolePayload = {
|
||||||
role_codes: string[];
|
role_codes: string[];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, ComponentType } from "react";
|
import type { CSSProperties, ComponentType, RefAttributes } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from "@/lib/task-monitor-display";
|
} from "@/lib/task-monitor-display";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
const DEFAULT_RECENT_LIMIT = 100;
|
const DEFAULT_RECENT_LIMIT = 100;
|
||||||
const WORKERS_TABLE_MIN_SCROLL_Y = 180;
|
const WORKERS_TABLE_MIN_SCROLL_Y = 180;
|
||||||
|
|||||||
@@ -974,7 +974,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-tower-models-table-anchor .ant-table-body {
|
.admin-tower-models-table-anchor .ant-table-body {
|
||||||
min-height: var(--admin-tower-models-table-body-min-height, 220px);
|
min-height: var(--admin-tower-models-table-body-min-height, 180px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tower-models-page-card {
|
.admin-tower-models-page-card {
|
||||||
@@ -1038,7 +1038,7 @@ body {
|
|||||||
|
|
||||||
.admin-tower-models-model-card-field {
|
.admin-tower-models-model-card-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px minmax(0, 1fr);
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user