Merge branch 'zh' into 'main'
Zh See merge request prism/lable/labelmain!15
This commit is contained in:
@@ -3,8 +3,9 @@ import {
|
|||||||
handleLoginData,
|
handleLoginData,
|
||||||
handleRefreshToken,
|
handleRefreshToken,
|
||||||
} from "@/components/login/libs/cookie"
|
} 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 { genAccessToken } from "@/components/login/libs/session"
|
||||||
import { headers } from "next/headers"
|
|
||||||
import {
|
import {
|
||||||
// NextRequest,
|
// NextRequest,
|
||||||
NextResponse,
|
NextResponse,
|
||||||
@@ -16,7 +17,66 @@ const isParamsValid = (params: {} | null) =>
|
|||||||
!Array.isArray(params) && // 排除数组(可选)
|
!Array.isArray(params) && // 排除数组(可选)
|
||||||
Object.keys(params).length > 0 // 检查是否有自身可枚举属性
|
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) {
|
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 {
|
try {
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|
||||||
@@ -33,7 +93,8 @@ export async function POST(req: any) {
|
|||||||
} = body
|
} = body
|
||||||
if (isDeleteCookie) {
|
if (isDeleteCookie) {
|
||||||
await handleDeleteCookie()
|
await handleDeleteCookie()
|
||||||
return new NextResponse(
|
return attachServerTiming(
|
||||||
|
new NextResponse(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: "Successfully delete cookie!",
|
message: "Successfully delete cookie!",
|
||||||
code: 200,
|
code: 200,
|
||||||
@@ -44,17 +105,50 @@ export async function POST(req: any) {
|
|||||||
"Content-Type": "application/json",
|
"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 = {}
|
let fetchOptions: any = {}
|
||||||
|
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
if (!userData?.refresh_token) {
|
if (!userData?.refresh_token) {
|
||||||
return NextResponse.json(
|
return attachServerTiming(
|
||||||
|
NextResponse.json(
|
||||||
{ message: "Refresh token is empty", code: 406 },
|
{ message: "Refresh token is empty", code: 406 },
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fetchOptions = {
|
fetchOptions = {
|
||||||
@@ -69,24 +163,30 @@ export async function POST(req: any) {
|
|||||||
// }),
|
// }),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const access_token = `${userData?.access_token}`
|
const requestHeaders: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLogin) {
|
||||||
|
requestHeaders.Token = `${userData?.access_token || ""}`
|
||||||
|
requestHeaders["Refresh-Token"] = `${userData?.refresh_token || ""}`
|
||||||
|
}
|
||||||
|
|
||||||
fetchOptions = {
|
fetchOptions = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
"content-type": "application/json",
|
|
||||||
["Token"]: access_token,
|
|
||||||
["Refresh-Token"]: userData?.refresh_token,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if (method === "POST" || method === "PUT" || method === "DELETE") {
|
if (method === "POST" || method === "PUT" || method === "DELETE") {
|
||||||
fetchOptions.body = data
|
fetchOptions.body = data
|
||||||
} else {
|
} else {
|
||||||
if (isParamsValid(data)) {
|
if (isParamsValid(data)) {
|
||||||
let test = ""
|
const queryString = new URLSearchParams(
|
||||||
for (const [key, value] of Object.entries(data)) {
|
Object.entries(data).map(([key, value]) => [key, String(value)])
|
||||||
test += `&${key}=${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 fetchUrl = isPrefixUrl ? url : `${process.env.NEXT_PUBLIC_URL}${url}`
|
||||||
|
|
||||||
|
const upstreamFetchStart = performance.now()
|
||||||
const res = await fetch(fetchUrl, fetchOptions)
|
const res = await fetch(fetchUrl, fetchOptions)
|
||||||
|
timing.upstreamFetchMs = performance.now() - upstreamFetchStart
|
||||||
|
|
||||||
if (res?.status !== 200) {
|
if (res?.status !== 200) {
|
||||||
let error = ""
|
let error = ""
|
||||||
@@ -105,7 +207,13 @@ export async function POST(req: any) {
|
|||||||
} else {
|
} else {
|
||||||
error = await res.text()
|
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 (
|
if (
|
||||||
@@ -113,15 +221,37 @@ export async function POST(req: any) {
|
|||||||
options?.responseType === "arraybuffer" ||
|
options?.responseType === "arraybuffer" ||
|
||||||
isDownload
|
isDownload
|
||||||
) {
|
) {
|
||||||
const blob = await res.blob()
|
return attachServerTiming(
|
||||||
const buffer = await blob.arrayBuffer()
|
new NextResponse(res.body, {
|
||||||
return new NextResponse(buffer, {
|
status: res.status,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": blob.type,
|
"Content-Type":
|
||||||
|
res.headers.get("Content-Type") || "application/octet-stream",
|
||||||
"x-real-name": res?.headers?.get("content-disposition") || "",
|
"x-real-name": res?.headers?.get("content-disposition") || "",
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
|
)
|
||||||
} else if (!notJson) {
|
} 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 {
|
try {
|
||||||
const data = await (res.headers
|
const data = await (res.headers
|
||||||
.get("Content-Type")
|
.get("Content-Type")
|
||||||
@@ -130,48 +260,73 @@ export async function POST(req: any) {
|
|||||||
: res.text())
|
: res.text())
|
||||||
|
|
||||||
if (isLogin) {
|
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({
|
const newData = await handleLoginData({
|
||||||
clientIP: clientIP,
|
clientIP: getClientIpFromRequest(req),
|
||||||
fingerprint: body.fingerprint,
|
fingerprint: body.fingerprint,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
return new NextResponse(JSON.stringify(newData), {
|
return attachServerTiming(
|
||||||
|
new NextResponse(JSON.stringify(newData), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
const newData = await handleRefreshToken({
|
const newData = await handleRefreshToken({
|
||||||
userData,
|
userData,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
return new NextResponse(JSON.stringify(newData), {
|
return attachServerTiming(
|
||||||
|
new NextResponse(JSON.stringify(newData), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
}
|
}
|
||||||
return new NextResponse(JSON.stringify(data), {
|
)
|
||||||
|
}
|
||||||
|
return attachServerTiming(
|
||||||
|
new NextResponse(JSON.stringify(data), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err }, { status: 500 })
|
return attachServerTiming(
|
||||||
|
NextResponse.json({ error: err }, { status: 500 }),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res
|
return attachServerTiming(res, {
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err }, { status: 500 })
|
return attachServerTiming(
|
||||||
|
NextResponse.json({ error: err }, { status: 500 }),
|
||||||
|
{
|
||||||
|
...timing,
|
||||||
|
totalMs: performance.now() - requestStart,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import "@mantine/core/styles.css"
|
import "@mantine/core/styles.css"
|
||||||
import "@mantine/core/styles.layer.css"
|
import "@mantine/core/styles.layer.css"
|
||||||
|
import "@mantine/dates/styles.css"
|
||||||
import "@mantine/notifications/styles.css"
|
import "@mantine/notifications/styles.css"
|
||||||
import "mantine-datatable/styles.css"
|
import "mantine-datatable/styles.css"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { getPersonProjectDashBoard } from "@/components/label/api/project"
|
import { getPersonProjectDashBoard } from "@/components/label/api/project"
|
||||||
import { Project } from "@/components/label/api/project/typing"
|
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 { IconRefresh } from "@tabler/icons-react"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
import "dayjs/locale/zh-cn"
|
||||||
import duration from "dayjs/plugin/duration"
|
import duration from "dayjs/plugin/duration"
|
||||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
@@ -24,6 +26,19 @@ function formatDurationSeconds(seconds?: number | null) {
|
|||||||
return "-"
|
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() {
|
export default function PersonalProjectTable() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [records, setRecords] = useState<
|
const [records, setRecords] = useState<
|
||||||
@@ -35,6 +50,10 @@ export default function PersonalProjectTable() {
|
|||||||
const [pageSize, setPageSize] = useState(50)
|
const [pageSize, setPageSize] = useState(50)
|
||||||
const [totalItems, setTotalItems] = useState(0)
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState(() => createInitialFilters())
|
||||||
|
const [queryTrigger, setQueryTrigger] = useState(0)
|
||||||
|
const [form, setForm] = useState<FilterFormRecord>(createInitialFilters())
|
||||||
|
|
||||||
const totalPages = useMemo(() => {
|
const totalPages = useMemo(() => {
|
||||||
const totalItems = records.length
|
const totalItems = records.length
|
||||||
return Math.max(1, Math.ceil(totalItems / pageSize))
|
return Math.max(1, Math.ceil(totalItems / pageSize))
|
||||||
@@ -45,22 +64,48 @@ export default function PersonalProjectTable() {
|
|||||||
return records.slice(start, start + pageSize)
|
return records.slice(start, start + pageSize)
|
||||||
}, [page, pageSize, records])
|
}, [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 () => {
|
const load = useCallback(async () => {
|
||||||
|
if (!queryTrigger) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getPersonProjectDashBoard()
|
const res = await getPersonProjectDashBoard(appliedParams)
|
||||||
setRecords(res ?? [])
|
setRecords(res ?? [])
|
||||||
setTotalItems(res?.length ?? 0)
|
setTotalItems(res?.length ?? 0)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [appliedParams, queryTrigger])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setQueryTrigger((v) => v + 1)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1)
|
||||||
|
setForm({ ...filters })
|
||||||
|
setQueryTrigger((v) => v + 1)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page > totalPages) setPage(totalPages)
|
if (page > totalPages) setPage(totalPages)
|
||||||
}, [page, totalPages])
|
}, [page, totalPages])
|
||||||
@@ -103,7 +148,19 @@ export default function PersonalProjectTable() {
|
|||||||
title: "项目名称",
|
title: "项目名称",
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (record: Project.PersonProjectDashboardResponseItem) => {
|
render: (record: Project.PersonProjectDashboardResponseItem) => {
|
||||||
return record.project_name ?? "-"
|
return (
|
||||||
|
<Text
|
||||||
|
title={record.project_name ?? "-"}
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}>
|
||||||
|
{record.project_name ?? "-"}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,15 +210,34 @@ export default function PersonalProjectTable() {
|
|||||||
align="stretch"
|
align="stretch"
|
||||||
style={{ flexDirection: "column" }}>
|
style={{ flexDirection: "column" }}>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={700} fz="lg" style={{ marginBottom: 8 }}>
|
<Flex align={"center"} gap={8}>
|
||||||
|
<Text fw={700} fz="lg">
|
||||||
参与项目
|
参与项目
|
||||||
</Text>
|
</Text>
|
||||||
|
<DatePickerInput
|
||||||
|
type="range"
|
||||||
|
label=""
|
||||||
|
placeholder="请选择时间范围,默认一周"
|
||||||
|
miw={208}
|
||||||
|
valueFormat="YYYY-MM-DD"
|
||||||
|
locale="zh-cn"
|
||||||
|
clearable
|
||||||
|
value={filters.query_date}
|
||||||
|
onChange={(value: DatesRangeValue) =>
|
||||||
|
setFilters((s) => ({
|
||||||
|
...s,
|
||||||
|
query_date: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size={"xs"}
|
size={"xs"}
|
||||||
fz={"sm"}
|
fz={"sm"}
|
||||||
fw={500}
|
fw={500}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
onClick={load}
|
onClick={handleSearch}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
leftSection={<IconRefresh size={16} />}>
|
leftSection={<IconRefresh size={16} />}>
|
||||||
更新
|
更新
|
||||||
|
|||||||
@@ -78,9 +78,21 @@ export default function PersonalTaskTable() {
|
|||||||
{
|
{
|
||||||
accessor: "project_name",
|
accessor: "project_name",
|
||||||
title: "项目名称",
|
title: "项目名称",
|
||||||
width: 150,
|
width: 160,
|
||||||
render: (record: Task.SimpleTaskItem) => {
|
render: (record: Task.SimpleTaskItem) => {
|
||||||
return <Text>{record.project_name}</Text>
|
return (
|
||||||
|
<Text
|
||||||
|
title={record.project_name ?? "-"}
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}>
|
||||||
|
{record.project_name ?? "-"}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core"
|
} from "@mantine/core"
|
||||||
|
import { DateInput } from "@mantine/dates"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { notifications } from "@mantine/notifications"
|
import { notifications } from "@mantine/notifications"
|
||||||
|
import "dayjs/locale/zh-cn"
|
||||||
import { useEffect, useMemo } from "react"
|
import { useEffect, useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
accountTypeOpts,
|
accountTypeOpts,
|
||||||
@@ -365,9 +367,9 @@ export default function DailyReportModal(props: {
|
|||||||
<Stack gap="md" className={classes.formPanel}>
|
<Stack gap="md" className={classes.formPanel}>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||||
<TextInput label="姓名" value={form.values.user_name} readOnly />
|
<TextInput label="姓名" value={form.values.user_name} readOnly />
|
||||||
<TextInput
|
<DateInput
|
||||||
label="日期"
|
label="日期"
|
||||||
type="date"
|
locale="zh-cn"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
{...form.getInputProps("date")}
|
{...form.getInputProps("date")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
SettingHeaderActions,
|
SettingHeaderActions,
|
||||||
SettingListHeader,
|
SettingListHeader,
|
||||||
SettingPage,
|
SettingPage,
|
||||||
SettingPanel,
|
|
||||||
} from "@/components/setting/PageSurface"
|
} from "@/components/setting/PageSurface"
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -43,7 +42,9 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import { DateInput } from "@mantine/dates"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
import "dayjs/locale/zh-cn"
|
||||||
import { DataTableColumn, DataTableSortStatus } from "mantine-datatable"
|
import { DataTableColumn, DataTableSortStatus } from "mantine-datatable"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import DailyReportModal from "./components/DailyReportModal"
|
import DailyReportModal from "./components/DailyReportModal"
|
||||||
@@ -154,7 +155,7 @@ export default function PersonReportPage() {
|
|||||||
const [totalItems, setTotalItems] = useState(0)
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [records, setRecords] = useState<Daily.DailyWorkList[]>([])
|
const [records, setRecords] = useState<Daily.DailyWorkList[]>([])
|
||||||
const [summary, setSummary] = useState<Daily.Summary | null>(null)
|
// const [summary, setSummary] = useState<Daily.Summary | null>(null)
|
||||||
|
|
||||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||||
columnAccessor: "id",
|
columnAccessor: "id",
|
||||||
@@ -218,11 +219,11 @@ export default function PersonReportPage() {
|
|||||||
const res = await getSelfDailyWorkList(queryParams)
|
const res = await getSelfDailyWorkList(queryParams)
|
||||||
setRecords(res?.daily_work_list ?? [])
|
setRecords(res?.daily_work_list ?? [])
|
||||||
setTotalItems(res?.total_items ?? 0)
|
setTotalItems(res?.total_items ?? 0)
|
||||||
setSummary(res?.total_summary ?? null)
|
// setSummary(res?.total_summary ?? null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRecords([])
|
setRecords([])
|
||||||
setTotalItems(0)
|
setTotalItems(0)
|
||||||
setSummary(null)
|
// setSummary(null)
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: "red",
|
color: "red",
|
||||||
title: "加载失败",
|
title: "加载失败",
|
||||||
@@ -290,9 +291,21 @@ export default function PersonReportPage() {
|
|||||||
[handleAddCustomPageSize]
|
[handleAddCustomPageSize]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sum = (arr: number[]) =>
|
||||||
|
arr.reduce((accumulator, currentValue) => {
|
||||||
|
const value = Number(currentValue ?? 0)
|
||||||
|
return accumulator + (Number.isFinite(value) ? value : 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const cols: DataTableColumn<Daily.DailyWorkList>[] = [
|
const cols: DataTableColumn<Daily.DailyWorkList>[] = [
|
||||||
{ accessor: "id", title: "ID", width: 80, sortable: true },
|
{
|
||||||
|
accessor: "id",
|
||||||
|
title: "ID",
|
||||||
|
width: 80,
|
||||||
|
// sortable: true,
|
||||||
|
footer: "总计",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessor: "date",
|
accessor: "date",
|
||||||
title: "日期",
|
title: "日期",
|
||||||
@@ -315,7 +328,7 @@ export default function PersonReportPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "actual_type",
|
accessor: "actual_type",
|
||||||
title: "实际类型",
|
title: "出勤类型",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (record) => record.actual_type ?? "-",
|
render: (record) => record.actual_type ?? "-",
|
||||||
},
|
},
|
||||||
@@ -368,6 +381,7 @@ export default function PersonReportPage() {
|
|||||||
title: "工时",
|
title: "工时",
|
||||||
width: 90,
|
width: 90,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
footer: sum(records.map((r) => r.work_time ?? 0)),
|
||||||
},
|
},
|
||||||
{ accessor: "rate", title: "额定倍率", width: 90 },
|
{ accessor: "rate", title: "额定倍率", width: 90 },
|
||||||
{
|
{
|
||||||
@@ -375,24 +389,28 @@ export default function PersonReportPage() {
|
|||||||
title: "标准量",
|
title: "标准量",
|
||||||
width: 90,
|
width: 90,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
footer: sum(records.map((r) => r.standard_size ?? 0)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "completed_size",
|
accessor: "completed_size",
|
||||||
title: "完成量",
|
title: "完成量",
|
||||||
width: 90,
|
width: 90,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
footer: sum(records.map((r) => r.completed_size ?? 0)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "residual_size",
|
accessor: "residual_size",
|
||||||
title: "余量",
|
title: "余量",
|
||||||
width: 90,
|
width: 90,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
footer: sum(records.map((r) => r.residual_size ?? 0)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "residual_work_time",
|
accessor: "residual_work_time",
|
||||||
title: "余量工时",
|
title: "余量工时",
|
||||||
width: 110,
|
width: 110,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
footer: sum(records.map((r) => r.residual_work_time ?? 0)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "remarks",
|
accessor: "remarks",
|
||||||
@@ -466,17 +484,17 @@ export default function PersonReportPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
return cols
|
return cols
|
||||||
}, [load])
|
}, [load, records])
|
||||||
|
|
||||||
const summaryText = useMemo(() => {
|
// const summaryText = useMemo(() => {
|
||||||
return {
|
// return {
|
||||||
total_work_time: summary?.total_work_time ?? 0,
|
// total_work_time: summary?.total_work_time ?? 0,
|
||||||
total_standard_size: summary?.total_standard_size ?? 0,
|
// total_standard_size: summary?.total_standard_size ?? 0,
|
||||||
total_completed_size: summary?.total_completed_size ?? 0,
|
// total_completed_size: summary?.total_completed_size ?? 0,
|
||||||
total_residual_size: summary?.total_residual_size ?? 0,
|
// total_residual_size: summary?.total_residual_size ?? 0,
|
||||||
total_residual_work_time: summary?.total_residual_work_time ?? 0,
|
// total_residual_work_time: summary?.total_residual_work_time ?? 0,
|
||||||
}
|
// }
|
||||||
}, [summary])
|
// }, [summary])
|
||||||
|
|
||||||
const asyncGetSelfProjectList = useCallback(async () => {
|
const asyncGetSelfProjectList = useCallback(async () => {
|
||||||
const res = await getSelfProjectList()
|
const res = await getSelfProjectList()
|
||||||
@@ -535,23 +553,26 @@ export default function PersonReportPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm">
|
||||||
<TextInput
|
<DateInput
|
||||||
label="开始日期"
|
label="开始日期"
|
||||||
type="date"
|
locale="zh-cn"
|
||||||
value={filters.date_start}
|
value={filters.date_start}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters((s) => ({
|
setFilters((s) => ({
|
||||||
...s,
|
...s,
|
||||||
date_start: e.target.value,
|
date_start: dayjs(e).format("YYYY-MM-DD"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<DateInput
|
||||||
label="结束日期"
|
label="结束日期"
|
||||||
type="date"
|
locale="zh-cn"
|
||||||
value={filters.date_end}
|
value={filters.date_end}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFilters((s) => ({ ...s, date_end: e.target.value }))
|
setFilters((s) => ({
|
||||||
|
...s,
|
||||||
|
date_end: dayjs(e).format("YYYY-MM-DD"),
|
||||||
|
}))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -716,7 +737,7 @@ export default function PersonReportPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SettingPanel radius="md" p="sm">
|
{/* <SettingPanel radius="md" p="sm">
|
||||||
<Group justify="space-between" wrap="wrap">
|
<Group justify="space-between" wrap="wrap">
|
||||||
<Text size="sm" fw={700}>
|
<Text size="sm" fw={700}>
|
||||||
当前页总计
|
当前页总计
|
||||||
@@ -733,7 +754,7 @@ export default function PersonReportPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</SettingPanel>
|
</SettingPanel> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</SettingContentPanel>
|
</SettingContentPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import httpFetch from "@/api/fetch"
|
import httpFetch from "@/api/fetch"
|
||||||
import { Project, ProjectDetail, ReviewUsers } from "./typing"
|
|
||||||
import { BASE_LABEL_API } from "../const"
|
import { BASE_LABEL_API } from "../const"
|
||||||
|
import { Project, ProjectDetail, ReviewUsers } from "./typing"
|
||||||
|
|
||||||
// 获取项目列表
|
// 获取项目列表
|
||||||
export const getProjectList = (data: Project.ListRequest) => {
|
export const getProjectList = (data: Project.ListRequest) => {
|
||||||
@@ -138,11 +138,14 @@ export const regenerateProjectEmbedding = (data: { project_id: number }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取个人参与项目
|
// 获取个人参与项目
|
||||||
export const getPersonProjectDashBoard = () => {
|
export const getPersonProjectDashBoard = (
|
||||||
|
params: Project.ReqPersonProjectDashBoard
|
||||||
|
) => {
|
||||||
return httpFetch<Array<Project.PersonProjectDashboardResponseItem>>({
|
return httpFetch<Array<Project.PersonProjectDashboardResponseItem>>({
|
||||||
url:
|
url:
|
||||||
BASE_LABEL_API + `/api/v1/label_server/data_statistics/personal_project`,
|
BASE_LABEL_API + `/api/v1/label_server/data_statistics/personal_project`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
data: params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ export namespace Project {
|
|||||||
task: number
|
task: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export interface ReqPersonProjectDashBoard {
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DetailResponse
|
* DetailResponse
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core"
|
} from "@mantine/core"
|
||||||
|
import Avvvatars from "avvvatars-react"
|
||||||
|
|
||||||
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
|
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
|
||||||
import { IconLogout, IconMoon, IconSun, IconUser } from "@tabler/icons-react"
|
import { IconLogout, IconMoon, IconSun } from "@tabler/icons-react"
|
||||||
import cx from "clsx"
|
import cx from "clsx"
|
||||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||||
import actionToggleClasses from "./ActionToggle.module.css"
|
import actionToggleClasses from "./ActionToggle.module.css"
|
||||||
@@ -33,6 +35,103 @@ import { ClientIcon } from "./components/ClientIcon"
|
|||||||
import { LinksGroup } from "./components/NavbarLinks"
|
import { LinksGroup } from "./components/NavbarLinks"
|
||||||
import { useAppLayoutStore } from "./store"
|
import { useAppLayoutStore } from "./store"
|
||||||
|
|
||||||
|
const COMPOUND_SURNAMES = new Set([
|
||||||
|
"欧阳",
|
||||||
|
"太史",
|
||||||
|
"端木",
|
||||||
|
"上官",
|
||||||
|
"司马",
|
||||||
|
"东方",
|
||||||
|
"独孤",
|
||||||
|
"南宫",
|
||||||
|
"万俟",
|
||||||
|
"闻人",
|
||||||
|
"夏侯",
|
||||||
|
"诸葛",
|
||||||
|
"尉迟",
|
||||||
|
"公羊",
|
||||||
|
"赫连",
|
||||||
|
"澹台",
|
||||||
|
"皇甫",
|
||||||
|
"宗政",
|
||||||
|
"濮阳",
|
||||||
|
"公冶",
|
||||||
|
"太叔",
|
||||||
|
"申屠",
|
||||||
|
"公孙",
|
||||||
|
"慕容",
|
||||||
|
"仲孙",
|
||||||
|
"钟离",
|
||||||
|
"长孙",
|
||||||
|
"宇文",
|
||||||
|
"司徒",
|
||||||
|
"鲜于",
|
||||||
|
"司空",
|
||||||
|
"闾丘",
|
||||||
|
"子车",
|
||||||
|
"亓官",
|
||||||
|
"司寇",
|
||||||
|
"巫马",
|
||||||
|
"公西",
|
||||||
|
"颛孙",
|
||||||
|
"壤驷",
|
||||||
|
"公良",
|
||||||
|
"漆雕",
|
||||||
|
"乐正",
|
||||||
|
"宰父",
|
||||||
|
"谷梁",
|
||||||
|
"拓跋",
|
||||||
|
"轩辕",
|
||||||
|
"令狐",
|
||||||
|
"段干",
|
||||||
|
"百里",
|
||||||
|
"呼延",
|
||||||
|
"东郭",
|
||||||
|
"南门",
|
||||||
|
"羊舌",
|
||||||
|
"微生",
|
||||||
|
"梁丘",
|
||||||
|
"左丘",
|
||||||
|
"东门",
|
||||||
|
"西门",
|
||||||
|
"南荣",
|
||||||
|
"第五",
|
||||||
|
])
|
||||||
|
|
||||||
|
function isChineseName(value: string) {
|
||||||
|
return /[\u4e00-\u9fff]/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarDisplayValue(rawValue?: string | null) {
|
||||||
|
const value = rawValue?.trim() ?? ""
|
||||||
|
|
||||||
|
if (!value) return "-"
|
||||||
|
|
||||||
|
if (isChineseName(value)) {
|
||||||
|
const normalized = value.replace(/[\s·•・・]/g, "")
|
||||||
|
if (normalized.length <= 1) return normalized
|
||||||
|
|
||||||
|
const surnameLength =
|
||||||
|
normalized.length >= 3 && COMPOUND_SURNAMES.has(normalized.slice(0, 2))
|
||||||
|
? 2
|
||||||
|
: 1
|
||||||
|
const givenName = normalized.slice(surnameLength)
|
||||||
|
|
||||||
|
if (!givenName) return normalized.slice(-1)
|
||||||
|
if (givenName.length <= 2) return givenName
|
||||||
|
return givenName.slice(-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = value.split(/[\s-_]+/).filter(Boolean)
|
||||||
|
if (tokens.length >= 2) {
|
||||||
|
return `${tokens[0][0] ?? ""}${tokens[tokens.length - 1][0] ?? ""}`
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
menu,
|
menu,
|
||||||
children,
|
children,
|
||||||
@@ -144,6 +243,10 @@ export default function AppLayout({
|
|||||||
}
|
}
|
||||||
const matches = useMediaQuery("(min-width: 48em)")
|
const matches = useMediaQuery("(min-width: 48em)")
|
||||||
const userName = usePermissionStore((s) => s.user_name)
|
const userName = usePermissionStore((s) => s.user_name)
|
||||||
|
const avatarDisplayValue = useMemo(
|
||||||
|
() => getAvatarDisplayValue(userName),
|
||||||
|
[userName]
|
||||||
|
)
|
||||||
|
|
||||||
return isClient ? (
|
return isClient ? (
|
||||||
withoutLayout ? (
|
withoutLayout ? (
|
||||||
@@ -199,10 +302,9 @@ export default function AppLayout({
|
|||||||
<Menu shadow="md" width={220}>
|
<Menu shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<IconUser
|
<Avvvatars
|
||||||
style={{ display: "block" }}
|
value={userName || "-"}
|
||||||
size={22}
|
displayValue={avatarDisplayValue}
|
||||||
stroke={1.5}
|
|
||||||
/>
|
/>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Login } from "@/components/login/api/types"
|
import { Login } from "@/components/login/api/types"
|
||||||
import redis from "@/components/login/libs/redis"
|
import redis from "@/components/login/libs/redis"
|
||||||
import { encrypt, generateKeyFromEnv } from "@/components/login/libs/session"
|
import {
|
||||||
|
deleteCachedSession,
|
||||||
|
setCachedSession,
|
||||||
|
} from "@/components/login/libs/session-cache"
|
||||||
|
import {
|
||||||
|
createSessionRedisKey,
|
||||||
|
getSessionRedisKeyFromCookieValue,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
} from "@/components/login/libs/session-common"
|
||||||
import { cookies } from "next/headers"
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
export const handleLoginData = async (props: {
|
export const handleLoginData = async (props: {
|
||||||
@@ -10,36 +18,36 @@ export const handleLoginData = async (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const { clientIP, fingerprint, data } = props
|
const { clientIP, fingerprint, data } = props
|
||||||
|
|
||||||
const sessionKey = {
|
|
||||||
clientIP,
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
}
|
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
const oldSession = cookieStore.get("session")?.value || ""
|
const oldSessionKey = getSessionRedisKeyFromCookieValue(
|
||||||
if (oldSession) {
|
cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||||
redis.del(JSON.parse(oldSession) as any)
|
)
|
||||||
|
if (oldSessionKey) {
|
||||||
|
await redis.del(oldSessionKey)
|
||||||
|
deleteCachedSession(oldSessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
|
const sessionRedisKey = createSessionRedisKey()
|
||||||
const encryptedSessionData = await encrypt(JSON.stringify(sessionKey), key)
|
const sessionUserData = {
|
||||||
|
|
||||||
// save redis
|
|
||||||
await redis.set(
|
|
||||||
encryptedSessionData,
|
|
||||||
JSON.stringify({
|
|
||||||
clientIP,
|
clientIP,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
user_name: data?.name,
|
user_name: data?.name,
|
||||||
access_token: data.token,
|
access_token: data.token,
|
||||||
refresh_token: data.refresh_token,
|
refresh_token: data.refresh_token,
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
// save redis
|
||||||
|
await redis.set(
|
||||||
|
sessionRedisKey,
|
||||||
|
JSON.stringify(sessionUserData),
|
||||||
"EX",
|
"EX",
|
||||||
172800
|
172800
|
||||||
)
|
)
|
||||||
|
setCachedSession(sessionRedisKey, sessionUserData)
|
||||||
|
|
||||||
// set cookie
|
// set cookie
|
||||||
cookieStore.set("session", JSON.stringify(encryptedSessionData), {
|
cookieStore.set(SESSION_COOKIE_NAME, sessionRedisKey, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NEXT_PUBLIC_COOKIE_ENV === "production",
|
secure: process.env.NEXT_PUBLIC_COOKIE_ENV === "production",
|
||||||
maxAge: 60 * 60 * 24 * 30, // 30天
|
maxAge: 60 * 60 * 24 * 30, // 30天
|
||||||
@@ -59,17 +67,26 @@ export const handleRefreshToken = async (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const { userData, data } = props
|
const { userData, data } = props
|
||||||
const { redisKey, ...rest } = userData
|
const { redisKey, ...rest } = userData
|
||||||
await redis.set(
|
const sessionRedisKey = getSessionRedisKeyFromCookieValue(redisKey)
|
||||||
JSON.parse(redisKey),
|
|
||||||
JSON.stringify({
|
if (!sessionRedisKey) {
|
||||||
|
throw new Error("未获取到有效的 session key")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUserData = {
|
||||||
...rest,
|
...rest,
|
||||||
user_name: data?.name,
|
user_name: data?.name,
|
||||||
access_token: data.token,
|
access_token: data.token,
|
||||||
refresh_token: data.refresh_token,
|
refresh_token: data.refresh_token,
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
sessionRedisKey,
|
||||||
|
JSON.stringify(sessionUserData),
|
||||||
"EX",
|
"EX",
|
||||||
172800
|
172800
|
||||||
)
|
)
|
||||||
|
setCachedSession(sessionRedisKey, sessionUserData)
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
token: "",
|
token: "",
|
||||||
@@ -80,11 +97,16 @@ export const handleRefreshToken = async (props: {
|
|||||||
|
|
||||||
export const handleDeleteCookie = async () => {
|
export const handleDeleteCookie = async () => {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const sessionData = cookieStore.get("session")?.value || ""
|
const sessionData = cookieStore.get(SESSION_COOKIE_NAME)?.value || ""
|
||||||
|
const sessionRedisKey = getSessionRedisKeyFromCookieValue(sessionData)
|
||||||
|
|
||||||
// 删cookie和redis中用户
|
// 删cookie和redis中用户
|
||||||
|
if (sessionRedisKey) {
|
||||||
|
await redis.del(sessionRedisKey)
|
||||||
|
deleteCachedSession(sessionRedisKey)
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionData) {
|
if (sessionData) {
|
||||||
await redis.del(JSON.parse(sessionData) as any)
|
cookieStore.delete(SESSION_COOKIE_NAME)
|
||||||
cookieStore.delete("session")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
components/login/libs/session-cache.ts
Normal file
96
components/login/libs/session-cache.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const SESSION_CACHE_TTL_MS = 5000
|
||||||
|
|
||||||
|
type SessionCacheSource = "memory" | "shared" | "redis"
|
||||||
|
|
||||||
|
interface SessionCacheEntry<T> {
|
||||||
|
data: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = new Map<string, SessionCacheEntry<any>>()
|
||||||
|
const inflightSessionLoads = new Map<string, Promise<any | null>>()
|
||||||
|
|
||||||
|
const getNow = () => Date.now()
|
||||||
|
|
||||||
|
const getValidCachedSession = <T>(redisKey: string) => {
|
||||||
|
const cacheEntry = sessionCache.get(redisKey)
|
||||||
|
|
||||||
|
if (!cacheEntry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheEntry.expiresAt <= getNow()) {
|
||||||
|
sessionCache.delete(redisKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheEntry.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCachedSession = <T>(redisKey: string) =>
|
||||||
|
getValidCachedSession<T>(redisKey)
|
||||||
|
|
||||||
|
export const setCachedSession = <T>(redisKey: string, data: T) => {
|
||||||
|
sessionCache.set(redisKey, {
|
||||||
|
data,
|
||||||
|
expiresAt: getNow() + SESSION_CACHE_TTL_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteCachedSession = (redisKey: string) => {
|
||||||
|
sessionCache.delete(redisKey)
|
||||||
|
inflightSessionLoads.delete(redisKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionCacheTtlMs = () => SESSION_CACHE_TTL_MS
|
||||||
|
|
||||||
|
export const getOrLoadSession = async <T>(
|
||||||
|
redisKey: string,
|
||||||
|
loader: () => Promise<T | null>
|
||||||
|
): Promise<{
|
||||||
|
data: T | null
|
||||||
|
source: SessionCacheSource
|
||||||
|
}> => {
|
||||||
|
const cachedData = getValidCachedSession<T>(redisKey)
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
return {
|
||||||
|
data: cachedData,
|
||||||
|
source: "memory",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightLoad = inflightSessionLoads.get(redisKey)
|
||||||
|
|
||||||
|
if (inflightLoad) {
|
||||||
|
return {
|
||||||
|
data: (await inflightLoad) as T | null,
|
||||||
|
source: "shared",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPromise = (async () => {
|
||||||
|
const loadedData = await loader()
|
||||||
|
|
||||||
|
if (loadedData) {
|
||||||
|
setCachedSession(redisKey, loadedData)
|
||||||
|
} else {
|
||||||
|
deleteCachedSession(redisKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedData
|
||||||
|
})()
|
||||||
|
|
||||||
|
inflightSessionLoads.set(redisKey, loadPromise)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: await loadPromise,
|
||||||
|
source: "redis",
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (inflightSessionLoads.get(redisKey) === loadPromise) {
|
||||||
|
inflightSessionLoads.delete(redisKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
components/login/libs/session-common.ts
Normal file
34
components/login/libs/session-common.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const SESSION_COOKIE_NAME = "session"
|
||||||
|
|
||||||
|
const SESSION_KEY_PREFIX = "label:session:"
|
||||||
|
|
||||||
|
const normalizeClientIp = (ip: string) => ip.replace(/^::ffff:/, "")
|
||||||
|
|
||||||
|
export const createSessionRedisKey = () =>
|
||||||
|
`${SESSION_KEY_PREFIX}${crypto.randomUUID()}`
|
||||||
|
|
||||||
|
export const getSessionRedisKeyFromCookieValue = (
|
||||||
|
sessionCookieValue?: string | null
|
||||||
|
) => {
|
||||||
|
if (!sessionCookieValue) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(sessionCookieValue)
|
||||||
|
return typeof parsedValue === "string" ? parsedValue : ""
|
||||||
|
} catch {
|
||||||
|
return sessionCookieValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClientIpFromRequest = (req: any) => {
|
||||||
|
const forwardedFor =
|
||||||
|
req?.headers?.get?.("x-forwarded-for") ||
|
||||||
|
req?.headers?.["x-forwarded-for"] ||
|
||||||
|
""
|
||||||
|
const clientIP =
|
||||||
|
forwardedFor?.split(",")[0]?.trim() || req?.socket?.remoteAddress || ""
|
||||||
|
|
||||||
|
return normalizeClientIp(clientIP)
|
||||||
|
}
|
||||||
@@ -1,156 +1,113 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { Base64 } from "js-base64"
|
|
||||||
import { headers } from "next/headers"
|
|
||||||
import redis from "./redis"
|
import redis from "./redis"
|
||||||
|
import { deleteCachedSession, getOrLoadSession } from "./session-cache"
|
||||||
|
import {
|
||||||
|
getClientIpFromRequest,
|
||||||
|
getSessionRedisKeyFromCookieValue,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
} from "./session-common"
|
||||||
|
|
||||||
// 生成加密密钥
|
const roundDurationMs = (durationMs: number) =>
|
||||||
export async function generateKey(): Promise<CryptoKey> {
|
Math.round(durationMs * 100) / 100
|
||||||
return crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "AES-CBC",
|
|
||||||
length: 256,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateKeyFromEnv(
|
|
||||||
envPassword: string
|
|
||||||
): Promise<CryptoKey> {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const password = encoder.encode(envPassword)
|
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
password,
|
|
||||||
{
|
|
||||||
name: "PBKDF2",
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
["deriveKey"]
|
|
||||||
)
|
|
||||||
return crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: "PBKDF2",
|
|
||||||
salt: new Uint8Array(16), // 随机盐值
|
|
||||||
iterations: 100000, // 迭代次数
|
|
||||||
hash: "SHA-256",
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
{
|
|
||||||
name: "AES-CBC",
|
|
||||||
length: 256,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将ArrayBuffer转换为Uint8Array
|
|
||||||
function arrayBufferToUint8Array(buffer: ArrayBuffer) {
|
|
||||||
return new Uint8Array(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密函数
|
|
||||||
export async function encrypt(data: any, key: CryptoKey): Promise<string> {
|
|
||||||
const encodedData = new TextEncoder().encode(JSON.stringify(data))
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16)) // 16 bytes for AES block size
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: "AES-CBC",
|
|
||||||
iv: iv,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
encodedData
|
|
||||||
)
|
|
||||||
|
|
||||||
// 将Uint8Array转换为Base64字符串,以便存储和传输
|
|
||||||
const encryptedBase64 = Base64.fromUint8Array(
|
|
||||||
arrayBufferToUint8Array(encrypted)
|
|
||||||
)
|
|
||||||
const ivBase64 = Base64.fromUint8Array(new Uint8Array(iv)) // 使用js-base64进行编码
|
|
||||||
|
|
||||||
// 将IV和加密数据拼接在一起,以便解密时使用
|
|
||||||
return `${ivBase64}::${encryptedBase64}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解密函数
|
|
||||||
export async function decrypt(
|
|
||||||
encryptedData: string,
|
|
||||||
key: CryptoKey
|
|
||||||
): Promise<any> {
|
|
||||||
// 将加密数据从Base64字符串转换回Uint8Array
|
|
||||||
const [ivBase64, encryptedBase64] = encryptedData.split("::")
|
|
||||||
|
|
||||||
const ivArray = Buffer.from(ivBase64, "base64")
|
|
||||||
|
|
||||||
const encrypted = Buffer.from(encryptedBase64, "base64")
|
|
||||||
|
|
||||||
// 使用AES-CBC算法进行解密
|
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-CBC",
|
|
||||||
iv: ivArray,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
encrypted
|
|
||||||
)
|
|
||||||
|
|
||||||
// 将ArrayBuffer转换为字符串
|
|
||||||
return new TextDecoder().decode(decrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验用户信息
|
// 校验用户信息
|
||||||
export async function genAccessToken(req: any) {
|
export async function genAccessToken(req: any) {
|
||||||
|
const sessionLookupStart = performance.now()
|
||||||
|
let redisLookupMs = 0
|
||||||
|
let sessionSource: "memory" | "shared" | "redis" | "skip" = "skip"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 校验用户信息
|
const sessionData = req?.cookies?.get?.(SESSION_COOKIE_NAME)?.value
|
||||||
// const fingerprint = req.headers.get("Fingerprint")
|
const redisKey = getSessionRedisKeyFromCookieValue(sessionData)
|
||||||
const sessionData = req.cookies.get("session")?.value
|
|
||||||
|
|
||||||
// 无session 直接重定向到登录页面
|
// 无session 直接重定向到登录页面
|
||||||
if (!sessionData) {
|
if (!redisKey) {
|
||||||
return "客户端请求中,未包含用户标识"
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
message: "客户端请求中,未包含用户标识",
|
||||||
|
timing: {
|
||||||
|
sessionLookupMs: roundDurationMs(
|
||||||
|
performance.now() - sessionLookupStart
|
||||||
|
),
|
||||||
|
redisLookupMs: 0,
|
||||||
|
sessionSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let redisKey = JSON.parse(sessionData)
|
const sessionResult = await getOrLoadSession(redisKey, async () => {
|
||||||
|
const redisLookupStart = performance.now()
|
||||||
|
const userDataString = await redis.get(redisKey)
|
||||||
|
redisLookupMs = roundDurationMs(performance.now() - redisLookupStart)
|
||||||
|
|
||||||
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
|
if (!userDataString) {
|
||||||
const decryptedSessionData = JSON.parse(await decrypt(redisKey, key))
|
return null
|
||||||
const fingerprint = JSON.parse(decryptedSessionData)?.fingerprint
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
return JSON.parse(userDataString)
|
||||||
let userData: any = await redis.get(redisKey)
|
})
|
||||||
|
|
||||||
userData = JSON.parse(userData)
|
sessionSource = sessionResult.source
|
||||||
|
|
||||||
// redis中无用户信息 直接重定向到登录页面
|
// redis中无用户信息 直接重定向到登录页面
|
||||||
if (!userData) {
|
if (!sessionResult.data) {
|
||||||
return "未获取到用户信息"
|
deleteCachedSession(redisKey)
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
message: "未获取到用户信息",
|
||||||
|
timing: {
|
||||||
|
sessionLookupMs: roundDurationMs(
|
||||||
|
performance.now() - sessionLookupStart
|
||||||
|
),
|
||||||
|
redisLookupMs,
|
||||||
|
sessionSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = headers()
|
const userData = sessionResult.data
|
||||||
let clientIP =
|
const clientIP = getClientIpFromRequest(req)
|
||||||
(await header).get("x-forwarded-for")?.split(",")[0] ||
|
|
||||||
req?.socket?.remoteAddress
|
|
||||||
// ip v6地址
|
|
||||||
if (clientIP.startsWith("::")) {
|
|
||||||
clientIP = clientIP.slice(7)
|
|
||||||
}
|
|
||||||
// 登录ip与用户请求ip不匹配
|
|
||||||
|
|
||||||
if (
|
if (clientIP !== userData?.clientIP) {
|
||||||
clientIP !== userData?.clientIP ||
|
return {
|
||||||
fingerprint !== userData?.fingerprint
|
ok: false as const,
|
||||||
) {
|
message: `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP} | redis存储: ${userData?.clientIP}`,
|
||||||
return `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP}, ${fingerprint} | redis存储: ${userData?.clientIP}, ${userData?.fingerprint}`
|
timing: {
|
||||||
|
sessionLookupMs: roundDurationMs(
|
||||||
|
performance.now() - sessionLookupStart
|
||||||
|
),
|
||||||
|
redisLookupMs,
|
||||||
|
sessionSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
userData: {
|
||||||
...userData,
|
...userData,
|
||||||
redisKey: sessionData,
|
redisKey,
|
||||||
|
},
|
||||||
|
timing: {
|
||||||
|
sessionLookupMs: roundDurationMs(
|
||||||
|
performance.now() - sessionLookupStart
|
||||||
|
),
|
||||||
|
redisLookupMs,
|
||||||
|
sessionSource,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return `获取用户信息报错, 报错原因:${err?.message}`
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
message: `获取用户信息报错, 报错原因:${err?.message}`,
|
||||||
|
timing: {
|
||||||
|
sessionLookupMs: roundDurationMs(
|
||||||
|
performance.now() - sessionLookupStart
|
||||||
|
),
|
||||||
|
redisLookupMs,
|
||||||
|
sessionSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { cookies } from "next/headers"
|
import {
|
||||||
|
getSessionRedisKeyFromCookieValue,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
} from "@/components/login/libs/session-common"
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
const publicRoutes = ["/login", "/"]
|
const publicRoutes = ["/login", "/"]
|
||||||
@@ -22,11 +25,10 @@ export default async function middleware(req: NextRequest) {
|
|||||||
// const isProtectedRoute = protectedRoutes.includes(path)
|
// const isProtectedRoute = protectedRoutes.includes(path)
|
||||||
const isPublicRoute = publicRoutes.includes(path)
|
const isPublicRoute = publicRoutes.includes(path)
|
||||||
|
|
||||||
// 3. Decrypt the session from the cookie
|
// 3. Resolve the session key from the cookie
|
||||||
|
const session = getSessionRedisKeyFromCookieValue(
|
||||||
const cookie = (await cookies()).get("session")?.value
|
req.cookies.get(SESSION_COOKIE_NAME)?.value
|
||||||
|
)
|
||||||
const session = cookie ? JSON.parse(cookie) : ""
|
|
||||||
|
|
||||||
// 4. Redirect to /login if the user is not authenticated
|
// 4. Redirect to /login if the user is not authenticated
|
||||||
if (!isPublicRoute && !session) {
|
if (!isPublicRoute && !session) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||||
"@mantine/core": "^8.3.1",
|
"@mantine/core": "^8.3.1",
|
||||||
|
"@mantine/dates": "^8.3.18",
|
||||||
"@mantine/form": "^8.3.1",
|
"@mantine/form": "^8.3.1",
|
||||||
"@mantine/hooks": "^8.3.1",
|
"@mantine/hooks": "^8.3.1",
|
||||||
"@mantine/modals": "^8.3.10",
|
"@mantine/modals": "^8.3.10",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"@tmcw/togeojson": "^7.1.2",
|
"@tmcw/togeojson": "^7.1.2",
|
||||||
|
"avvvatars-react": "^0.4.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
|
|||||||
Reference in New Issue
Block a user