Browse Source

Fix #35: 价格表页面增加分页功能 (#37)

* fix: 修复用户分组过滤功能 - 补全 providerGroup 字段查询 (#31)

修复问题:
validateApiKeyAndGetUser 函数在查询用户信息时遗漏了 providerGroup 字段,导致 session.authState.user.providerGroup 始终为 undefined,供应商选择器中的用户分组过滤逻辑无法生效。

修改内容:
- 在 SELECT 查询中添加 userProviderGroup: users.providerGroup
- 在构建 User 对象时传递 providerGroup: row.userProviderGroup

影响范围:
- 恢复用户分组过滤功能,使设置了 providerGroup 的用户请求能够正确过滤到对应 groupTag 的供应商
- 向后兼容,未设置 providerGroup 的用户行为不变
- 无需数据库迁移或环境变更"

Co-authored-by: GuShenghua <[email protected]>

* chore: sync VERSION file with release v0.2.13 [skip ci]

* feat: 价格表页面增加分页功能,close #35

- 添加分页接口 PaginationParams 和 PaginatedResult
- 新增 findAllLatestPricesPaginated 分页查询方法
- 新增 getModelPricesPaginated action 函数
- 新增 /api/prices API 端点支持分页查询
- 重构 PriceList 组件支持分页、页面大小选择、搜索
- 修改价格表页面支持分页参数和降级处理
- 支持URL参数同步分页状态
- 优化性能,避免一次性加载大量数据

* fix: 修复价格表分页功能的审查意见

主要改进:
- 添加 /api/prices 端点权限检查,确保只有管理员可访问
- 实现 SQL 层面的搜索过滤,提升大数据量场景下的性能
- 添加搜索防抖机制(500ms),优化用户体验
- 移除客户端过滤逻辑,统一使用后端搜索
- 优化 useEffect 依赖,避免不必要的 API 请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: gsh <[email protected]>
Co-authored-by: GuShenghua <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Assistant <[email protected]>
Co-authored-by: ding113 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Ding <[email protected]>
claude[bot] 4 months ago
parent
commit
4e4a75e81a

+ 33 - 0
src/actions/model-prices.ts

@@ -7,7 +7,10 @@ import {
   findLatestPriceByModel,
   createModelPrice,
   findAllLatestPrices,
+  findAllLatestPricesPaginated,
   hasAnyPriceRecords,
+  type PaginationParams,
+  type PaginatedResult,
 } from "@/repository/model-price";
 import type {
   PriceTableJson,
@@ -135,6 +138,36 @@ export async function getModelPrices(): Promise<ModelPrice[]> {
   }
 }
 
+/**
+ * 分页获取所有模型的最新价格
+ */
+export async function getModelPricesPaginated(
+  params: PaginationParams
+): Promise<ActionResult<PaginatedResult<ModelPrice>>> {
+  try {
+    // 权限检查:只有管理员可以查看价格表
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: "无权限执行此操作",
+      };
+    }
+
+    const result = await findAllLatestPricesPaginated(params);
+    return {
+      ok: true,
+      data: result,
+    };
+  } catch (error) {
+    logger.error("获取模型价格失败:", error);
+    return {
+      ok: false,
+      error: "获取价格数据失败,请稍后重试",
+    };
+  }
+}
+
 /**
  * 检查是否存在价格表数据
  */

+ 65 - 0
src/app/api/prices/route.ts

@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getModelPricesPaginated } from "@/actions/model-prices";
+import type { PaginationParams } from "@/repository/model-price";
+import { getSession } from "@/lib/auth";
+
+/**
+ * GET /api/prices
+ *
+ * 查询参数:
+ * - page: 页码 (默认: 1)
+ * - pageSize: 每页大小 (默认: 50)
+ * - search: 搜索关键词 (可选)
+ */
+export async function GET(request: NextRequest) {
+  try {
+    // 权限检查:只有管理员可以访问价格数据
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return NextResponse.json(
+        { ok: false, error: "无权限访问此资源" },
+        { status: 403 }
+      );
+    }
+
+    const { searchParams } = new URL(request.url);
+
+    // 解析查询参数
+    const page = parseInt(searchParams.get('page') || '1', 10);
+    const pageSize = parseInt(searchParams.get('pageSize') || searchParams.get('size') || '50', 10);
+    const search = searchParams.get('search') || '';
+
+    // 参数验证
+    if (page < 1) {
+      return NextResponse.json(
+        { ok: false, error: '页码必须大于0' },
+        { status: 400 }
+      );
+    }
+
+    if (pageSize < 1 || pageSize > 200) {
+      return NextResponse.json(
+        { ok: false, error: '每页大小必须在1-200之间' },
+        { status: 400 }
+      );
+    }
+
+    // 构建分页参数
+    const paginationParams: PaginationParams = {
+      page,
+      pageSize,
+      search: search || undefined,  // 传递搜索关键词给后端
+    };
+
+    // 获取分页数据(搜索在 SQL 层面执行)
+    const result = await getModelPricesPaginated(paginationParams);
+
+    return NextResponse.json(result);
+  } catch (error) {
+    console.error('获取价格数据失败:', error);
+    return NextResponse.json(
+      { ok: false, error: '服务器内部错误' },
+      { status: 500 }
+    );
+  }
+}

