diff --git a/app/api/transfer/[path]/route.ts b/app/api/transfer/[path]/route.ts index 16b77ed..b90e97e 100644 --- a/app/api/transfer/[path]/route.ts +++ b/app/api/transfer/[path]/route.ts @@ -3,8 +3,9 @@ import { handleLoginData, handleRefreshToken, } from "@/components/login/libs/cookie" +import { getSessionCacheTtlMs } from "@/components/login/libs/session-cache" +import { getClientIpFromRequest } from "@/components/login/libs/session-common" import { genAccessToken } from "@/components/login/libs/session" -import { headers } from "next/headers" import { // NextRequest, NextResponse, @@ -16,7 +17,66 @@ const isParamsValid = (params: {} | null) => !Array.isArray(params) && // 排除数组(可选) Object.keys(params).length > 0 // 检查是否有自身可枚举属性 +const formatDurationMs = (durationMs: number) => + Number.isFinite(durationMs) ? durationMs.toFixed(2) : "0.00" + +const buildServerTimingHeader = (props: { + sessionLookupMs: number + redisLookupMs: number + sessionSource: "memory" | "shared" | "redis" | "skip" + upstreamFetchMs: number + totalMs: number +}) => { + const { + sessionLookupMs, + redisLookupMs, + sessionSource, + upstreamFetchMs, + totalMs, + } = props + const cacheTtlMs = getSessionCacheTtlMs() + + return [ + `session-lookup;dur=${formatDurationMs(sessionLookupMs)};desc="${sessionSource}"`, + `redis;dur=${formatDurationMs(redisLookupMs)};desc="${sessionSource === "redis" ? "get" : sessionSource}"`, + `upstream;dur=${formatDurationMs(upstreamFetchMs)}`, + `total;dur=${formatDurationMs(totalMs)}`, + `session-cache;dur=${sessionSource === "memory" ? "1" : "0"};desc="ttl=${cacheTtlMs}ms"`, + ].join(", ") +} + +const attachServerTiming = ( + response: Response | NextResponse, + props: { + sessionLookupMs: number + redisLookupMs: number + sessionSource: "memory" | "shared" | "redis" | "skip" + upstreamFetchMs: number + totalMs: number + } +) => { + const nextResponse = + response instanceof NextResponse + ? response + : new NextResponse(response.body, { + status: response.status, + headers: response.headers, + }) + + nextResponse.headers.set("Server-Timing", buildServerTimingHeader(props)) + + return nextResponse +} + export async function POST(req: any) { + const requestStart = performance.now() + const timing = { + sessionLookupMs: 0, + redisLookupMs: 0, + sessionSource: "skip" as "memory" | "shared" | "redis" | "skip", + upstreamFetchMs: 0, + } + try { const body = await req.json() @@ -33,28 +93,62 @@ export async function POST(req: any) { } = body if (isDeleteCookie) { await handleDeleteCookie() - return new NextResponse( - JSON.stringify({ - message: "Successfully delete cookie!", - code: 200, - status: true, - }), + return attachServerTiming( + new NextResponse( + JSON.stringify({ + message: "Successfully delete cookie!", + code: 200, + status: true, + }), + { + headers: { + "Content-Type": "application/json", + }, + } + ), { - headers: { - "Content-Type": "application/json", - }, + ...timing, + totalMs: performance.now() - requestStart, } ) } - const userData = await genAccessToken(req) + + let userData: any = null + if (!isLogin) { + const sessionResult = await genAccessToken(req) + timing.sessionLookupMs = sessionResult.timing.sessionLookupMs + timing.redisLookupMs = sessionResult.timing.redisLookupMs + timing.sessionSource = sessionResult.timing.sessionSource + + if (!sessionResult.ok) { + return attachServerTiming( + NextResponse.json( + { message: sessionResult.message || "未获取到用户信息", code: 401 }, + { status: 401 } + ), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + + userData = sessionResult.userData + } let fetchOptions: any = {} if (isRefresh) { if (!userData?.refresh_token) { - return NextResponse.json( - { message: "Refresh token is empty", code: 406 }, - { status: 200 } + return attachServerTiming( + NextResponse.json( + { message: "Refresh token is empty", code: 406 }, + { status: 200 } + ), + { + ...timing, + totalMs: performance.now() - requestStart, + } ) } fetchOptions = { @@ -69,24 +163,30 @@ export async function POST(req: any) { // }), } } else { - const access_token = `${userData?.access_token}` + const requestHeaders: Record = { + "content-type": "application/json", + } + + if (!isLogin) { + requestHeaders.Token = `${userData?.access_token || ""}` + requestHeaders["Refresh-Token"] = `${userData?.refresh_token || ""}` + } + fetchOptions = { method, - headers: { - "content-type": "application/json", - ["Token"]: access_token, - ["Refresh-Token"]: userData?.refresh_token, - }, + headers: requestHeaders, } if (method === "POST" || method === "PUT" || method === "DELETE") { fetchOptions.body = data } else { if (isParamsValid(data)) { - let test = "" - for (const [key, value] of Object.entries(data)) { - test += `&${key}=${value}` + const queryString = new URLSearchParams( + Object.entries(data).map(([key, value]) => [key, String(value)]) + ).toString() + + if (queryString) { + url += url.includes("?") ? `&${queryString}` : `?${queryString}` } - url += `?${test}` } } } @@ -95,7 +195,9 @@ export async function POST(req: any) { const fetchUrl = isPrefixUrl ? url : `${process.env.NEXT_PUBLIC_URL}${url}` + const upstreamFetchStart = performance.now() const res = await fetch(fetchUrl, fetchOptions) + timing.upstreamFetchMs = performance.now() - upstreamFetchStart if (res?.status !== 200) { let error = "" @@ -105,7 +207,13 @@ export async function POST(req: any) { } else { error = await res.text() } - return NextResponse.json({ message: error }, { status: res?.status }) + return attachServerTiming( + NextResponse.json({ message: error }, { status: res?.status }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } if ( @@ -113,15 +221,37 @@ export async function POST(req: any) { options?.responseType === "arraybuffer" || isDownload ) { - const blob = await res.blob() - const buffer = await blob.arrayBuffer() - return new NextResponse(buffer, { - headers: { - "Content-Type": blob.type, - "x-real-name": res?.headers?.get("content-disposition") || "", - }, - }) + return attachServerTiming( + new NextResponse(res.body, { + status: res.status, + headers: { + "Content-Type": + res.headers.get("Content-Type") || "application/octet-stream", + "x-real-name": res?.headers?.get("content-disposition") || "", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } else if (!notJson) { + if (!isLogin && !isRefresh) { + return attachServerTiming( + new NextResponse(res.body, { + status: res.status, + headers: { + "Content-Type": + res.headers.get("Content-Type") || "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + try { const data = await (res.headers .get("Content-Type") @@ -130,48 +260,73 @@ export async function POST(req: any) { : res.text()) if (isLogin) { - const header = await headers() - let clientIP = - header.get("x-forwarded-for")?.split(",")[0] || - req?.socket?.remoteAddress - // ip v6地址 - if (clientIP.startsWith("::")) { - clientIP = clientIP.slice(7) - } const newData = await handleLoginData({ - clientIP: clientIP, + clientIP: getClientIpFromRequest(req), fingerprint: body.fingerprint, data, }) - return new NextResponse(JSON.stringify(newData), { - headers: { - "Content-Type": "application/json", - }, - }) + return attachServerTiming( + new NextResponse(JSON.stringify(newData), { + headers: { + "Content-Type": "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } if (isRefresh) { const newData = await handleRefreshToken({ userData, data, }) - return new NextResponse(JSON.stringify(newData), { + return attachServerTiming( + new NextResponse(JSON.stringify(newData), { + headers: { + "Content-Type": "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + return attachServerTiming( + new NextResponse(JSON.stringify(data), { headers: { "Content-Type": "application/json", }, - }) - } - return new NextResponse(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - }, - }) + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } catch (err) { - return NextResponse.json({ error: err }, { status: 500 }) + return attachServerTiming( + NextResponse.json({ error: err }, { status: 500 }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } } else { - return res + return attachServerTiming(res, { + ...timing, + totalMs: performance.now() - requestStart, + }) } } catch (err) { - return NextResponse.json({ error: err }, { status: 500 }) + return attachServerTiming( + NextResponse.json({ error: err }, { status: 500 }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } } diff --git a/app/layout.tsx b/app/layout.tsx index 230ae6c..fb37f14 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import "@mantine/core/styles.css" import "@mantine/core/styles.layer.css" +import "@mantine/dates/styles.css" import "@mantine/notifications/styles.css" import "mantine-datatable/styles.css" import Script from "next/script" diff --git a/app/person/dashboard/components/PersonalProjectTable.tsx b/app/person/dashboard/components/PersonalProjectTable.tsx index 213aa6e..812bfa6 100644 --- a/app/person/dashboard/components/PersonalProjectTable.tsx +++ b/app/person/dashboard/components/PersonalProjectTable.tsx @@ -2,10 +2,12 @@ import { getPersonProjectDashBoard } from "@/components/label/api/project" import { Project } from "@/components/label/api/project/typing" -import { Button, Group, Text } from "@mantine/core" +import { Button, Flex, Group, Text } from "@mantine/core" +import { DatePickerInput, type DatesRangeValue } from "@mantine/dates" import { IconRefresh } from "@tabler/icons-react" import dayjs from "dayjs" +import "dayjs/locale/zh-cn" import duration from "dayjs/plugin/duration" import { DataTable, DataTableColumn } from "mantine-datatable" import { useRouter } from "next/navigation" @@ -24,6 +26,19 @@ function formatDurationSeconds(seconds?: number | null) { return "-" } +interface FilterFormRecord { + query_date: DatesRangeValue +} + +function createInitialFilters(): FilterFormRecord { + return { + query_date: [ + dayjs().subtract(1, "month").format("YYYY-MM-DD"), + dayjs().format("YYYY-MM-DD"), + ], + } +} + export default function PersonalProjectTable() { const router = useRouter() const [records, setRecords] = useState< @@ -35,6 +50,10 @@ export default function PersonalProjectTable() { const [pageSize, setPageSize] = useState(50) const [totalItems, setTotalItems] = useState(0) + const [filters, setFilters] = useState(() => createInitialFilters()) + const [queryTrigger, setQueryTrigger] = useState(0) + const [form, setForm] = useState(createInitialFilters()) + const totalPages = useMemo(() => { const totalItems = records.length return Math.max(1, Math.ceil(totalItems / pageSize)) @@ -45,22 +64,48 @@ export default function PersonalProjectTable() { return records.slice(start, start + pageSize) }, [page, pageSize, records]) + const appliedParams = useMemo(() => { + const obj: Project.ReqPersonProjectDashBoard = { + start_date: dayjs().format("YYYY-MM-DD"), + end_date: dayjs().format("YYYY-MM-DD"), + } + if (form.query_date[0] && form.query_date[1]) { + obj.start_date = dayjs(form.query_date[0]).format("YYYY-MM-DD") + obj.end_date = dayjs(form.query_date[1]).format("YYYY-MM-DD") + } + + return obj + }, [form.query_date]) + const load = useCallback(async () => { + if (!queryTrigger) return setLoading(true) try { - const res = await getPersonProjectDashBoard() + const res = await getPersonProjectDashBoard(appliedParams) setRecords(res ?? []) setTotalItems(res?.length ?? 0) setPage(1) } finally { setLoading(false) } - }, []) + }, [appliedParams, queryTrigger]) useEffect(() => { load() }, [load]) + useEffect(() => { + queueMicrotask(() => { + setQueryTrigger((v) => v + 1) + }) + }, []) + + const handleSearch = () => { + setPage(1) + setForm({ ...filters }) + setQueryTrigger((v) => v + 1) + } + useEffect(() => { if (page > totalPages) setPage(totalPages) }, [page, totalPages]) @@ -103,7 +148,19 @@ export default function PersonalProjectTable() { title: "项目名称", width: 160, render: (record: Project.PersonProjectDashboardResponseItem) => { - return record.project_name ?? "-" + return ( + + {record.project_name ?? "-"} + + ) }, }, { @@ -153,15 +210,34 @@ export default function PersonalProjectTable() { align="stretch" style={{ flexDirection: "column" }}> - - 参与项目 - + + + 参与项目 + + + setFilters((s) => ({ + ...s, + query_date: value, + })) + } + /> + +