From 6f4e84ab0da34e1420b4edb413df9189ec3af34d Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:25:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?style:=20=E6=96=B9=E6=B3=95=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/apps/oauth/models.go | 53 +++++++++++++++++++++++++++++++++ internal/apps/oauth/tasks.go | 55 ++--------------------------------- 2 files changed, 56 insertions(+), 52 deletions(-) diff --git a/internal/apps/oauth/models.go b/internal/apps/oauth/models.go index d6ae48d..5cb7db8 100644 --- a/internal/apps/oauth/models.go +++ b/internal/apps/oauth/models.go @@ -25,6 +25,14 @@ package oauth import ( + "context" + "encoding/json" + "fmt" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" + "github.com/linux-do/cdk/internal/utils" + "net/http" "time" "gorm.io/gorm" @@ -75,3 +83,48 @@ func (u *User) SetScore(tx *gorm.DB, newScore int) error { func (u *User) RiskLevel() int8 { return BaseUserScore - u.Score } + +func (u *User) GetUserBadges(ctx context.Context) (*UserBadgeResponse, error) { + url := fmt.Sprintf("https://linux.do/user-badges/%s.json", u.Username) + resp, err := utils.Request(ctx, http.MethodGet, url, nil, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取用户徽章失败,状态码: %d", resp.StatusCode) + } + + var response UserBadgeResponse + if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("解析用户徽章响应失败: %w", err) + } + return &response, nil +} + +func (u *User) CalculateUserScore(badges []Badge, badgeScores map[int]int) int { + var totalScore int + for _, badge := range badges { + // 从缓存中查找徽章分数 + if score, exists := badgeScores[badge.ID]; exists { + totalScore += score + } + } + return totalScore - int(config.Config.ProjectApp.DeductionPerOffense)*int(u.ViolationCount) +} + +func (u *User) UpdateUserScore(ctx context.Context, newScore int) error { + // 如果分数没变化,不更新 + if int(u.Score) == newScore || (newScore > MaxUserScore && int(u.Score) == MaxUserScore) { + return nil + } + + // 更新用户分数 + if err := u.SetScore(db.DB(ctx), newScore); err != nil { + return fmt.Errorf("更新用户[%s]分数失败: %w", u.Username, err) + } + + logger.InfoF(ctx, "用户[%s]徽章分数更新成功: %d -> %d", u.Username, u.Score, newScore) + return nil +} diff --git a/internal/apps/oauth/tasks.go b/internal/apps/oauth/tasks.go index 03e555d..a7eb944 100644 --- a/internal/apps/oauth/tasks.go +++ b/internal/apps/oauth/tasks.go @@ -74,57 +74,8 @@ func loadBadgeScores(ctx context.Context) (map[int]int, error) { return badgeScores, nil } -// getUserBadges 获取用户的徽章 -func getUserBadges(ctx context.Context, username string) (*UserBadgeResponse, error) { - url := fmt.Sprintf("https://linux.do/user-badges/%s.json", username) - resp, err := utils.Request(ctx, http.MethodGet, url, nil, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取用户徽章失败,状态码: %d", resp.StatusCode) - } - - var response UserBadgeResponse - if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("解析用户徽章响应失败: %w", err) - } - return &response, nil -} - -// calculateUserScore 计算用户分数 -func calculateUserScore(user *User, badges []Badge, badgeScores map[int]int) int { - var totalScore int - for _, badge := range badges { - // 从缓存中查找徽章分数 - if score, exists := badgeScores[badge.ID]; exists { - totalScore += score - } - } - return totalScore - int(config.Config.ProjectApp.DeductionPerOffense)*int(user.ViolationCount) -} - -// updateUserScore 更新用户分数 -func updateUserScore(ctx context.Context, user *User, newScore int) error { - // 如果分数没变化,不更新 - if int(user.Score) == newScore || (newScore > MaxUserScore && int(user.Score) == MaxUserScore) { - return nil - } - - // 更新用户分数 - if err := user.SetScore(db.DB(ctx), newScore); err != nil { - return fmt.Errorf("更新用户[%s]分数失败: %w", user.Username, err) - } - - logger.InfoF(ctx, "用户[%s]徽章分数更新成功: %d -> %d", user.Username, user.Score, newScore) - return nil -} - // HandleUpdateUserBadgeScores 处理所有用户徽章分数更新任务 func HandleUpdateUserBadgeScores(ctx context.Context, t *asynq.Task) error { - // 分页处理用户 pageSize := 200 page := 0 @@ -194,17 +145,17 @@ func HandleUpdateSingleUserBadgeScore(ctx context.Context, t *asynq.Task) error } // 获取用户徽章 - response, err := getUserBadges(ctx, user.Username) + response, err := user.GetUserBadges(ctx) if err != nil { logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, err) return err } // 计算用户分数 - totalScore := calculateUserScore(&user, response.Badges, badgeScores) + totalScore := user.CalculateUserScore(response.Badges, badgeScores) // 更新用户分数 - return updateUserScore(ctx, &user, totalScore) + return user.UpdateUserScore(ctx, totalScore) } // getAllBadges 获取所有徽章数据 From b242cbd9bf80c5eadaa5f0d29b9298a9a967a4f3 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:12:24 +0800 Subject: [PATCH 2/5] feat: add search functionality and improve data handling in DataTable and DataChart... --- .../components/common/received/DataChart.tsx | 11 +- .../components/common/received/DataTable.tsx | 161 ++++-------------- .../common/received/ReceivedMain.tsx | 115 +++++++++---- .../lib/services/project/project.service.ts | 1 + frontend/lib/services/project/types.ts | 2 + internal/apps/oauth/tasks.go | 20 ++- internal/apps/project/routers.go | 68 +++++--- 7 files changed, 185 insertions(+), 193 deletions(-) diff --git a/frontend/components/common/received/DataChart.tsx b/frontend/components/common/received/DataChart.tsx index c641227..8b72406 100644 --- a/frontend/components/common/received/DataChart.tsx +++ b/frontend/components/common/received/DataChart.tsx @@ -69,6 +69,7 @@ const StatCard = ({title, value, suffix = ''}: {title: string; value: number; su export function DataChart({data}: DataChartProps) { const isMobile = useIsMobile(); const [timeRange, setTimeRange] = React.useState('7d'); + const safeData = data || []; React.useEffect(() => { if (isMobile) setTimeRange('7d'); @@ -84,7 +85,7 @@ export function DataChart({data}: DataChartProps) { const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const statsMap = new Map(); - data.forEach((item) => { + safeData.forEach((item) => { if (item.received_at) { const date = new Date(item.received_at); let key: string; @@ -139,7 +140,7 @@ export function DataChart({data}: DataChartProps) { count: statsMap.get(dateKey) || 0, }; }); - }, [data, timeRange]); + }, [safeData, timeRange]); /** * 计算统计数据(总计、今日、本月、日均) @@ -152,7 +153,7 @@ export function DataChart({data}: DataChartProps) { let todayCount = 0; let thisMonthCount = 0; - data.forEach((item) => { + safeData.forEach((item) => { if (item.received_at) { const date = new Date(item.received_at); const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; @@ -171,12 +172,12 @@ export function DataChart({data}: DataChartProps) { const avgDaily = currentDay > 0 ? Math.round(thisMonthCount / currentDay * 10) / 10 : 0; return { - total: data.length, + total: safeData.length, today: todayCount, thisMonth: thisMonthCount, avgDaily, }; - }, [data]); + }, [safeData]); const containerVariants = { hidden: {opacity: 0, y: 20}, diff --git a/frontend/components/common/received/DataTable.tsx b/frontend/components/common/received/DataTable.tsx index 32fc21e..95f09bf 100644 --- a/frontend/components/common/received/DataTable.tsx +++ b/frontend/components/common/received/DataTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, {useState, useMemo, useEffect} from 'react'; +import React from 'react'; import Link from 'next/link'; import {Input} from '@/components/ui/input'; import {Button} from '@/components/ui/button'; @@ -12,12 +12,7 @@ import {EmptyState} from '@/components/common/layout/EmptyState'; import {motion} from 'motion/react'; import {useIsMobile} from '@/hooks/use-mobile'; -const ITEMS_PER_PAGE = 20; const MAX_PAGINATION_BUTTONS = 5; -const SORT_DIRECTIONS = { - ASC: 'asc' as const, - DESC: 'desc' as const, -}; /** * 数据表格组件的Props接口 @@ -25,11 +20,20 @@ const SORT_DIRECTIONS = { interface DataTableProps { /** 领取历史数据 */ data: ReceiveHistoryItem[]; + /** 当前页码 */ + currentPage: number; + /** 总数据条数 */ + totalItems: number; + /** 每页条数 */ + pageSize: number; + /** 页码变更回调 */ + onPageChange: (page: number) => void; + /** 搜索词 */ + searchTerm: string; + /** 搜索词变更回调 */ + onSearchChange: (search: string) => void; } -type SortField = keyof ReceiveHistoryItem; -type SortDirection = typeof SORT_DIRECTIONS[keyof typeof SORT_DIRECTIONS]; - /** * 打开项目详情页 */ @@ -39,58 +43,20 @@ const openProjectDetail = (projectId: string): void => { window.open(url, '_blank'); }; -/** - * 数据过滤和排序处理 - */ -const processData = ( - data: ReceiveHistoryItem[], - searchTerm: string, - sortField: SortField, - sortDirection: SortDirection, -): ReceiveHistoryItem[] => { - let filtered = data; - - if (searchTerm) { - const term = searchTerm.toLowerCase(); - filtered = data.filter((item) => - item.project_name.toLowerCase().includes(term) || - item.project_creator.toLowerCase().includes(term) || - item.project_creator_nickname.toLowerCase().includes(term), - ); - } - - return filtered.sort((a, b) => { - let aValue: string | number | null = a[sortField]; - let bValue: string | number | null = b[sortField]; - - if (sortField === 'received_at') { - const aTime = aValue ? new Date(aValue as string).getTime() : 0; - const bTime = bValue ? new Date(bValue as string).getTime() : 0; - aValue = aTime; - bValue = bTime; - } - - if (aValue === null || aValue === undefined) return 1; - if (bValue === null || bValue === undefined) return -1; - - return sortDirection === SORT_DIRECTIONS.ASC ? - (aValue < bValue ? -1 : aValue > bValue ? 1 : 0) : - (aValue > bValue ? -1 : aValue < bValue ? 1 : 0); - }); -}; - /** * 分页组件 */ const Pagination = ({ currentPage, totalPages, - dataLength, + totalItems, + pageSize, onPageChange, }: { currentPage: number totalPages: number - dataLength: number + totalItems: number + pageSize: number onPageChange: (page: number) => void }) => { const generatePageNumbers = () => { @@ -114,10 +80,10 @@ const Pagination = ({ return (
- {dataLength === 0 ? ( + {totalItems === 0 ? ( '无数据' ) : ( - `第 ${((currentPage - 1) * ITEMS_PER_PAGE) + 1}-${Math.min(currentPage * ITEMS_PER_PAGE, dataLength)} 条,共 ${dataLength} 条` + `第 ${((currentPage - 1) * pageSize) + 1}-${Math.min(currentPage * pageSize, totalItems)} 条,共 ${totalItems} 条` )}
@@ -126,7 +92,7 @@ const Pagination = ({ variant="ghost" size="sm" onClick={() => onPageChange(currentPage - 1)} - disabled={currentPage === 1 || dataLength === 0} + disabled={currentPage === 1 || totalItems === 0} className="h-7 w-7 p-0" > @@ -180,7 +146,7 @@ const Pagination = ({ variant="ghost" size="sm" onClick={() => onPageChange(currentPage + 1)} - disabled={currentPage === totalPages || dataLength === 0} + disabled={currentPage === totalPages || totalItems === 0} className="h-7 w-7 p-0" > @@ -193,42 +159,10 @@ const Pagination = ({ /** * 数据表格组件 */ -export function DataTable({data}: DataTableProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [sortField, setSortField] = useState('received_at'); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTIONS.DESC); - const [currentPage, setCurrentPage] = useState(1); +export function DataTable({data, currentPage, totalItems, pageSize, onPageChange, searchTerm, onSearchChange}: DataTableProps) { const isMobile = useIsMobile(); - - useEffect(() => { - setCurrentPage(1); - }, [searchTerm]); - - const sortedAndFilteredData = useMemo( - () => processData(data, searchTerm, sortField, sortDirection), - [data, searchTerm, sortField, sortDirection], - ); - - const totalPages = Math.ceil(sortedAndFilteredData.length / ITEMS_PER_PAGE); - const paginatedData = sortedAndFilteredData.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE, - ); - - const handleSort = (field: SortField) => { - if (field === sortField) { - setSortDirection(sortDirection === SORT_DIRECTIONS.ASC ? SORT_DIRECTIONS.DESC : SORT_DIRECTIONS.ASC); - } else { - setSortField(field); - setSortDirection(SORT_DIRECTIONS.DESC); - } - setCurrentPage(1); - }; - - const renderSortIcon = (field: SortField) => { - if (field !== sortField) return null; - return sortDirection === SORT_DIRECTIONS.ASC ? ' ↑' : ' ↓'; - }; + const totalPages = Math.ceil(totalItems / pageSize); + const safeData = data || []; const containerVariants = { hidden: {opacity: 0, y: 20}, @@ -268,7 +202,7 @@ export function DataTable({data}: DataTableProps) { setSearchTerm(e.target.value)} + onChange={(e) => onSearchChange(e.target.value)} className="pl-8 w-48" />
@@ -278,43 +212,15 @@ export function DataTable({data}: DataTableProps) { - - - - - - - - - - - - + 领取时间 + 项目名称 + 创建者 + 项目内容 - {paginatedData.length === 0 ? ( + {safeData.length === 0 ? ( ) : ( - paginatedData.map((item) => ( + safeData.map((item) => ( {item.received_at ? formatDateTimeWithSeconds(item.received_at) : '-'} @@ -403,8 +309,9 @@ export function DataTable({data}: DataTableProps) { diff --git a/frontend/components/common/received/ReceivedMain.tsx b/frontend/components/common/received/ReceivedMain.tsx index 07f1a2a..955745d 100644 --- a/frontend/components/common/received/ReceivedMain.tsx +++ b/frontend/components/common/received/ReceivedMain.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useState, useEffect} from 'react'; +import {useState, useEffect, useCallback} from 'react'; import {toast} from 'sonner'; import {Skeleton} from '@/components/ui/skeleton'; import {Separator} from '@/components/ui/separator'; @@ -9,8 +9,9 @@ import {DataChart, DataTable} from '@/components/common/received'; import services from '@/lib/services'; import {ReceiveHistoryItem} from '@/lib/services/project/types'; import {motion} from 'motion/react'; +import {useDebounce} from '@/hooks/use-debounce'; -const PAGE_SIZE = 100; +const PAGE_SIZE = 20; /** * 数据图表骨架屏组件 @@ -136,60 +137,96 @@ const DataTableSkeleton = () => ( * 我的领取页面主组件 */ export function ReceivedMain() { - const [data, setData] = useState([]); - const [Loading, setLoading] = useState(true); + const [chartData, setChartData] = useState([]); + const [tableData, setTableData] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [chartLoading, setChartLoading] = useState(true); + const [tableLoading, setTableLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const debouncedSearch = useDebounce(searchInput, 500); /** - * 获取所有领取记录 + * 获取图表数据 */ - const fetchAllReceiveHistory = async () => { + const fetchChartData = async () => { try { - setLoading(true); + setChartLoading(true); const firstPageResult = await services.project.getReceiveHistorySafe({ current: 1, - size: PAGE_SIZE, + size: 100, }); if (!firstPageResult.success || !firstPageResult.data) { - throw new Error(firstPageResult.error || '获取数据失败'); + throw new Error(firstPageResult.error || '获取图表数据失败'); } const {total, results} = firstPageResult.data; - const allResults = [...results]; + const allResults = [...(results || [])]; - if (total > PAGE_SIZE) { - const totalPages = Math.ceil(total / PAGE_SIZE); + if (total > 100) { + const totalPages = Math.ceil(total / 100); const remainingPages = Array.from({length: totalPages - 1}, (_, i) => i + 2); const remainingRequests = remainingPages.map((page) => services.project.getReceiveHistorySafe({ current: page, - size: PAGE_SIZE, + size: 100, }), ); const remainingResults = await Promise.all(remainingRequests); remainingResults.forEach((result) => { - if (result.success && result.data) { + if (result.success && result.data && result.data.results) { allResults.push(...result.data.results); } }); } - setData(allResults); + setChartData(allResults); } catch (err) { - toast.error(err instanceof Error ? err.message : '获取数据失败'); + toast.error(err instanceof Error ? err.message : '获取图表数据失败'); } finally { - setLoading(false); + setChartLoading(false); } }; useEffect(() => { - fetchAllReceiveHistory(); + //fetchChartData(); }, []); + /** + * 获取表格数据 + */ + const fetchTableData = async (page: number, search: string) => { + try { + setTableLoading(true); + + const result = await services.project.getReceiveHistorySafe({ + current: page, + size: PAGE_SIZE, + search, + }); + + if (!result.success || !result.data) { + throw new Error(result.error || '获取表格数据失败'); + } + + setTableData(result.data.results || []); + setTotalItems(result.data.total); + } catch (err) { + toast.error(err instanceof Error ? err.message : '获取表格数据失败'); + } finally { + setTableLoading(false); + } + }; + + useEffect(() => { + fetchTableData(currentPage, debouncedSearch); + }, [currentPage, debouncedSearch]); + const containerVariants = { hidden: {opacity: 0}, visible: { @@ -211,6 +248,18 @@ export function ReceivedMain() { }, }; + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearch]); + return ( - {Loading ? ( - <> - - - - + {chartLoading ? ( + + ) : ( + + )} + + + + {tableLoading ? ( + ) : ( - <> - - - - + )} diff --git a/frontend/lib/services/project/project.service.ts b/frontend/lib/services/project/project.service.ts index d36ebfc..8875094 100644 --- a/frontend/lib/services/project/project.service.ts +++ b/frontend/lib/services/project/project.service.ts @@ -145,6 +145,7 @@ export class ProjectService extends BaseService { params: { current: params.current, size: params.size, + search: params.search || '', }, }); diff --git a/frontend/lib/services/project/types.ts b/frontend/lib/services/project/types.ts index 500be29..c847bab 100644 --- a/frontend/lib/services/project/types.ts +++ b/frontend/lib/services/project/types.ts @@ -173,6 +173,8 @@ export interface ReceiveHistoryRequest { current: number; /** 每页数量 */ size: number; + /** 搜索关键词 */ + search?: string; } /** diff --git a/internal/apps/oauth/tasks.go b/internal/apps/oauth/tasks.go index a7eb944..e3a5a07 100644 --- a/internal/apps/oauth/tasks.go +++ b/internal/apps/oauth/tasks.go @@ -28,12 +28,13 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "time" + "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/task" "github.com/linux-do/cdk/internal/task/schedule" "github.com/linux-do/cdk/internal/utils" - "net/http" - "time" "github.com/hibiken/asynq" "github.com/linux-do/cdk/internal/db" @@ -80,16 +81,23 @@ func HandleUpdateUserBadgeScores(ctx context.Context, t *asynq.Task) error { pageSize := 200 page := 0 currentDelay := 0 * time.Second + // 计算一周前日期 now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - oneWeekAgo := today.AddDate(0, 0, -6) + sessionAgeDays := config.Config.App.SessionAge / 86400 + if sessionAgeDays < 7 { + sessionAgeDays = 7 + } + oneWeekAgo := today.AddDate(0, 0, -sessionAgeDays) for { var users []User - if err := db.DB(ctx).Where("last_login_at >= ? AND is_active = ?", oneWeekAgo, true). - Select("id, username"). - Offset(page * pageSize).Limit(pageSize). + if err := db.DB(ctx). + Table("users u"). + Select("u.id, u.username"). + Joins("INNER JOIN (SELECT id FROM users WHERE last_login_at >= ? ORDER BY last_login_at DESC LIMIT ? OFFSET ?) tmp ON u.id = tmp.id", + oneWeekAgo, pageSize, page*pageSize). Find(&users).Error; err != nil { logger.ErrorF(ctx, "查询用户失败: %v", err) return err diff --git a/internal/apps/project/routers.go b/internal/apps/project/routers.go index ffcc0c3..ec34310 100644 --- a/internal/apps/project/routers.go +++ b/internal/apps/project/routers.go @@ -566,8 +566,9 @@ func ReportProject(c *gin.Context) { } type ListReceiveHistoryRequest struct { - Current int `json:"current" form:"current" binding:"min=1"` - Size int `json:"size" form:"size" binding:"min=1,max=100"` + Current int `json:"current" form:"current" binding:"min=1"` + Size int `json:"size" form:"size" binding:"min=1,max=100"` + Search string `json:"search" form:"search" binding:"max=255"` } type ListReceiveHistoryResponseDataResult struct { @@ -607,40 +608,55 @@ func ListReceiveHistory(c *gin.Context) { } offset := (req.Current - 1) * req.Size - // query db + // build base query + baseQuery := db.DB(c.Request.Context()). + Table("project_items"). + Joins("INNER JOIN projects ON projects.id = project_items.project_id"). + Joins("INNER JOIN users ON users.id = projects.creator_id"). + Where("project_items.receiver_id = ?", userID) + + // apply search filter + if req.Search != "" { + searchPattern := strings.TrimSpace(req.Search) + "%" + if len(searchPattern) > 21 { + baseQuery = baseQuery.Where( + "users.nickname LIKE ? OR projects.name LIKE ?", + "%"+searchPattern, "%"+searchPattern, + ) + } else { + baseQuery = baseQuery.Where( + "users.username LIKE ? OR users.nickname LIKE ? OR projects.name LIKE ?", + searchPattern, "%"+searchPattern, "%"+searchPattern, + ) + } + } + + // query total count var total int64 - if err := db.DB(c.Request.Context()).Model(&ProjectItem{}).Where("receiver_id = ?", userID).Count(&total).Error; err != nil { + if err := baseQuery.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, ListReceiveHistoryResponse{ErrorMsg: err.Error()}) return } - var items []*ProjectItem - if err := db.DB(c.Request.Context()). - Model(&ProjectItem{}). - Where("receiver_id = ?", userID). + + // build data query with COALESCE for nickname default + var results []ListReceiveHistoryResponseDataResult + if err := baseQuery. + Select(` + project_items.project_id, + projects.name as project_name, + users.username as project_creator, + COALESCE(NULLIF(users.nickname, ''), users.username) as project_creator_nickname, + project_items.content, + project_items.received_at + `). + Order("project_items.received_at DESC, project_items.id DESC"). Offset(offset). Limit(req.Size). - Preload("Project.Creator"). - Find(&items).Error; err != nil { + Scan(&results).Error; err != nil { c.JSON(http.StatusInternalServerError, ListReceiveHistoryResponse{ErrorMsg: err.Error()}) return } - // response - results := make([]ListReceiveHistoryResponseDataResult, len(items)) - for i, item := range items { - creatorNickname := item.Project.Creator.Nickname - if creatorNickname == "" { - creatorNickname = item.Project.Creator.Username - } - results[i] = ListReceiveHistoryResponseDataResult{ - ProjectID: item.ProjectID, - ProjectName: item.Project.Name, - ProjectCreator: item.Project.Creator.Username, - ProjectCreatorNickname: creatorNickname, - Content: item.Content, - ReceivedAt: item.ReceivedAt, - } - } c.JSON( http.StatusOK, ListReceiveHistoryResponse{ From 515e201ec57e103418e0aed7be9f2f72f629dd9c Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:01:40 +0800 Subject: [PATCH 3/5] feat(received-history): add chart endpoint and integrate frontend visualization --- .../components/common/received/DataChart.tsx | 116 ++++++------------ .../common/received/ReceivedMain.tsx | 47 ++----- .../lib/services/project/project.service.ts | 48 ++++++++ frontend/lib/services/project/types.ts | 25 ++++ internal/apps/oauth/utils.go | 4 +- internal/apps/project/routers.go | 71 +++++++++++ internal/db/migrator/migrator.go | 5 + internal/router/router.go | 6 +- 8 files changed, 204 insertions(+), 118 deletions(-) diff --git a/frontend/components/common/received/DataChart.tsx b/frontend/components/common/received/DataChart.tsx index 8b72406..7beb829 100644 --- a/frontend/components/common/received/DataChart.tsx +++ b/frontend/components/common/received/DataChart.tsx @@ -5,7 +5,7 @@ import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts'; import {useIsMobile} from '@/hooks/use-mobile'; import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from '@/components/ui/chart'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; -import {ReceiveHistoryItem} from '@/lib/services/project/types'; +import {ReceiveHistoryChartPoint} from '@/lib/services/project/types'; import {CountingNumber} from '@/components/animate-ui/text/counting-number'; import {motion} from 'motion/react'; @@ -33,14 +33,18 @@ type TimeRange = keyof typeof TIME_RANGE_CONFIG */ interface DataChartProps { /** 领取历史数据 */ - data: ReceiveHistoryItem[] + data: ReceiveHistoryChartPoint[] + /** 当前选择的天数 */ + selectedDay: number + /** 范围变更回调 */ + onRangeChange: (day: number) => void } /** * 统计数据卡片组件 */ const StatCard = ({title, value, suffix = ''}: {title: string; value: number; suffix?: string}) => { - const decimalPlaces = value % 1 === 0 ? 0 : 2; + const decimalPlaces = Number.isInteger(value) ? 0 : 1; return ( ('7d'); const safeData = data || []; @@ -75,72 +79,28 @@ export function DataChart({data}: DataChartProps) { if (isMobile) setTimeRange('7d'); }, [isMobile]); + React.useEffect(() => { + if (selectedDay <= 7) { + setTimeRange('7d'); + } else if (selectedDay <= 30) { + setTimeRange('30d'); + } else if (selectedDay <= 90) { + setTimeRange('3m'); + } else { + setTimeRange('6m'); + } + }, [selectedDay]); + /** * 生成图表数据,根据时间范围聚合统计 */ const chartData = React.useMemo(() => { - const config = TIME_RANGE_CONFIG[timeRange]; - const isMonthRange = 'months' in config; - const today = new Date(); - const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - - const statsMap = new Map(); - safeData.forEach((item) => { - if (item.received_at) { - const date = new Date(item.received_at); - let key: string; - - if (isMonthRange) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - key = `${year}-${month}`; - } else { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - key = `${year}-${month}-${day}`; - } - - statsMap.set(key, (statsMap.get(key) || 0) + 1); - } - }); - - const dateRange: string[] = []; - if (isMonthRange) { - for (let i = config.months - 1; i >= 0; i--) { - const date = new Date(startDate.getFullYear(), startDate.getMonth() - i, 1); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - dateRange.push(`${year}-${month}`); - } - } else { - for (let i = config.days - 1; i >= 0; i--) { - const date = new Date(startDate.getTime() - i * 24 * 60 * 60 * 1000); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - dateRange.push(`${year}-${month}-${day}`); - } - } - - return dateRange.map((dateKey) => { - let displayDate: string; - - if (isMonthRange) { - const month = dateKey.split('-')[1]; - displayDate = `${month}月`; - } else { - const [, month, day] = dateKey.split('-'); - displayDate = `${month}/${day}`; - } - - return { - date: dateKey, - displayDate, - count: statsMap.get(dateKey) || 0, - }; - }); - }, [safeData, timeRange]); + return safeData.map((item) => ({ + date: item.date, + displayDate: item.label, + count: item.count, + })); + }, [safeData]); /** * 计算统计数据(总计、今日、本月、日均) @@ -154,17 +114,11 @@ export function DataChart({data}: DataChartProps) { let thisMonthCount = 0; safeData.forEach((item) => { - if (item.received_at) { - const date = new Date(item.received_at); - const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - - if (dateStr === todayStr) { - todayCount++; - } - if (monthStr === thisMonthStr) { - thisMonthCount++; - } + if (item.date === todayStr) { + todayCount += item.count; + } + if (item.date.startsWith(thisMonthStr)) { + thisMonthCount += item.count; } }); @@ -172,7 +126,7 @@ export function DataChart({data}: DataChartProps) { const avgDaily = currentDay > 0 ? Math.round(thisMonthCount / currentDay * 10) / 10 : 0; return { - total: safeData.length, + total: safeData.reduce((sum, item) => sum + item.count, 0), today: todayCount, thisMonth: thisMonthCount, avgDaily, @@ -225,7 +179,11 @@ export function DataChart({data}: DataChartProps) {