+ 222 - 20
src/app/settings/prices/_components/price-list.tsx

@@ -1,8 +1,9 @@
 "use client";
 
-import { useState } from "react";
-import { Search, Package, DollarSign } from "lucide-react";
+import { useState, useEffect } from "react";
+import { Search, Package, DollarSign, ChevronLeft, ChevronRight } from "lucide-react";
 import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
 import {
   Table,
   TableBody,
@@ -12,22 +13,134 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { Badge } from "@/components/ui/badge";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import type { ModelPrice } from "@/types/model-price";
+import { useDebounce } from "@/lib/hooks/use-debounce";
 
 interface PriceListProps {
-  prices: ModelPrice[];
+  initialPrices: ModelPrice[];
+  initialTotal: number;
+  initialPage: number;
+  initialPageSize: number;
 }
 
 /**
- * 价格列表组件
+ * 价格列表组件(支持分页)
  */
-export function PriceList({ prices }: PriceListProps) {
+export function PriceList({
+  initialPrices,
+  initialTotal,
+  initialPage,
+  initialPageSize
+}: PriceListProps) {
   const [searchTerm, setSearchTerm] = useState("");
+  const [prices, setPrices] = useState<ModelPrice[]>(initialPrices);
+  const [total, setTotal] = useState(initialTotal);
+  const [page, setPage] = useState(initialPage);
+  const [pageSize, setPageSize] = useState(initialPageSize);
+  const [isLoading, setIsLoading] = useState(false);
 
-  // 过滤价格数据
-  const filteredPrices = prices.filter((price) =>
-    price.modelName.toLowerCase().includes(searchTerm.toLowerCase())
-  );
+  // 使用防抖,避免频繁请求
+  const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+  // 计算总页数
+  const totalPages = Math.ceil(total / pageSize);
+
+  // 从 URL 搜索参数中读取初始状态(仅在挂载时执行一次)
+  useEffect(() => {
+    const urlParams = new URLSearchParams(window.location.search);
+    const searchParam = urlParams.get('search');
+    const pageParam = urlParams.get('page');
+    const sizeParam = urlParams.get('size');
+
+    if (searchParam) setSearchTerm(searchParam);
+    if (pageParam) setPage(parseInt(pageParam, 10));
+    if (sizeParam) setPageSize(parseInt(sizeParam, 10));
+  }, []);  // 空依赖数组,仅在挂载时执行一次
+
+  // 更新 URL 搜索参数
+  const updateURL = (newSearchTerm: string, newPage: number, newPageSize: number) => {
+    const url = new URL(window.location.href);
+    if (newSearchTerm) {
+      url.searchParams.set('search', newSearchTerm);
+    } else {
+      url.searchParams.delete('search');
+    }
+    if (newPage > 1) {
+      url.searchParams.set('page', newPage.toString());
+    } else {
+      url.searchParams.delete('page');
+    }
+    if (newPageSize !== 50) {
+      url.searchParams.set('size', newPageSize.toString());
+    } else {
+      url.searchParams.delete('size');
+    }
+    window.history.replaceState({}, '', url.toString());
+  };
+
+  // 获取价格数据
+  const fetchPrices = async (newPage: number, newPageSize: number, newSearchTerm: string) => {
+    setIsLoading(true);
+    try {
+      const response = await fetch(`/api/prices?page=${newPage}&pageSize=${newPageSize}&search=${encodeURIComponent(newSearchTerm)}`);
+      const result = await response.json();
+
+      if (result.ok) {
+        setPrices(result.data.data);
+        setTotal(result.data.total);
+        setPage(result.data.page);
+        setPageSize(result.data.pageSize);
+      }
+    } catch (error) {
+      console.error('获取价格数据失败:', error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // 当防抖后的搜索词变化时,触发搜索(重置到第一页)
+  useEffect(() => {
+    // 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时)
+    if (debouncedSearchTerm !== searchTerm) return;
+
+    const newPage = 1;  // 搜索时重置到第一页
+    setPage(newPage);
+    updateURL(debouncedSearchTerm, newPage, pageSize);
+    fetchPrices(newPage, pageSize, debouncedSearchTerm);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [debouncedSearchTerm]);  // 仅依赖 debouncedSearchTerm
+
+  // 搜索输入处理(只更新状态,不触发请求)
+  const handleSearchChange = (value: string) => {
+    setSearchTerm(value);
+  };
+
+  // 页面大小变化处理
+  const handlePageSizeChange = (newPageSize: number) => {
+    const newPage = Math.max(1, Math.min(page, Math.ceil(total / newPageSize)));
+    setPageSize(newPageSize);
+    setPage(newPage);
+    updateURL(debouncedSearchTerm, newPage, newPageSize);
+    fetchPrices(newPage, newPageSize, debouncedSearchTerm);
+  };
+
+  // 页面跳转处理
+  const handlePageChange = (newPage: number) => {
+    if (newPage < 1 || newPage > totalPages) return;
+    setPage(newPage);
+    updateURL(debouncedSearchTerm, newPage, pageSize);
+    fetchPrices(newPage, pageSize, debouncedSearchTerm);
+  };
+
+  // 移除客户端过滤逻辑(现在由后端处理)
+  const filteredPrices = prices;
 
   /**
    * 格式化价格显示为每百万token的价格
@@ -66,15 +179,31 @@ export function PriceList({ prices }: PriceListProps) {
 
   return (
     <div className="space-y-4">
-      {/* 搜索栏 */}
-      <div className="relative">
-        <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
-        <Input
-          placeholder="搜索模型名称..."
-          value={searchTerm}
-          onChange={(e) => setSearchTerm(e.target.value)}
-          className="pl-9"
-        />
+      {/* 搜索和页面大小控制 */}
+      <div className="flex items-center gap-4">
+        <div className="relative flex-1">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+          <Input
+            placeholder="搜索模型名称..."
+            value={searchTerm}
+            onChange={(e) => handleSearchChange(e.target.value)}
+            className="pl-9"
+          />
+        </div>
+        <div className="flex items-center gap-2">
+          <span className="text-sm text-muted-foreground">每页显示:</span>
+          <Select value={pageSize.toString()} onValueChange={(value) => handlePageSizeChange(parseInt(value, 10))}>
+            <SelectTrigger className="w-20">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="20">20</SelectItem>
+              <SelectItem value="50">50</SelectItem>
+              <SelectItem value="100">100</SelectItem>
+              <SelectItem value="200">200</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
       </div>
 
       {/* 价格表格 */}
@@ -91,7 +220,16 @@ export function PriceList({ prices }: PriceListProps) {
             </TableRow>
           </TableHeader>
           <TableBody>
-            {filteredPrices.length > 0 ? (
+            {isLoading ? (
+              <TableRow>
+                <TableCell colSpan={6} className="text-center py-8">
+                  <div className="flex items-center justify-center gap-2 text-muted-foreground">
+                    <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-current"></div>
+                    <span>加载中...</span>
+                  </div>
+                </TableCell>
+              </TableRow>
+            ) : filteredPrices.length > 0 ? (
               filteredPrices.map((price) => (
                 <TableRow key={price.id}>
                   <TableCell className="font-mono text-sm whitespace-normal break-words">
@@ -150,11 +288,75 @@ export function PriceList({ prices }: PriceListProps) {
         </Table>
       </div>
 
+      {/* 分页控件 */}
+      {totalPages > 1 && (
+        <div className="flex items-center justify-between">
+          <div className="text-sm text-muted-foreground">
+            显示第 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条,共 {total} 条记录
+          </div>
+          <div className="flex items-center gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handlePageChange(page - 1)}
+              disabled={page <= 1 || isLoading}
+            >
+              <ChevronLeft className="h-4 w-4" />
+              上一页
+            </Button>
+
+            <div className="flex items-center gap-1">
+              {/* 页码显示逻辑 */}
+              {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
+                let pageNum;
+                if (totalPages <= 5) {
+                  pageNum = i + 1;
+                } else if (page <= 3) {
+                  pageNum = i + 1;
+                } else if (page >= totalPages - 2) {
+                  pageNum = totalPages - 4 + i;
+                } else {
+                  pageNum = page - 2 + i;
+                }
+
+                return (
+                  <Button
+                    key={pageNum}
+                    variant={page === pageNum ? "default" : "outline"}
+                    size="sm"
+                    onClick={() => handlePageChange(pageNum)}
+                    disabled={isLoading}
+                    className="w-8 h-8"
+                  >
+                    {pageNum}
+                  </Button>
+                );
+              })}
+            </div>
+
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handlePageChange(page + 1)}
+              disabled={page >= totalPages || isLoading}
+            >
+              下一页
+              <ChevronRight className="h-4 w-4" />
+            </Button>
+          </div>
+        </div>
+      )}
+
       {/* 统计信息 */}
       <div className="flex items-center justify-between text-sm text-muted-foreground">
         <div className="flex items-center gap-1">
           <DollarSign className="h-4 w-4" />
-          <span>共 {filteredPrices.length} 个模型价格</span>
+          <span>共 {total} 个模型价格</span>
+          {searchTerm && (
+            <span className="text-muted-foreground">
+              (搜索结果:{filteredPrices.length} 个)
+            </span>
+          )}
         </div>
         <div>
           最后更新:

+ 43 - 5
src/app/settings/prices/page.tsx

@@ -1,4 +1,4 @@
-import { getModelPrices } from "@/actions/model-prices";
+import { getModelPrices, getModelPricesPaginated } from "@/actions/model-prices";
 import { Section } from "@/components/section";
 import { PriceList } from "./_components/price-list";
 import { UploadPriceDialog } from "./_components/upload-price-dialog";
@@ -8,14 +8,47 @@ import { SettingsPageHeader } from "../_components/settings-page-header";
 export const dynamic = "force-dynamic";
 
 interface SettingsPricesPageProps {
-  searchParams: Promise<{ required?: string }>;
+  searchParams: Promise<{
+    required?: string;
+    page?: string;
+    pageSize?: string;
+    size?: string;
+    search?: string;
+  }>;
 }
 
 export default async function SettingsPricesPage({ searchParams }: SettingsPricesPageProps) {
   const params = await searchParams;
-  const prices = await getModelPrices();
+
+  // 解析分页参数
+  const page = parseInt(params.page || '1', 10);
+  const pageSize = parseInt(params.pageSize || params.size || '50', 10);
+
+  // 获取分页数据(搜索在客户端处理)
+  const pricesResult = await getModelPricesPaginated({ page, pageSize });
   const isRequired = params.required === "true";
-  const isEmpty = prices.length === 0;
+
+  // 如果获取分页数据失败,降级到获取所有数据
+  let initialPrices = [];
+  let initialTotal = 0;
+  let initialPage = page;
+  let initialPageSize = pageSize;
+
+  if (pricesResult.ok) {
+    initialPrices = pricesResult.data!.data;
+    initialTotal = pricesResult.data!.total;
+    initialPage = pricesResult.data!.page;
+    initialPageSize = pricesResult.data!.pageSize;
+  } else {
+    // 降级处理:获取所有数据
+    const allPrices = await getModelPrices();
+    initialPrices = allPrices;
+    initialTotal = allPrices.length;
+    initialPage = 1;
+    initialPageSize = allPrices.length; // 显示所有数据
+  }
+
+  const isEmpty = initialTotal === 0;
 
   return (
     <>
@@ -31,7 +64,12 @@ export default async function SettingsPricesPage({ searchParams }: SettingsPrice
           </div>
         }
       >
-        <PriceList prices={prices} />
+        <PriceList
+          initialPrices={initialPrices}
+          initialTotal={initialTotal}
+          initialPage={initialPage}
+          initialPageSize={initialPageSize}
+        />
       </Section>
     </>
   );

+ 27 - 0
src/lib/hooks/use-debounce.ts

@@ -0,0 +1,27 @@
+import { useEffect, useState } from "react";
+
+/**
+ * 防抖 Hook
+ * 延迟更新值,避免频繁触发
+ *
+ * @param value - 需要防抖的值
+ * @param delay - 延迟时间(毫秒),默认 500ms
+ * @returns 防抖后的值
+ */
+export function useDebounce<T>(value: T, delay: number = 500): T {
+  const [debouncedValue, setDebouncedValue] = useState<T>(value);
+
+  useEffect(() => {
+    // 设置定时器,延迟更新值
+    const handler = setTimeout(() => {
+      setDebouncedValue(value);
+    }, delay);
+
+    // 清理函数:组件卸载或值变化时清除定时器
+    return () => {
+      clearTimeout(handler);
+    };
+  }, [value, delay]);
+
+  return debouncedValue;
+}

+ 105 - 1
src/repository/model-price.ts

@@ -6,6 +6,26 @@ import { eq, desc, sql } from "drizzle-orm";
 import type { ModelPrice, ModelPriceData } from "@/types/model-price";
 import { toModelPrice } from "./_shared/transformers";
 
+/**
+ * 分页查询参数
+ */
+export interface PaginationParams {
+  page: number;
+  pageSize: number;
+  search?: string;  // 可选的搜索关键词
+}
+
+/**
+ * 分页查询结果
+ */
+export interface PaginatedResult<T> {
+  data: T[];
+  total: number;
+  page: number;
+  pageSize: number;
+  totalPages: number;
+}
+
 /**
  * 获取指定模型的最新价格
  */
@@ -28,7 +48,7 @@ export async function findLatestPriceByModel(modelName: string): Promise<ModelPr
 }
 
 /**
- * 获取所有模型的最新价格
+ * 获取所有模型的最新价格(非分页版本,保持向后兼容)
  * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数
  */
 export async function findAllLatestPrices(): Promise<ModelPrice[]> {
@@ -68,6 +88,90 @@ export async function findAllLatestPrices(): Promise<ModelPrice[]> {
   return Array.from(result).map(toModelPrice);
 }
 
+/**
+ * 分页获取所有模型的最新价格
+ * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数
+ */
+export async function findAllLatestPricesPaginated(
+  params: PaginationParams
+): Promise<PaginatedResult<ModelPrice>> {
+  const { page, pageSize, search } = params;
+  const offset = (page - 1) * pageSize;
+
+  // 先获取总数
+  const countQuery = sql`
+    WITH latest_prices AS (
+      SELECT
+        model_name,
+        MAX(created_at) as max_created_at
+      FROM model_prices
+      ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``}
+      GROUP BY model_name
+    ),
+    latest_records AS (
+      SELECT
+        mp.id,
+        ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn
+      FROM model_prices mp
+      INNER JOIN latest_prices lp
+        ON mp.model_name = lp.model_name
+        AND mp.created_at = lp.max_created_at
+    )
+    SELECT COUNT(*) as total
+    FROM latest_records
+    WHERE rn = 1
+  `;
+
+  const [countResult] = await db.execute(countQuery);
+  const total = Number(countResult.total);
+
+  // 获取分页数据
+  const dataQuery = sql`
+    WITH latest_prices AS (
+      SELECT
+        model_name,
+        MAX(created_at) as max_created_at
+      FROM model_prices
+      ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``}
+      GROUP BY model_name
+    ),
+    latest_records AS (
+      SELECT
+        mp.id,
+        mp.model_name,
+        mp.price_data,
+        mp.created_at,
+        mp.updated_at,
+        ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn
+      FROM model_prices mp
+      INNER JOIN latest_prices lp
+        ON mp.model_name = lp.model_name
+        AND mp.created_at = lp.max_created_at
+    )
+    SELECT
+      id,
+      model_name as "modelName",
+      price_data as "priceData",
+      created_at as "createdAt",
+      updated_at as "updatedAt"
+    FROM latest_records
+    WHERE rn = 1
+    ORDER BY model_name
+    LIMIT ${pageSize} OFFSET ${offset}
+  `;
+
+  const result = await db.execute(dataQuery);
+  const data = Array.from(result).map(toModelPrice);
+
+  return {
+    data,
+    total,
+    page,
+    pageSize,
+    totalPages: Math.ceil(total / pageSize),
+  };
+}
+
 /**
  * 检查是否存在任意价格记录
  */