feat(project): init
153
app/api/transfer/[path]/route.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
handleDeleteCookie,
|
||||
handleLoginData,
|
||||
handleRefreshToken,
|
||||
} from "@/components/login/libs/cookie"
|
||||
import { genAccessToken } from "@/components/login/libs/session"
|
||||
import { headers } from "next/headers"
|
||||
import {
|
||||
// NextRequest,
|
||||
NextResponse,
|
||||
} from "next/server"
|
||||
|
||||
const isParamsValid = (params: {} | null) =>
|
||||
params !== null && // 排除 null
|
||||
typeof params === "object" && // 确保是对象类型
|
||||
!Array.isArray(params) && // 排除数组(可选)
|
||||
Object.keys(params).length > 0 // 检查是否有自身可枚举属性
|
||||
|
||||
export async function POST(req: any) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
let {
|
||||
url,
|
||||
data,
|
||||
options = {},
|
||||
method = "GET",
|
||||
isDownload = false,
|
||||
isLogin = false,
|
||||
isRefresh = false,
|
||||
isDeleteCookie = false,
|
||||
notJson = false,
|
||||
} = body
|
||||
if (isDeleteCookie) {
|
||||
await handleDeleteCookie()
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
message: "Successfully delete cookie!",
|
||||
code: 200,
|
||||
status: true,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const userData = await genAccessToken(req)
|
||||
|
||||
const authorization = `bearer ${userData?.access_token}`
|
||||
|
||||
const fetchOptions: any = {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: authorization,
|
||||
},
|
||||
}
|
||||
|
||||
if (method === "POST" || method === "PUT") {
|
||||
let refreshBody = JSON.stringify({
|
||||
...JSON.parse(data),
|
||||
token: userData?.refresh_token,
|
||||
})
|
||||
fetchOptions.body = isRefresh ? refreshBody : data
|
||||
} else {
|
||||
if (isParamsValid(data)) {
|
||||
let test = ""
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
test += `&${key}=${value}`
|
||||
}
|
||||
url += `?${test}`
|
||||
}
|
||||
}
|
||||
|
||||
const isPrefixUrl = url.startsWith("http://") || url.startsWith("https://")
|
||||
|
||||
const fetchUrl = isPrefixUrl ? url : `${process.env.NEXT_PUBLIC_URL}${url}`
|
||||
|
||||
const res = await fetch(fetchUrl, fetchOptions)
|
||||
|
||||
if (res?.status !== 200) {
|
||||
let error = ""
|
||||
if (res.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const data = await res.json()
|
||||
error = data?.message || ""
|
||||
} else {
|
||||
error = await res.text()
|
||||
}
|
||||
return NextResponse.json({ message: error }, { status: res?.status })
|
||||
}
|
||||
|
||||
if (
|
||||
options?.responseType === "blob" ||
|
||||
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") || "",
|
||||
},
|
||||
})
|
||||
} else if (!notJson) {
|
||||
const data = await res.json()
|
||||
|
||||
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,
|
||||
fingerprint: body.fingerprint,
|
||||
data,
|
||||
})
|
||||
return new NextResponse(JSON.stringify(newData), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (isRefresh) {
|
||||
const newData = await handleRefreshToken({
|
||||
userData,
|
||||
data,
|
||||
})
|
||||
return new NextResponse(JSON.stringify(newData), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new NextResponse(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err }, { status: 500 })
|
||||
}
|
||||
}
|
||||
5
app/component/base/department/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentBaseDepartmentPage() {
|
||||
return <>ComponentBaseDepartmentPage</>
|
||||
}
|
||||
33
app/component/base/placement/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Divider,
|
||||
Paper,
|
||||
Stack,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
|
||||
export default function ComponentBasePlacementPage() {
|
||||
const items = [{ title: "布局组件" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md">
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
<Paper shadow="xs" p="0" flex={1} display="flex">
|
||||
<Stack flex={1}>
|
||||
<Stack flex={1} justify="center" align="center">
|
||||
<Text c="dimmed">空</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Divider orientation="vertical"></Divider>
|
||||
<Stack flex={2}></Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
69
app/component/base/select/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import TreeSelect from "@/components/tree-select"
|
||||
import { getCityTree } from "@/components/tree-select/api"
|
||||
import { TreeNode } from "@/components/tree-select/api/type"
|
||||
import { VirtualTreeSelect } from "@/components/tree-select/tree"
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Flex,
|
||||
Paper,
|
||||
Stack,
|
||||
TreeNodeData,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
export const convertTreeNodeToTreeNodeData = (
|
||||
list: TreeNode[],
|
||||
level: number = 1
|
||||
): TreeNodeData[] =>
|
||||
list.map((g) => ({
|
||||
label: g.label,
|
||||
value: g.value,
|
||||
nodeProps: { level: level },
|
||||
children:
|
||||
g.children && g.children.length
|
||||
? convertTreeNodeToTreeNodeData(g.children, level + 1)
|
||||
: [],
|
||||
}))
|
||||
|
||||
export default function ComponentBaseSelectPage() {
|
||||
const items = [{ title: "选择组件" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
|
||||
const [value, setValue] = useState<string>("")
|
||||
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
|
||||
const asyncGetCityTree = useCallback(async () => {
|
||||
const res = await getCityTree()
|
||||
setTreeData(convertTreeNodeToTreeNodeData(res))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => asyncGetCityTree())
|
||||
}, [asyncGetCityTree])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md">
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
<Paper shadow="xs" p={"md"} flex={1} display="flex">
|
||||
<Flex h={"fit-content"} gap={"lg"}>
|
||||
<TreeSelect treeData={treeData} />
|
||||
|
||||
<VirtualTreeSelect
|
||||
data={treeData}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
setCurrCityCode={() => {}}
|
||||
w={"200px"}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex>{value}</Flex>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
app/component/label/ffmpeg/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Video Frame Extractor (FFmpeg.wasm)
|
||||
|
||||
## 运行位置
|
||||
- 纯前端:所有转码/抽帧在浏览器 Web Worker + WASM 内完成,不依赖后端服务。
|
||||
|
||||
## 大文件最佳实践
|
||||
- 优先使用 WORKERFS 挂载输入文件,避免 `writeFile(arrayBuffer)` 将整段视频复制进 WASM 内存。
|
||||
- 抽帧建议走“单帧模式”:每次 `-ss <time> -frames:v 1` 只产出一张,读取后立即删除,避免在 WASM 内累积。
|
||||
- 输出建议使用 image2 序列 pattern(例如 `frame_%06d.jpg`)并配合 `-start_number`,避免 image2 的“必须是序列 pattern”报错路径。
|
||||
- UI 端必须对日志与图片列表做上限与释放(`URL.revokeObjectURL`),否则长视频会导致 JS 堆持续增长。
|
||||
|
||||
## 故障恢复
|
||||
- 一旦出现 `Aborted()` / `memory access out of bounds`,当前 FFmpeg.wasm 实例可能进入不可恢复状态,需要 terminate 并重建实例/worker。
|
||||
|
||||
326
app/component/label/ffmpeg/VideoFrameExtractor.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
FileInput,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconMovie,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { appendCapped } from "./cap"
|
||||
import type { WorkerResponse } from "./ffmpeg.worker"
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [message, setMessage] = useState("Wait for loading...")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [images, setImages] = useState<{ url: string; name: string }[]>([])
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
const messageRef = useRef<HTMLDivElement>(null)
|
||||
const extractedCountRef = useRef(0)
|
||||
|
||||
const MAX_LOG_LINES = 300
|
||||
const MAX_IMAGES = 1500
|
||||
|
||||
// Initialize Worker once
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!workerRef.current) {
|
||||
setIsLoading(true)
|
||||
|
||||
const baseURL = window.location.origin + "/wasm"
|
||||
|
||||
const createWorker = () => {
|
||||
const worker = new Worker(
|
||||
new URL("./ffmpeg.worker.ts", import.meta.url)
|
||||
)
|
||||
workerRef.current = worker
|
||||
return worker
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const { type } = event.data
|
||||
|
||||
switch (type) {
|
||||
case "READY":
|
||||
setLoaded(true)
|
||||
setIsLoading(false)
|
||||
setMessage("FFmpeg loaded successfully")
|
||||
break
|
||||
case "LOG":
|
||||
if ("message" in event.data) {
|
||||
const msg = event.data.message
|
||||
setLogs((prev) => appendCapped(prev, [msg], MAX_LOG_LINES))
|
||||
if (messageRef.current) {
|
||||
messageRef.current.scrollTop = messageRef.current.scrollHeight
|
||||
}
|
||||
}
|
||||
break
|
||||
case "PROGRESS":
|
||||
if ("progress" in event.data) {
|
||||
const p = Math.round(event.data.progress * 100)
|
||||
setProgress(p)
|
||||
setMessage(`Processing... ${p}%`)
|
||||
}
|
||||
break
|
||||
case "SEGMENT_DATA":
|
||||
if ("files" in event.data) {
|
||||
const newImages = event.data.files.map((f: any) => {
|
||||
const blob = new Blob([f.data], { type: "image/jpeg" })
|
||||
return { url: URL.createObjectURL(blob), name: f.name }
|
||||
})
|
||||
extractedCountRef.current += newImages.length
|
||||
setImages((prev) =>
|
||||
appendCapped(prev, newImages, MAX_IMAGES, (img) =>
|
||||
URL.revokeObjectURL((img as any).url)
|
||||
)
|
||||
)
|
||||
setMessage(
|
||||
`Processing... Extracted ${extractedCountRef.current} frames.`
|
||||
)
|
||||
}
|
||||
break
|
||||
case "DONE":
|
||||
setMessage(
|
||||
`Completed! Extracted ${extractedCountRef.current} frames.`
|
||||
)
|
||||
setProcessing(false)
|
||||
break
|
||||
case "FATAL":
|
||||
if ("error" in event.data) {
|
||||
setError(event.data.error)
|
||||
setProcessing(false)
|
||||
setLoaded(false)
|
||||
setIsLoading(true)
|
||||
}
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate()
|
||||
workerRef.current = null
|
||||
}
|
||||
{
|
||||
const worker = createWorker()
|
||||
worker.onmessage = handleMessage
|
||||
worker.postMessage({ type: "LOAD", baseURL })
|
||||
}
|
||||
break
|
||||
case "ERROR":
|
||||
if ("error" in event.data) {
|
||||
setError(event.data.error)
|
||||
setProcessing(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const worker = createWorker()
|
||||
worker.onmessage = handleMessage
|
||||
|
||||
worker.postMessage({ type: "LOAD", baseURL })
|
||||
}
|
||||
})
|
||||
// Only create worker if not exists
|
||||
|
||||
// Cleanup worker on unmount?
|
||||
// Usually good practice, but if we want to keep it alive for navigation back/forth,
|
||||
// we might want to move it to Context.
|
||||
// For this task, local cleanup is fine.
|
||||
return () => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate()
|
||||
workerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const processVideo = () => {
|
||||
if (!file || !loaded || !workerRef.current) return
|
||||
|
||||
setProcessing(true)
|
||||
setProgress(0)
|
||||
extractedCountRef.current = 0
|
||||
for (const img of images) URL.revokeObjectURL(img.url)
|
||||
setImages([])
|
||||
setLogs([])
|
||||
setError(null)
|
||||
setMessage("Starting processing...")
|
||||
|
||||
// Extract 1 frame per second: -vf fps=1
|
||||
// Optimize: Scale down to 720p max width to save memory
|
||||
workerRef.current.postMessage({
|
||||
type: "EXEC",
|
||||
file,
|
||||
args: [
|
||||
"-vf",
|
||||
"fps=1,scale='min(720,iw)':-1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"frame_%03d.jpg",
|
||||
],
|
||||
outputPattern: "frame_",
|
||||
})
|
||||
}
|
||||
|
||||
const downloadAll = () => {
|
||||
images.forEach((img, index) => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement("a")
|
||||
a.href = img.url
|
||||
a.download = img.name
|
||||
a.click()
|
||||
}, index * 200)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Video Frame Extractor (Worker + Local WASM)</Title>
|
||||
<Text c="dimmed">
|
||||
Extract frames from your video files purely in the browser using
|
||||
FFmpeg.wasm (Async Worker).
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Error"
|
||||
color="red"
|
||||
withCloseButton
|
||||
onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder shadow="sm" p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<FileInput
|
||||
label="Select Video File"
|
||||
placeholder="Click to select MP4, WebM..."
|
||||
accept="video/*"
|
||||
leftSection={<IconMovie size={16} />}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
disabled={!loaded || processing}
|
||||
/>
|
||||
|
||||
{!loaded && isLoading && (
|
||||
<Group>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">Loading FFmpeg core...</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{loaded && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={processVideo}
|
||||
disabled={!file || processing}
|
||||
loading={processing}
|
||||
leftSection={<IconPhoto size={16} />}>
|
||||
{processing ? "Processing..." : "Start Extraction"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{processing && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Progress value={progress} animated />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<Card withBorder shadow="sm" p="xs" radius="md" bg="gray.0">
|
||||
<Text size="xs" fw={500} mb="xs">
|
||||
Logs
|
||||
</Text>
|
||||
<ScrollArea h={100} type="always" viewportRef={messageRef}>
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "11px",
|
||||
}}>
|
||||
{logs.map((log, i) => (
|
||||
<div key={i}>{log}</div>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={3}>Extracted Frames ({images.length})</Title>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={downloadAll}>
|
||||
Download All
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: 4 }} spacing="md">
|
||||
{images.map((img) => (
|
||||
<Card
|
||||
key={img.name}
|
||||
shadow="sm"
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder>
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={img.url}
|
||||
height={160}
|
||||
alt={img.name}
|
||||
fit="cover"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Group justify="space-between" mt="xs" mb="xs">
|
||||
<Text fw={500} size="xs" truncate>
|
||||
{img.name}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
component="a"
|
||||
href={img.url}
|
||||
download={img.name}>
|
||||
<IconDownload size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
18
app/component/label/ffmpeg/cap.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { appendCapped } from "./cap"
|
||||
|
||||
describe("appendCapped", () => {
|
||||
test("keeps all items when under max", () => {
|
||||
expect(appendCapped([1, 2], [3], 10)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("drops from head when exceeding max", () => {
|
||||
expect(appendCapped([1, 2], [3, 4, 5], 3)).toEqual([3, 4, 5])
|
||||
})
|
||||
|
||||
test("calls onDrop for dropped items", () => {
|
||||
const dropped: number[] = []
|
||||
const result = appendCapped([1, 2], [3, 4, 5], 3, (x) => dropped.push(x))
|
||||
expect(result).toEqual([3, 4, 5])
|
||||
expect(dropped).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
14
app/component/label/ffmpeg/cap.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const appendCapped = <T>(
|
||||
prev: T[],
|
||||
next: T[],
|
||||
max: number,
|
||||
onDrop?: (item: T) => void
|
||||
) => {
|
||||
const merged = [...prev, ...next]
|
||||
if (merged.length <= max) return merged
|
||||
const dropCount = merged.length - max
|
||||
if (onDrop) {
|
||||
for (let i = 0; i < dropCount; i += 1) onDrop(merged[i])
|
||||
}
|
||||
return merged.slice(dropCount)
|
||||
}
|
||||
520
app/component/label/ffmpeg/ffmpeg.worker.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
// Define types for worker messages
|
||||
export type WorkerMessage =
|
||||
| { type: "LOAD"; baseURL: string }
|
||||
| { type: "EXEC"; file: File; args: string[]; outputPattern: string }
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: "READY" }
|
||||
| { type: "LOG"; message: string }
|
||||
| { type: "PROGRESS"; progress: number }
|
||||
| { type: "DONE" }
|
||||
| { type: "SEGMENT_DATA"; files: { name: string; data: Uint8Array }[] }
|
||||
| { type: "FATAL"; error: string }
|
||||
| { type: "ERROR"; error: string }
|
||||
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg"
|
||||
|
||||
const ctx: Worker = self as any
|
||||
const ffmpeg = new FFmpeg()
|
||||
|
||||
let loaded = false
|
||||
let lastBaseURL: string | null = null
|
||||
|
||||
// Custom Logger
|
||||
ffmpeg.on("log", ({ message }) => {
|
||||
ctx.postMessage({ type: "LOG", message })
|
||||
})
|
||||
|
||||
ffmpeg.on("progress", ({ progress }) => {
|
||||
ctx.postMessage({ type: "PROGRESS", progress })
|
||||
})
|
||||
|
||||
ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const { type } = event.data
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "LOAD":
|
||||
if (loaded) {
|
||||
ctx.postMessage({ type: "READY" })
|
||||
return
|
||||
}
|
||||
const { baseURL } = event.data
|
||||
lastBaseURL = baseURL
|
||||
|
||||
await ffmpeg.load({
|
||||
coreURL: `${baseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
|
||||
})
|
||||
|
||||
loaded = true
|
||||
ctx.postMessage({ type: "READY" })
|
||||
break
|
||||
|
||||
case "EXEC":
|
||||
if (!loaded) throw new Error("FFmpeg not loaded")
|
||||
const { file, args, outputPattern } = event.data
|
||||
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[start] outputPattern=${outputPattern}`,
|
||||
})
|
||||
|
||||
const mountDir = "/input_mount"
|
||||
const inputPath = `${mountDir}/${file.name}`
|
||||
|
||||
const mountInput = async () => {
|
||||
await ffmpeg.createDir(mountDir)
|
||||
await ffmpeg.mount("WORKERFS" as any, { files: [file] }, mountDir)
|
||||
}
|
||||
|
||||
const unmountInput = async () => {
|
||||
await ffmpeg.unmount(mountDir).catch(() => {})
|
||||
await ffmpeg.deleteDir(mountDir).catch(() => {})
|
||||
}
|
||||
|
||||
await mountInput()
|
||||
|
||||
try {
|
||||
const normalizeError = (e: unknown) => {
|
||||
if (typeof e === "string") return e
|
||||
if (e && typeof e === "object" && "message" in e)
|
||||
return String((e as any).message)
|
||||
return String(e)
|
||||
}
|
||||
|
||||
const isOom = (msg: string) =>
|
||||
msg.includes("memory access out of bounds") ||
|
||||
msg.includes("Cannot enlarge memory") ||
|
||||
msg.includes("Aborted")
|
||||
|
||||
const nowMs = () =>
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
|
||||
const makeThrottledLogger = (intervalMs: number) => {
|
||||
let last = 0
|
||||
return (message: string) => {
|
||||
const t = nowMs()
|
||||
if (t - last < intervalMs) return
|
||||
last = t
|
||||
ctx.postMessage({ type: "LOG", message })
|
||||
}
|
||||
}
|
||||
|
||||
const logThrottled = makeThrottledLogger(1000)
|
||||
|
||||
const findArgValue = (flag: string) => {
|
||||
const idx = args.indexOf(flag)
|
||||
if (idx < 0) return null
|
||||
const value = args[idx + 1]
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
const parseFps = () => {
|
||||
const vf = findArgValue("-vf")
|
||||
if (!vf) return null
|
||||
const match = vf.match(/(?:^|,)fps=(\d+(?:\.\d+)?)(?:,|$)/)
|
||||
if (!match) return null
|
||||
const fps = Number(match[1])
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : null
|
||||
}
|
||||
|
||||
let durationSec = 0
|
||||
const durationProbePath = "__duration.txt"
|
||||
try {
|
||||
await ffmpeg.ffprobe([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
inputPath,
|
||||
"-o",
|
||||
durationProbePath,
|
||||
])
|
||||
const txt = (await ffmpeg.readFile(
|
||||
durationProbePath,
|
||||
"utf8"
|
||||
)) as string
|
||||
const parsed = Number.parseFloat(String(txt).trim())
|
||||
if (Number.isFinite(parsed) && parsed > 0) durationSec = parsed
|
||||
} catch (e) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[probe] ffprobe failed: ${normalizeError(e)}`,
|
||||
})
|
||||
} finally {
|
||||
await ffmpeg.deleteFile(durationProbePath).catch(() => {})
|
||||
}
|
||||
|
||||
if (!durationSec) {
|
||||
const logHandler = ({ message }: { message: string }) => {
|
||||
const match = message.match(
|
||||
/Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/
|
||||
)
|
||||
if (match) {
|
||||
const [, h, m, s] = match
|
||||
durationSec =
|
||||
parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s)
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg.on("log", logHandler)
|
||||
try {
|
||||
await ffmpeg.exec(["-i", inputPath])
|
||||
} catch (e) {
|
||||
void e
|
||||
} finally {
|
||||
ffmpeg.off("log", logHandler)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[probe] durationSec=${durationSec || "unknown"}`,
|
||||
})
|
||||
if (!durationSec) durationSec = 600
|
||||
|
||||
const fps = parseFps() ?? 1
|
||||
const stepSec = 1 / fps
|
||||
const preferSingleFrame = file.size >= 20 * 1024 * 1024
|
||||
const maxFrames = preferSingleFrame ? 30 : 0
|
||||
const reloadEvery = preferSingleFrame ? 10 : 0
|
||||
let sentBytes = 0
|
||||
|
||||
const maxWidth =
|
||||
file.size >= 40 * 1024 * 1024 ? 360 : preferSingleFrame ? 480 : 720
|
||||
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[config] fileMB=${Math.round(file.size / 1024 / 1024)} fps=${fps} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame}`,
|
||||
})
|
||||
|
||||
const outputExt = (() => {
|
||||
for (let i = args.length - 1; i >= 0; i -= 1) {
|
||||
const v = args[i]
|
||||
if (typeof v !== "string") continue
|
||||
const m = v.match(/\.([a-zA-Z0-9]+)$/)
|
||||
if (m) return m[1].toLowerCase()
|
||||
}
|
||||
return "jpg"
|
||||
})()
|
||||
|
||||
const buildSingleFrameArgs = (
|
||||
outputPattern: string,
|
||||
startNumber: number
|
||||
) => {
|
||||
const vf = findArgValue("-vf")
|
||||
const qv = findArgValue("-q:v")
|
||||
const baseFilter =
|
||||
vf
|
||||
?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "")
|
||||
.replace(/^,|,$/g, "")
|
||||
.replace(/,,+/g, ",") ?? null
|
||||
|
||||
const patchedFilter = (() => {
|
||||
if (!baseFilter || !baseFilter.trim()) return null
|
||||
let f = baseFilter
|
||||
f = f.replace(/min\(\s*720\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
|
||||
f = f.replace(/min\(\s*480\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
|
||||
f = f.replace(/min\(\s*360\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
|
||||
if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p`
|
||||
return f
|
||||
})()
|
||||
|
||||
const out: string[] = []
|
||||
if (patchedFilter && patchedFilter.trim())
|
||||
out.push("-vf", patchedFilter)
|
||||
if (qv) {
|
||||
const qNum = Number(qv)
|
||||
if (Number.isFinite(qNum) && preferSingleFrame) {
|
||||
out.push("-q:v", String(Math.max(qNum, 6)))
|
||||
} else {
|
||||
out.push("-q:v", qv)
|
||||
}
|
||||
}
|
||||
out.push("-pix_fmt", "yuv420p")
|
||||
out.push("-start_number", String(startNumber))
|
||||
out.push("-frames:v", "1", outputPattern)
|
||||
return out
|
||||
}
|
||||
|
||||
const segmentCandidates = preferSingleFrame
|
||||
? []
|
||||
: [2, 1.2, 1].filter((v) => v >= stepSec && v > 0)
|
||||
|
||||
const trySegmentDuration = async (segmentDurationSec: number) => {
|
||||
let t = 0
|
||||
let emptyStreak = 0
|
||||
let producedAny = false
|
||||
|
||||
while (t < durationSec) {
|
||||
logThrottled(
|
||||
`[exec] mode=segment t=${t.toFixed(3)} dur=${segmentDurationSec}`
|
||||
)
|
||||
|
||||
const segmentArgs = [
|
||||
"-ss",
|
||||
t.toString(),
|
||||
"-t",
|
||||
segmentDurationSec.toString(),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-threads",
|
||||
"1",
|
||||
"-an",
|
||||
"-sn",
|
||||
"-dn",
|
||||
...args,
|
||||
]
|
||||
|
||||
const ret = await ffmpeg.exec(segmentArgs)
|
||||
if (ret !== 0) {
|
||||
logThrottled(
|
||||
`[exec] mode=segment ret=${ret} t=${t.toFixed(3)} dur=${segmentDurationSec}`
|
||||
)
|
||||
}
|
||||
|
||||
const files = await ffmpeg.listDir(".")
|
||||
const imageFiles = files.filter(
|
||||
(f) =>
|
||||
!f.isDir &&
|
||||
f.name.startsWith("frame_") &&
|
||||
f.name.endsWith(`.${outputExt}`)
|
||||
)
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
emptyStreak += 1
|
||||
if (emptyStreak >= Math.ceil(5 / segmentDurationSec)) break
|
||||
} else {
|
||||
emptyStreak = 0
|
||||
producedAny = true
|
||||
}
|
||||
|
||||
const segmentFiles: { name: string; data: Uint8Array }[] = []
|
||||
const transfer: ArrayBuffer[] = []
|
||||
|
||||
for (const f of imageFiles) {
|
||||
const raw = (await ffmpeg.readFile(f.name)) as Uint8Array
|
||||
const copy = raw.slice()
|
||||
segmentFiles.push({
|
||||
name: `t_${t.toFixed(3)}_${f.name}`,
|
||||
data: copy,
|
||||
})
|
||||
transfer.push(copy.buffer)
|
||||
await ffmpeg.deleteFile(f.name)
|
||||
}
|
||||
|
||||
if (segmentFiles.length > 0) {
|
||||
ctx.postMessage(
|
||||
{ type: "SEGMENT_DATA", files: segmentFiles },
|
||||
transfer
|
||||
)
|
||||
}
|
||||
|
||||
ctx.postMessage({
|
||||
type: "PROGRESS",
|
||||
progress: Math.min((t + segmentDurationSec) / durationSec, 1),
|
||||
})
|
||||
|
||||
t += segmentDurationSec
|
||||
}
|
||||
|
||||
if (!producedAny) {
|
||||
throw new Error("NO_FRAMES")
|
||||
}
|
||||
}
|
||||
|
||||
const runSingleFrameMode = async () => {
|
||||
let index = 0
|
||||
let emptyStreak = 0
|
||||
const pattern = `frame_%06d.${outputExt}`
|
||||
|
||||
const reloadCore = async (reason: string) => {
|
||||
if (!lastBaseURL) return
|
||||
ctx.postMessage({ type: "LOG", message: `[reload] ${reason}` })
|
||||
await unmountInput()
|
||||
ffmpeg.terminate()
|
||||
loaded = false
|
||||
await ffmpeg.load({
|
||||
coreURL: `${lastBaseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
|
||||
})
|
||||
loaded = true
|
||||
await mountInput()
|
||||
}
|
||||
|
||||
for (let t = 0; t < durationSec; t += stepSec) {
|
||||
if (maxFrames > 0 && index >= maxFrames) break
|
||||
if (reloadEvery > 0 && index > 0 && index % reloadEvery === 0) {
|
||||
await reloadCore(`periodic frame=${index}`)
|
||||
}
|
||||
|
||||
const outputName = `frame_${String(index).padStart(6, "0")}.${outputExt}`
|
||||
const singleFrameArgs = [
|
||||
"-ss",
|
||||
t.toString(),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-threads",
|
||||
"1",
|
||||
"-an",
|
||||
"-sn",
|
||||
"-dn",
|
||||
...(buildSingleFrameArgs(pattern, index) || []),
|
||||
]
|
||||
|
||||
try {
|
||||
const ret = await ffmpeg.exec(singleFrameArgs)
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[exec] mode=single ret=${ret} t=${t.toFixed(3)} out=${outputName}`,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = normalizeError(e)
|
||||
if (isOom(msg)) throw e
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[exec] mode=single error t=${t.toFixed(3)}: ${msg}`,
|
||||
})
|
||||
if (
|
||||
msg.includes("At least one output file must be specified")
|
||||
) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[exec] mode=single argsTail=${JSON.stringify(
|
||||
singleFrameArgs.slice(-12)
|
||||
)}`,
|
||||
})
|
||||
}
|
||||
await ffmpeg.deleteFile(outputName).catch(() => {})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
|
||||
const copy = raw.slice()
|
||||
sentBytes += copy.byteLength
|
||||
await ffmpeg.deleteFile(outputName).catch(() => {})
|
||||
|
||||
ctx.postMessage(
|
||||
{
|
||||
type: "SEGMENT_DATA",
|
||||
files: [{ name: outputName, data: copy }],
|
||||
},
|
||||
[copy.buffer]
|
||||
)
|
||||
|
||||
logThrottled(
|
||||
`[frame] idx=${index} t=${t.toFixed(3)} size=${copy.byteLength} sentMB=${Math.round((sentBytes / 1024 / 1024) * 10) / 10}`
|
||||
)
|
||||
} catch (e) {
|
||||
void e
|
||||
emptyStreak += 1
|
||||
if (emptyStreak >= 3) break
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
emptyStreak = 0
|
||||
|
||||
ctx.postMessage({
|
||||
type: "PROGRESS",
|
||||
progress: Math.min((t + stepSec) / durationSec, 1),
|
||||
})
|
||||
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
let segmentOk = false
|
||||
for (const seg of segmentCandidates) {
|
||||
try {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[mode] trying segmentDuration=${seg}s stepSec=${stepSec}s`,
|
||||
})
|
||||
await trySegmentDuration(seg)
|
||||
segmentOk = true
|
||||
break
|
||||
} catch (e) {
|
||||
const msg = normalizeError(e)
|
||||
if (msg.includes("NO_FRAMES")) break
|
||||
if (
|
||||
msg.includes("Output file is empty") ||
|
||||
msg.includes("nothing was encoded")
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (!isOom(msg)) throw e
|
||||
}
|
||||
}
|
||||
if (!segmentOk) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[mode] segment mode unavailable; fallback to single-frame sampling`,
|
||||
})
|
||||
await runSingleFrameMode()
|
||||
}
|
||||
|
||||
// Send DONE without data
|
||||
ctx.postMessage({ type: "DONE" })
|
||||
} catch (e: any) {
|
||||
const errorMessage =
|
||||
typeof e === "string" ? e : e?.message || String(e)
|
||||
if (errorMessage.includes("memory access out of bounds")) {
|
||||
try {
|
||||
ffmpeg.terminate()
|
||||
loaded = false
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[fatal] ffmpeg terminated due to: ${errorMessage}`,
|
||||
})
|
||||
|
||||
if (lastBaseURL) {
|
||||
await ffmpeg.load({
|
||||
coreURL: `${lastBaseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
|
||||
})
|
||||
loaded = true
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[fatal] ffmpeg reloaded`,
|
||||
})
|
||||
}
|
||||
} catch (e2) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[fatal] reload failed: ${
|
||||
typeof e2 === "string"
|
||||
? e2
|
||||
: (e2 as any)?.message || String(e2)
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.postMessage({ type: "FATAL", error: errorMessage })
|
||||
return
|
||||
}
|
||||
|
||||
throw e
|
||||
} finally {
|
||||
// Cleanup input
|
||||
try {
|
||||
await unmountInput()
|
||||
} catch (e) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[cleanup] ${typeof e === "string" ? e : (e as any)?.message || String(e)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (error: any) {
|
||||
const msg =
|
||||
typeof error === "string" ? error : error?.message || String(error)
|
||||
ctx.postMessage({ type: "ERROR", error: msg })
|
||||
}
|
||||
}
|
||||
7
app/component/label/ffmpeg/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import VideoFrameExtractor from "./VideoFrameExtractor"
|
||||
|
||||
export default function ComponentLabelFfmpegPage() {
|
||||
return <VideoFrameExtractor />
|
||||
}
|
||||
39
app/component/label/login/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { userLogin } from "@/components/label/api/label"
|
||||
import { usePermissionStore } from "@/components/label/store/auth"
|
||||
import { Button } from "@mantine/core"
|
||||
|
||||
export default function ComponentLabelLoginPage() {
|
||||
const { setUserInfo, setUserPassword } = usePermissionStore()
|
||||
const user_id = usePermissionStore.getState().user_id
|
||||
const token = usePermissionStore.getState().token
|
||||
|
||||
// 用户登录
|
||||
const handleUserLoginBtnClick = async () => {
|
||||
try {
|
||||
const res = await userLogin({
|
||||
name: "admin",
|
||||
password: "123456",
|
||||
})
|
||||
const { uid, name, token, refresh_token } = res
|
||||
setUserInfo({
|
||||
user_id: uid,
|
||||
user_name: name,
|
||||
token,
|
||||
refresh_token,
|
||||
detailInfo: res,
|
||||
})
|
||||
setUserPassword("123456")
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleUserLoginBtnClick}>Login</Button>
|
||||
user_id:{user_id}
|
||||
token:{token}
|
||||
</>
|
||||
)
|
||||
}
|
||||
41
app/component/label/picture/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import LabelContent from "@/components/label"
|
||||
import { useAppLayoutStore } from "@/components/layout/store"
|
||||
import { Breadcrumbs, Flex, Paper, Stack, UnstyledButton } from "@mantine/core"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export default function ComponentLabelPicturePage() {
|
||||
const items = [{ title: "地图展示" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
const { isOpen } = useAppLayoutStore()
|
||||
let leftWidth = useMemo(() => {
|
||||
let width = 69
|
||||
return !isOpen ? width : width + 160
|
||||
}, [isOpen])
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p={"md"}>
|
||||
<Flex justify={"space-between"} align={"center"} h={"16px"}>
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
</Flex>
|
||||
<Paper
|
||||
shadow="xs"
|
||||
p="0"
|
||||
flex={1}
|
||||
display="flex"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<LabelContent
|
||||
headerHeight={128}
|
||||
leftWidth={leftWidth}
|
||||
project_id={1}
|
||||
task_id={3}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
11
app/component/label/picture/standalone/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import LabelContent from "@/components/label"
|
||||
|
||||
export default function ComponentLabelPictureStandalonePage() {
|
||||
return (
|
||||
<>
|
||||
<LabelContent headerHeight={0} leftWidth={0} project_id={1} task_id={3} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
406
app/component/label/video/libav.worker.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// src/workers/ffmpeg.worker.ts
|
||||
import type { LibAVWrapper, WorkerMessage, WorkerResponse } from "./types/libav"
|
||||
|
||||
const ctx: Worker = self as any
|
||||
let libav: any = null // Use any to access full API
|
||||
const PROXY_FILE = "proxy.avi"
|
||||
|
||||
ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const { type } = event.data
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "LOAD":
|
||||
await loadLibAV(event.data.config.corePath)
|
||||
break
|
||||
case "TRANSCODE":
|
||||
if (!libav) throw new Error("LibAV not loaded")
|
||||
if ("file" in event.data) {
|
||||
await handleTranscode(event.data.file)
|
||||
}
|
||||
break
|
||||
case "EXTRACT_FRAME":
|
||||
if (!libav) throw new Error("LibAV not loaded")
|
||||
if ("time" in event.data) {
|
||||
await handleExtractFrame(event.data.time)
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Worker Error:", err)
|
||||
ctx.postMessage({
|
||||
type: "ERROR",
|
||||
error: err.message || "Unknown error",
|
||||
} as WorkerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLibAV(corePath: string) {
|
||||
if (libav) {
|
||||
ctx.postMessage({ type: "READY" } as WorkerResponse)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
importScripts(corePath)
|
||||
|
||||
const LibAVWrapper = (self as any).LibAV as LibAVWrapper
|
||||
if (!LibAVWrapper || typeof LibAVWrapper.LibAV !== "function") {
|
||||
throw new Error("LibAV factory not found.")
|
||||
}
|
||||
|
||||
const baseUrl = corePath.substring(0, corePath.lastIndexOf("/") + 1)
|
||||
// Dynamic WASM URL based on the JS file name (standard convention)
|
||||
const wasmUrl = corePath.replace(".js", ".wasm.wasm")
|
||||
|
||||
libav = await LibAVWrapper.LibAV({
|
||||
noworker: true,
|
||||
base: baseUrl,
|
||||
wasmurl: wasmUrl,
|
||||
})
|
||||
|
||||
ctx.postMessage({ type: "READY" } as WorkerResponse)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load libav: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTranscode(file: File) {
|
||||
const ext = file.name.match(/\.([^.]+)$/)?.[0] || ".mp4"
|
||||
const inputFile = `input_video${ext}`
|
||||
|
||||
try {
|
||||
// 1. Write Input File
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const u8Arr = new Uint8Array(fileBuffer)
|
||||
console.log(`[Worker] Received file: ${file.name}, size: ${u8Arr.length}`)
|
||||
|
||||
await libav.writeFile(inputFile, u8Arr)
|
||||
|
||||
// Note: libav.js (webcodecs-avf) does not support readdir/stat via API directly
|
||||
// but readFile works. We trust writeFile worked.
|
||||
|
||||
// 2. Init Demuxer (Input)
|
||||
// webcodecs-avf SHOULD have the demuxer for MP4.
|
||||
let fmt_ctx, streams
|
||||
try {
|
||||
;[fmt_ctx, streams] = await libav.ff_init_demuxer_file(inputFile)
|
||||
} catch (e) {
|
||||
console.error("Demuxer init failed:", e)
|
||||
throw new Error(
|
||||
`Demuxer failed. Libav variant might lack MP4 support. Error: ${e}`
|
||||
)
|
||||
}
|
||||
|
||||
// Find Video Stream
|
||||
let videoStreamIdx = -1
|
||||
let videoStream = null
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
if (streams[i].codec_type === 0) {
|
||||
// 0 is Video
|
||||
videoStreamIdx = i
|
||||
videoStream = streams[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (videoStreamIdx === -1) throw new Error("No video stream found")
|
||||
|
||||
console.log(
|
||||
`[Worker] Found video stream #${videoStreamIdx}, codec_id=${videoStream.codec_id}`
|
||||
)
|
||||
|
||||
// 3. Init Decoder (Input)
|
||||
// For obsolete variant, we might have software H.264 decoders.
|
||||
// Try standard init first.
|
||||
|
||||
let c, pkt, frame
|
||||
try {
|
||||
// Try standard init first
|
||||
;[, c, pkt, frame] = await libav.ff_init_decoder(
|
||||
videoStream.codec_id,
|
||||
videoStream.codecpar
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[Worker] Standard decoder init failed, trying explicit H264...",
|
||||
e
|
||||
)
|
||||
try {
|
||||
// Try explicit H264
|
||||
;[, c, pkt, frame] = await libav.ff_init_decoder(
|
||||
"h264",
|
||||
videoStream.codecpar
|
||||
)
|
||||
} catch (e2) {
|
||||
console.error("[Worker] All decoder init attempts failed", e2)
|
||||
throw new Error("Codec not found (decoder init failed).")
|
||||
}
|
||||
}
|
||||
|
||||
// Get input properties
|
||||
let width = await libav.AVCodecParameters_width(videoStream.codecpar)
|
||||
let height = await libav.AVCodecParameters_height(videoStream.codecpar)
|
||||
|
||||
console.log(`[Worker] Video dimensions: ${width}x${height}`)
|
||||
|
||||
// 4. Init Encoder (Output: MJPEG)
|
||||
const codec_name = "mjpeg"
|
||||
const [, enc_c, enc_frame, enc_pkt] = await libav.ff_init_encoder(
|
||||
codec_name,
|
||||
{
|
||||
ctx: {
|
||||
width: width,
|
||||
height: height,
|
||||
pix_fmt: 0, // AV_PIX_FMT_YUV420P
|
||||
time_base: [1, 25],
|
||||
framerate: [25, 1],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 5. Init Muxer (Output: AVI container)
|
||||
const [oc, , pb, [stream_ctx]] = await libav.ff_init_muxer(
|
||||
{ format_name: "avi", filename: PROXY_FILE, open: true },
|
||||
[[enc_c, 1, 25]]
|
||||
)
|
||||
|
||||
console.log(stream_ctx)
|
||||
|
||||
// Write Header
|
||||
await libav.avformat_write_header(oc, 0)
|
||||
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: "Transcoding started...",
|
||||
} as WorkerResponse)
|
||||
|
||||
// 6. Loop: Read -> Decode -> Encode -> Write
|
||||
let frameCount = 0
|
||||
|
||||
// We need to use "video" copyout mode for WebCodecs to work efficiently?
|
||||
// Actually, ff_decode_multi returns Frame objects.
|
||||
// If WebCodecs is used, the Frame might be backed by VideoFrame.
|
||||
|
||||
while (true) {
|
||||
const [res, packets] = await libav.ff_read_frame_multi(fmt_ctx, pkt)
|
||||
|
||||
if (res === libav.AVERROR_EOF) break
|
||||
if (res < 0 && res !== -11) break
|
||||
|
||||
const streamPackets = packets[videoStreamIdx]
|
||||
if (!streamPackets || streamPackets.length === 0) continue
|
||||
|
||||
// Decode
|
||||
const decodedFrames = await libav.ff_decode_multi(
|
||||
c,
|
||||
pkt,
|
||||
frame,
|
||||
streamPackets
|
||||
)
|
||||
|
||||
for (const f of decodedFrames) {
|
||||
console.log(f)
|
||||
|
||||
// Encode
|
||||
const encodedPackets = await libav.ff_encode_multi(
|
||||
enc_c,
|
||||
enc_frame,
|
||||
enc_pkt
|
||||
)
|
||||
|
||||
for (const p of encodedPackets) {
|
||||
p.stream_index = 0
|
||||
}
|
||||
|
||||
await libav.ff_write_multi(oc, enc_pkt, encodedPackets)
|
||||
frameCount++
|
||||
}
|
||||
|
||||
if (frameCount % 30 === 0) {
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `Transcoded ${frameCount} frames...`,
|
||||
} as WorkerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush Encoder
|
||||
const flushedPackets = await libav.ff_encode_multi(
|
||||
enc_c,
|
||||
enc_frame,
|
||||
enc_pkt,
|
||||
[],
|
||||
true
|
||||
)
|
||||
if (flushedPackets.length > 0) {
|
||||
for (const p of flushedPackets) p.stream_index = 0
|
||||
await libav.ff_write_multi(oc, enc_pkt, flushedPackets)
|
||||
}
|
||||
|
||||
// Write Trailer
|
||||
await libav.av_write_trailer(oc)
|
||||
|
||||
// Cleanup
|
||||
await libav.ff_free_muxer(oc, pb)
|
||||
await libav.ff_free_encoder(enc_c, enc_frame, enc_pkt)
|
||||
await libav.ff_free_decoder(c, pkt, frame)
|
||||
await libav.avformat_close_input_js(fmt_ctx)
|
||||
await libav.unlink(inputFile)
|
||||
|
||||
ctx.postMessage({
|
||||
type: "DONE",
|
||||
data: `Transcoding complete. ${frameCount} frames ready.`,
|
||||
} as WorkerResponse)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractFrame(time: number) {
|
||||
if (!libav) return
|
||||
|
||||
try {
|
||||
// 1. Open Proxy File
|
||||
// Note: For efficiency in a real app, keep this open.
|
||||
// But for safety here, we open/close per request or keep it open in global?
|
||||
// Let's open it fresh. AVI parsing is fast.
|
||||
const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(PROXY_FILE)
|
||||
|
||||
const streamIdx = 0 // We know we only have 1 video stream
|
||||
const stream = streams[streamIdx]
|
||||
console.log(stream)
|
||||
|
||||
// 2. Init Decoder (MJPEG)
|
||||
const [, c, pkt, frame] = await libav.ff_init_decoder("mjpeg")
|
||||
|
||||
// 3. Seek
|
||||
// Calculate timestamp in AV_TIME_BASE or stream time base
|
||||
// av_seek_frame(ctx, stream_index, timestamp, flags)
|
||||
// If stream_index is -1, timestamp is in AV_TIME_BASE (microseconds)
|
||||
// We'll use stream index 0. We need to know its timebase.
|
||||
// But easier: Use -1 and AV_TIME_BASE
|
||||
const targetTS = Math.floor(time * 1000000) // seconds -> microseconds
|
||||
|
||||
// seek flags: 1 (BACKWARD) is usually good to find keyframe before time
|
||||
await libav.av_seek_frame(fmt_ctx, -1, targetTS, 1)
|
||||
|
||||
// 4. Read & Decode until we hit the frame
|
||||
// Since MJPEG is all-intra, the first frame we read after seek should be the one (or close)
|
||||
let foundFrame = null
|
||||
|
||||
// Limit attempts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const [res, packets] = await libav.ff_read_frame_multi(fmt_ctx, pkt, {
|
||||
limit: 1,
|
||||
})
|
||||
if (res === libav.AVERROR_EOF) break
|
||||
|
||||
const streamPackets = packets[streamIdx]
|
||||
if (!streamPackets || streamPackets.length === 0) continue
|
||||
|
||||
// Decode
|
||||
const frames = await libav.ff_decode_multi(c, pkt, frame, streamPackets)
|
||||
if (frames && frames.length > 0) {
|
||||
foundFrame = frames[0]
|
||||
break // Just take the first one found after seek
|
||||
}
|
||||
}
|
||||
|
||||
if (foundFrame) {
|
||||
// 5. Convert to Blob (BMP is simplest uncompressed format libav supports encoding to?)
|
||||
// Or use libav to encode to JPEG?
|
||||
// Actually, since we are in MJPEG, the packet *is* a JPEG.
|
||||
// But we just decoded it.
|
||||
// Let's re-encode the single frame to JPEG for the UI.
|
||||
// Or just return the raw packet?
|
||||
// Let's re-encode to be safe (ensure headers etc).
|
||||
|
||||
// Init single-frame encoder
|
||||
// const [, jpeg_c, jpeg_frame, jpeg_pkt] = await libav.ff_init_encoder("mjpeg", {
|
||||
// ctx: { width: foundFrame.width, height: foundFrame.height, ... }
|
||||
// })
|
||||
// This is heavy.
|
||||
|
||||
// Alternative: "copyoutFrame" as "ImageData" (only in main thread).
|
||||
// In Worker, we can use "video_packed" to get RGB/RGBA buffer.
|
||||
// libav.js Frame object has data.
|
||||
// Let's stick to the prompt: "extract single images".
|
||||
// I'll return a Blob of a JPEG.
|
||||
// Since I have a valid frame, I can use a simple JS JPEG encoder or libav.
|
||||
// Using libav is robust.
|
||||
|
||||
// Let's try to grab the PACKET directly from the read step?
|
||||
// If the packet is a full JPEG, we can just dump it.
|
||||
// MJPEG stream packets usually miss Huffman tables (DHT). Browsers often fail to render them directly.
|
||||
// So safe bet: Decode -> Encode to JPEG (single frame).
|
||||
|
||||
// Reuse the decoder context? No, that's for decoding.
|
||||
// Create a throwaway encoder for the snapshot.
|
||||
const width = await libav.AVCodecContext_width(c)
|
||||
const height = await libav.AVCodecContext_height(c)
|
||||
|
||||
// We need to know the pixel format of the decoded frame.
|
||||
// usually YUVJ420P.
|
||||
|
||||
const [, enc_c, enc_frame, enc_pkt] = await libav.ff_init_encoder(
|
||||
"mjpeg",
|
||||
{
|
||||
ctx: {
|
||||
width,
|
||||
height,
|
||||
pix_fmt: 0, // YUV420P
|
||||
time_base: [1, 1],
|
||||
framerate: [1, 1],
|
||||
},
|
||||
options: {
|
||||
// Force standard JPEG tables
|
||||
qscale: "2",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const encodedPackets = await libav.ff_encode_multi(
|
||||
enc_c,
|
||||
enc_frame,
|
||||
enc_pkt,
|
||||
[foundFrame]
|
||||
)
|
||||
|
||||
// Combine packets if multiple (usually 1 for JPEG)
|
||||
// But actually, ff_encode_multi returns packets.
|
||||
// We can concat their data.
|
||||
|
||||
const chunks = []
|
||||
for (const p of encodedPackets) {
|
||||
// p.data is the pointer. We need to copy it out.
|
||||
// Wait, ff_encode_multi returns objects with .data usually if copyoutPacket is default?
|
||||
// No, it returns Packet objects which refer to memory.
|
||||
// We need to copy data out.
|
||||
const data = await libav.ff_copyout_packet(p.ptr) // or just p.data if it's already copied?
|
||||
// libav.js docs: "Most functions will instead copy out their data into libav.js Frame or Packet objects."
|
||||
// So `encodedPackets` are JS objects with `data` (Uint8Array).
|
||||
console.log(data)
|
||||
chunks.push(p.data)
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks, { type: "image/jpeg" })
|
||||
|
||||
ctx.postMessage({
|
||||
type: "FRAME",
|
||||
data: blob,
|
||||
time: time,
|
||||
} as WorkerResponse)
|
||||
|
||||
await libav.ff_free_encoder(enc_c, enc_frame, enc_pkt)
|
||||
} else {
|
||||
console.warn("Frame not found after seek")
|
||||
}
|
||||
|
||||
// Cleanup extraction
|
||||
await libav.ff_free_decoder(c, pkt, frame)
|
||||
await libav.avformat_close_input_js(fmt_ctx)
|
||||
} catch (e) {
|
||||
console.error("Extraction error", e)
|
||||
}
|
||||
}
|
||||
124
app/component/label/video/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react"
|
||||
import type { WorkerMessage, WorkerResponse } from "./types/libav"
|
||||
|
||||
export default function Home() {
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const addLog = (msg: string) => {
|
||||
setLogs((prev) => [...prev.slice(-10), msg]) // 只保留最近10条
|
||||
}
|
||||
useEffect(() => {
|
||||
// 初始化 Worker
|
||||
// Next.js 会自动检测到这个语法并单独打包 Worker
|
||||
workerRef.current = new Worker(
|
||||
new URL("./libav.worker.ts", import.meta.url)
|
||||
)
|
||||
|
||||
// 设置消息监听
|
||||
workerRef.current.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const { type } = event.data
|
||||
|
||||
switch (type) {
|
||||
case "READY":
|
||||
setReady(true)
|
||||
addLog("FFmpeg core loaded and ready.")
|
||||
break
|
||||
case "LOG":
|
||||
if ("message" in event.data) addLog(event.data.message)
|
||||
break
|
||||
case "DONE":
|
||||
if ("data" in event.data) {
|
||||
const url = URL.createObjectURL(event.data.data)
|
||||
setVideoUrl(url)
|
||||
setProcessing(false)
|
||||
addLog("Transcoding complete!")
|
||||
}
|
||||
break
|
||||
case "ERROR":
|
||||
if ("error" in event.data) {
|
||||
addLog(`Error: ${event.data.error}`)
|
||||
setProcessing(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 告诉 Worker 加载 libav
|
||||
// 这里的路径必须指向 public 目录下的实际文件
|
||||
// 使用 default-cli 变体以确保包含 MP4/H.264 等常见格式的解封装器和解码器
|
||||
const LIBAV_PATH = "/libav/libav-6.8.8.0-default.js"
|
||||
workerRef.current.postMessage({
|
||||
type: "LOAD",
|
||||
config: { corePath: location.origin + LIBAV_PATH },
|
||||
} as WorkerMessage)
|
||||
|
||||
return () => {
|
||||
workerRef.current?.terminate()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !workerRef.current || !ready) return
|
||||
|
||||
setProcessing(true)
|
||||
setVideoUrl(null)
|
||||
setLogs([])
|
||||
addLog(`Starting upload: ${file.name}`)
|
||||
|
||||
workerRef.current.postMessage({
|
||||
type: "TRANSCODE",
|
||||
file: file,
|
||||
} as WorkerMessage)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-8 font-mono">
|
||||
<h1 className="text-2xl font-bold mb-4">Next.js + Libav.js + Worker</h1>
|
||||
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<p>
|
||||
Status:{" "}
|
||||
{ready ? (
|
||||
<span className="text-green-600">Ready</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Loading Core...</span>
|
||||
)}
|
||||
</p>
|
||||
<p>Processing: {processing ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
disabled={!ready || processing}
|
||||
className="mb-4 block w-full text-sm text-slate-500
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-full file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-violet-50 file:text-violet-700
|
||||
hover:file:bg-violet-100"
|
||||
/>
|
||||
|
||||
{videoUrl && (
|
||||
<div className="mb-4">
|
||||
<h2 className="font-bold">Result:</h2>
|
||||
<video controls src={videoUrl} className="w-full max-w-md border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-black text-white p-4 rounded text-xs h-64 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="border-b border-gray-800 py-1">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
74
app/component/label/video/types/libav.d.ts
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/types/libav.d.ts
|
||||
|
||||
export type WorkerMessage =
|
||||
| { type: "LOAD"; config: { corePath: string } }
|
||||
| { type: "TRANSCODE"; file: File }
|
||||
| { type: "EXTRACT_FRAME"; time: number }
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: "READY" }
|
||||
| { type: "LOG"; message: string }
|
||||
| { type: "DONE"; data: any } // data 类型根据实际返回调整
|
||||
| { type: "FRAME"; data: Blob; time: number }
|
||||
| { type: "ERROR"; error: string }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LibAV: LibAVWrapper
|
||||
}
|
||||
}
|
||||
|
||||
export interface LibAVOpts {
|
||||
noworker?: boolean
|
||||
base?: string
|
||||
wasmurl?: string
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export interface LibAVWrapper {
|
||||
LibAV(opts?: LibAVOpts): Promise<LibAVInstance>
|
||||
}
|
||||
|
||||
// 核心实例接口:添加官方例子中用到的底层方法
|
||||
export interface LibAVInstance {
|
||||
writeFile(name: string, content: Uint8Array): Promise<void>
|
||||
unlink(name: string): Promise<void>
|
||||
terminate(): void
|
||||
|
||||
// 1. 初始化解封装器 (Demuxer)
|
||||
// 返回 [Context, Streams[]]
|
||||
ff_init_demuxer_file(filename: string): Promise<[number, any[]]>
|
||||
|
||||
// 2. 初始化解码器 (Decoder)
|
||||
// 返回 [Codec, CodecContext, Packet, Frame]
|
||||
ff_init_decoder(
|
||||
codec_id: number,
|
||||
codecpar: number
|
||||
): Promise<[number, number, number, number]>
|
||||
|
||||
// 3. 读取多个包
|
||||
// 返回 [ResultCode, PacketsMap]
|
||||
ff_read_frame_multi(
|
||||
fmt_ctx: number,
|
||||
pkt: number,
|
||||
opts?: {
|
||||
limit?: number // OUTPUT limit, in bytes
|
||||
unify?: boolean // If true, unify the packets into a single stream (called 0), so that the output is in the same order as the input
|
||||
copyoutPacket?: "default" // Version of ff_copyout_packet to use
|
||||
}
|
||||
): Promise<[number, Record<number, Packet[]>]>
|
||||
|
||||
// 4. 解码多个包
|
||||
// 返回 DecodedFrames[]
|
||||
ff_decode_multi(
|
||||
ctx: number,
|
||||
pkt: number,
|
||||
frame: number,
|
||||
packets: any[],
|
||||
fin?: boolean
|
||||
): Promise<any[]>
|
||||
|
||||
// 辅助:释放资源
|
||||
avformat_close_input_js(fmt_ctx: number): Promise<void>
|
||||
ff_free_decoder(c: number, pkt: number, frame: number): Promise<void>
|
||||
}
|
||||
21
app/component/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
import { PathnameRecorder } from "@/components/layout/PathnameRecorder"
|
||||
import { componentList, showList } from "@/components/layout/common"
|
||||
|
||||
async function getMenu() {
|
||||
return [...componentList, ...showList]
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menu = await getMenu()
|
||||
return (
|
||||
<AppLayout menu={menu}>
|
||||
{children}
|
||||
<PathnameRecorder menu={menu} />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
5
app/component/media/dynamic/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentMediaDynamicPage() {
|
||||
return <>ComponentMediaDynamicPage</>
|
||||
}
|
||||
5
app/component/media/static/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentMediaStaticPage() {
|
||||
return <>ComponentMediaStaticPage</>
|
||||
}
|
||||
12
app/component/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function ComponentPage() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.push("/component/base/department")
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
5
app/component/table/base/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentTableBasePage() {
|
||||
return <>ComponentTableBasePage</>
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
27
app/globals.css
Normal file
@@ -0,0 +1,27 @@
|
||||
html,
|
||||
body {
|
||||
/* max-width: 100vw; */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标准写法 */
|
||||
:fullscreen::backdrop {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
/* Safari / WebKit */
|
||||
:-webkit-full-screen::backdrop {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
36
app/label/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import LabelContent from "@/components/label"
|
||||
import { Paper } from "@mantine/core"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export default function LabelPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const projectId = useMemo(() => {
|
||||
const value = Number(searchParams.get("project_id"))
|
||||
return Number.isFinite(value) ? value : -1
|
||||
}, [searchParams])
|
||||
const taskId = useMemo(() => {
|
||||
const value = Number(searchParams.get("task_id"))
|
||||
return Number.isFinite(value) ? value : -1
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p={0}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
borderRadius: 0,
|
||||
}}>
|
||||
<LabelContent
|
||||
headerHeight={0}
|
||||
leftWidth={0}
|
||||
project_id={projectId}
|
||||
task_id={taskId}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
66
app/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import "@mantine/core/styles.css"
|
||||
import "@mantine/core/styles.layer.css"
|
||||
import "@mantine/notifications/styles.css"
|
||||
import "mantine-datatable/styles.layer.css"
|
||||
import Script from "next/script"
|
||||
|
||||
import { InfoCheck } from "@/app/store/InfoCheck"
|
||||
import {
|
||||
ColorSchemeScript,
|
||||
MantineProvider,
|
||||
// createTheme,
|
||||
mantineHtmlProps,
|
||||
} from "@mantine/core"
|
||||
import { ModalsProvider } from "@mantine/modals"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
import { theme } from "./theme"
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
// const theme = createTheme({
|
||||
// colors: {
|
||||
// blue: [
|
||||
// "#e3f2fd", // 0
|
||||
// "#bbdefb", // 1
|
||||
// "#90caf9", // 2
|
||||
// "#e8f0ff", // 3 ← dark默认主色
|
||||
// "#42a5f5", // 4
|
||||
// "#2196f3", // 5
|
||||
// "#145bff", // 6 ← 默认主色
|
||||
// "#1976d2", // 7
|
||||
// "#1565c0", // 8
|
||||
// "#0d47a1", // 9
|
||||
// ],
|
||||
// },
|
||||
// })
|
||||
return (
|
||||
<html lang="en" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme="auto" />
|
||||
<Script
|
||||
src="https://lf-scm-cn.feishucdn.com/lark/op/h5-js-sdk-1.5.44.js"
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => {
|
||||
console.log("飞书 SDK 加载完成", window.h5sdk)
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider defaultColorScheme="auto" theme={theme}>
|
||||
<Notifications />
|
||||
<InfoCheck />
|
||||
<ModalsProvider>
|
||||
<Suspense fallback={<></>}>{children}</Suspense>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
7
app/login/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
import { useUserStore } from "../store/user"
|
||||
import LoginComponent from "@/components/login"
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginComponent useUserStore={useUserStore} />
|
||||
}
|
||||
21
app/management/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
import { PathnameRecorder } from "@/components/layout/PathnameRecorder"
|
||||
import { componentList, showList } from "@/components/layout/common"
|
||||
|
||||
async function getMenu() {
|
||||
return [...componentList, ...showList]
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menu = await getMenu()
|
||||
return (
|
||||
<AppLayout menu={menu}>
|
||||
{children}
|
||||
<PathnameRecorder menu={menu} />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
12
app/management/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function ComponentPage() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.push("/management/person/dashboard")
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
282
app/management/person/dashboard/components/Icon.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
export const DataIcon = () => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0.1 4C0.1 1.84609 1.84609 0.1 4 0.1H28C30.1539 0.1 31.9 1.84609 31.9 4V28C31.9 30.1539 30.1539 31.9 28 31.9H4C1.84609 31.9 0.1 30.1539 0.1 28V4Z"
|
||||
fill="url(#paint0_linear_726_9281)"
|
||||
stroke="url(#paint1_linear_726_9281)"
|
||||
strokeWidth="0.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.67848 12.5511L9.89873 11.2781L10.6614 12.0738L9.44114 13.3468L10.2038 14.1424L11.424 12.7898L12.1867 13.5855L10.9665 14.8585L11.1953 15.495L15.2373 11.3577L12.4918 8.25471C12.0342 7.8569 11.1953 7.93646 10.8139 8.41384L8.22088 10.9599C7.83955 11.3577 7.99209 11.9146 8.37341 12.3124L8.67848 12.5511ZM8.37341 23.0535C8.29715 23.3717 8.52595 23.6104 8.83101 23.5309L13.4832 22.0192L9.89873 18.3593L8.37341 23.0535ZM23.5503 11.9942C23.9316 11.5964 23.9316 11.0394 23.5503 10.6416L21.3386 8.41384C20.9573 8.01602 20.3472 8.01602 19.9658 8.41384L18.3642 10.0051L21.9487 13.5855L23.5503 11.9942ZM17.8304 10.5621L10.4326 17.8819L14.0171 21.4622L21.4149 14.0628L17.8304 10.5621ZM23.7791 19.3936L20.4234 16.1315L16.3051 20.1892L17.0677 20.9848L18.3642 19.7118L19.1269 20.5075L17.8304 21.7805L18.593 22.5761L19.8896 21.3031L20.6522 22.0987L19.3557 23.3717L19.5845 23.6104C19.9658 24.0083 20.5759 24.1674 21.0335 23.7696L23.6266 21.2235C24.0079 20.6666 24.1604 19.7914 23.7791 19.3936Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_726_9281"
|
||||
x1="-4"
|
||||
y1="-9.5"
|
||||
x2="29"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" stopOpacity="0.26" />
|
||||
<stop offset="1" stopColor="#FFCBCE" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_726_9281"
|
||||
x1="1.67638e-07"
|
||||
y1="32"
|
||||
x2="32"
|
||||
y2="1.67638e-07"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#FBACB3" />
|
||||
<stop offset="1" stopColor="white" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const HourIcon = () => (
|
||||
<svg
|
||||
width="38"
|
||||
height="37"
|
||||
viewBox="0 0 38 37"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dd_726_9271)">
|
||||
<rect
|
||||
x="2"
|
||||
y="1"
|
||||
width="32"
|
||||
height="32"
|
||||
rx="3.84"
|
||||
fill="url(#paint0_linear_726_9271)"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="2"
|
||||
y="1"
|
||||
width="32"
|
||||
height="32"
|
||||
rx="3.84"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M15.1422 18.626L16.6317 17.5382L20.6829 11.3447L21.6931 9.79101C21.7472 9.70607 21.774 9.60659 21.7699 9.50597C21.7656 9.42473 21.7414 9.34579 21.6995 9.27612C21.6575 9.20644 21.599 9.14817 21.5291 9.10646L20.6829 8.61169C20.5676 8.54209 20.43 8.51976 20.2987 8.54935C20.1674 8.57894 20.0526 8.65817 19.9784 8.77046L19.2562 9.87151L18.9667 10.3242L15.0322 16.7614L15.1429 18.626H15.1422ZM23.0216 9.77994H25.2333C25.7243 9.77994 26.1231 10.2193 26.1231 10.6956V21.9084C26.1231 22.3847 25.7243 22.7709 25.2333 22.7709H10.7668C10.2757 22.7709 9.87695 22.3847 9.87695 21.9084V10.6956C9.87695 10.2193 10.1362 9.77994 10.6272 9.77994H17.8893L13.8034 16.4165L14.0692 20.4736L17.8782 17.903L23.0216 9.77994ZM18.6115 19.4685C18.4533 19.4685 18.3017 19.5314 18.1898 19.6432C18.078 19.755 18.0152 19.9067 18.0152 20.0649C18.0152 20.223 18.078 20.3747 18.1898 20.4865C18.3017 20.5983 18.4533 20.6612 18.6115 20.6612H24.4896C24.6478 20.6612 24.7995 20.5983 24.9113 20.4865C25.0231 20.3747 25.0859 20.223 25.0859 20.0649C25.0859 19.9067 25.0231 19.755 24.9113 19.6432C24.7995 19.5314 24.6478 19.4685 24.4896 19.4685H18.6115ZM25.1963 24.0854H10.8052C10.3606 24.0854 10.0003 24.3941 10.0003 24.7744C10.0003 25.1547 10.3606 25.4634 10.8052 25.4634H25.1963C25.6416 25.4634 26.0013 25.1547 26.0013 24.7744C25.999 24.397 25.6394 24.0854 25.1963 24.0854Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_dd_726_9271"
|
||||
x="0.56"
|
||||
y="0.0399997"
|
||||
width="37.28"
|
||||
height="36.8"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology
|
||||
radius="0.48"
|
||||
operator="erode"
|
||||
in="SourceAlpha"
|
||||
result="effect1_dropShadow_726_9271"
|
||||
/>
|
||||
<feOffset dx="1.44" dy="1.44" />
|
||||
<feGaussianBlur stdDeviation="1.44" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.0627451 0 0 0 0 0.760784 0 0 0 0 0.423529 0 0 0 0.15 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_726_9271"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology
|
||||
radius="0.48"
|
||||
operator="erode"
|
||||
in="SourceAlpha"
|
||||
result="effect2_dropShadow_726_9271"
|
||||
/>
|
||||
<feOffset dx="-0.48" />
|
||||
<feGaussianBlur stdDeviation="0.72" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.0627451 0 0 0 0 0.760784 0 0 0 0 0.423529 0 0 0 0.33 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_dropShadow_726_9271"
|
||||
result="effect2_dropShadow_726_9271"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect2_dropShadow_726_9271"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_726_9271"
|
||||
x1="-13"
|
||||
y1="-27"
|
||||
x2="27"
|
||||
y2="31.5"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#E7F9F0" />
|
||||
<stop offset="1" stopColor="#4FD796" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ReviewIcon = () => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="3.84" fill="url(#paint0_linear_0_1)" />
|
||||
<g filter="url(#filter0_ii_0_1)">
|
||||
<rect width="32" height="32" rx="3.84" fill="white" fillOpacity="0.1" />
|
||||
</g>
|
||||
<g filter="url(#filter1_ii_0_1)">
|
||||
<rect width="32" height="32" rx="3.84" fill="white" fillOpacity="0.1" />
|
||||
</g>
|
||||
<path
|
||||
d="M23.227 18.008H20.5869C20.1405 18.008 19.7365 17.7447 19.5553 17.3347L18.4511 14.8444C18.2574 14.4063 18.3641 13.9006 18.7028 13.5623C19.4724 12.7923 19.9188 11.6954 19.8142 10.4966C19.6526 8.6318 18.1352 7.13848 16.2771 7.00945C14.0429 6.85439 12.1827 8.62868 12.1827 10.8411C12.1827 11.9265 12.6312 12.9057 13.351 13.604C13.6794 13.9224 13.7912 14.4032 13.6286 14.8309L12.6975 17.2785C12.6157 17.4931 12.471 17.6778 12.2826 17.808C12.0941 17.9383 11.8708 18.008 11.642 18.008H8.77399C8.06967 18.008 7.5 18.5813 7.5 19.2879V21.3859C7.5 22.0935 8.07071 22.6658 8.77399 22.6658H23.226C23.9303 22.6658 24.5 22.0925 24.5 21.3859V19.2879C24.501 18.5813 23.9303 18.008 23.227 18.008ZM23.139 25H8.86203C8.54819 25 8.2934 24.744 8.2934 24.4287C8.2934 24.1134 8.54819 23.8574 8.86203 23.8574H23.14C23.4539 23.8574 23.7087 24.1134 23.7087 24.4287C23.7086 24.5038 23.6938 24.5781 23.6652 24.6475C23.6365 24.7168 23.5945 24.7798 23.5416 24.8329C23.4887 24.8859 23.4259 24.928 23.3568 24.9567C23.2878 24.9853 23.2137 25.0001 23.139 25Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_ii_0_1"
|
||||
x="-0.48"
|
||||
y="-0.48"
|
||||
width="32.96"
|
||||
height="32.96"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dx="0.48" dy="-0.48" />
|
||||
<feGaussianBlur stdDeviation="0.48" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.760784 0 0 0 0 0.470588 0 0 0 0 0.0627451 0 0 0 0.15 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dx="-0.48" dy="0.48" />
|
||||
<feGaussianBlur stdDeviation="0.74" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_innerShadow_0_1"
|
||||
result="effect2_innerShadow_0_1"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_ii_0_1"
|
||||
x="-0.48"
|
||||
y="-0.48"
|
||||
width="32.96"
|
||||
height="32.96"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dx="0.48" dy="-0.48" />
|
||||
<feGaussianBlur stdDeviation="0.48" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.760784 0 0 0 0 0.470588 0 0 0 0 0.0627451 0 0 0 0.15 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dx="-0.48" dy="0.48" />
|
||||
<feGaussianBlur stdDeviation="0.74" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_innerShadow_0_1"
|
||||
result="effect2_innerShadow_0_1"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_0_1"
|
||||
x1="-15"
|
||||
y1="-28"
|
||||
x2="25"
|
||||
y2="30.5"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#FEF5E7" />
|
||||
<stop offset="1" stopColor="#FFB84D" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client"
|
||||
|
||||
import { getPersonProjectDashBoard } from "@/components/label/api/project"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Table,
|
||||
} from "@mantine/core"
|
||||
import { IconRefresh } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
const pageSizeOptions = [50, 100, 200]
|
||||
|
||||
function formatDurationSeconds(seconds?: number | null) {
|
||||
const value = seconds ?? 0
|
||||
if (!value) return "-"
|
||||
const durationObj = dayjs.duration(value, "seconds")
|
||||
const hours = durationObj.hours()
|
||||
const minutes = durationObj.minutes()
|
||||
if (hours) return `${hours}小时${minutes}分钟`
|
||||
if (minutes) return `${minutes}分钟`
|
||||
return "-"
|
||||
}
|
||||
|
||||
export default function PersonalProjectTable() {
|
||||
const [records, setRecords] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const totalItems = records.length
|
||||
return Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
}, [pageSize, records.length])
|
||||
|
||||
const pageRecords = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return records.slice(start, start + pageSize)
|
||||
}, [page, pageSize, records])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getPersonProjectDashBoard()
|
||||
setRecords(res ?? [])
|
||||
setPage(1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
h="100%"
|
||||
align="stretch"
|
||||
style={{ flexDirection: "column" }}>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
leftSection={<IconRefresh size={16} />}>
|
||||
更新
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap={0} align="stretch" style={{ flex: 1, minHeight: 0 }}>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Table withTableBorder withColumnBorders striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 96 }}>项目ID</Table.Th>
|
||||
<Table.Th style={{ width: 160 }}>项目名称</Table.Th>
|
||||
<Table.Th style={{ width: 160 }}>所属业务</Table.Th>
|
||||
<Table.Th style={{ width: 140 }}>任务耗时</Table.Th>
|
||||
<Table.Th style={{ width: 160 }}>标注类别</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{pageRecords.map((record) => (
|
||||
<Table.Tr key={record.project_id}>
|
||||
<Table.Td>{record.project_id ?? "-"}</Table.Td>
|
||||
<Table.Td>{record.project_name ?? "-"}</Table.Td>
|
||||
<Table.Td>{record.project_type ?? "-"}</Table.Td>
|
||||
<Table.Td>{formatDurationSeconds(record.work_time)}</Table.Td>
|
||||
<Table.Td>{record.project_label_type ?? "-"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
w={120}
|
||||
value={String(pageSize)}
|
||||
data={pageSizeOptions.map((v) => String(v))}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => {
|
||||
const next = Number(value)
|
||||
if (!Number.isFinite(next)) return
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Pagination value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
181
app/management/person/dashboard/components/PersonalTaskTable.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { getUserTaskList } from "@/components/label/api/task"
|
||||
import {
|
||||
useBackUrlStore,
|
||||
usePermissionStore,
|
||||
} from "@/components/label/store/auth"
|
||||
import { TaskStatusEnum } from "@/components/label/utils/constants"
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { IconRefresh } from "@tabler/icons-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
const pageSizeOptions = [50, 100, 200]
|
||||
|
||||
function getStatusColor(status?: number | null) {
|
||||
switch (status) {
|
||||
case 2:
|
||||
return "blue"
|
||||
case 4:
|
||||
return "yellow"
|
||||
case 6:
|
||||
return "orange"
|
||||
case 7:
|
||||
return "green"
|
||||
case 8:
|
||||
return "red"
|
||||
default:
|
||||
return "gray"
|
||||
}
|
||||
}
|
||||
|
||||
export default function PersonalTaskTable() {
|
||||
const router = useRouter()
|
||||
const user_name = usePermissionStore((s) => s.user_name)
|
||||
const user_password = usePermissionStore((s) => s.user_password)
|
||||
const setBackProps = useBackUrlStore((s) => s.setBackProps)
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [records, setRecords] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
return Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
}, [pageSize, totalItems])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!user_name) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getUserTaskList({
|
||||
page_number: page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
setRecords(res?.simple_task_list ?? [])
|
||||
setTotalItems(res?.total_items ?? 0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, user_name])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
const resetData = () => {
|
||||
if (page === 1) load()
|
||||
else setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
h="100%"
|
||||
align="stretch"
|
||||
style={{ flexDirection: "column" }}>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={resetData}
|
||||
loading={loading}
|
||||
leftSection={<IconRefresh size={16} />}>
|
||||
更新
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap={0} align="stretch" style={{ flex: 1, minHeight: 0 }}>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Table withTableBorder withColumnBorders striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 260 }}>项目名称</Table.Th>
|
||||
<Table.Th style={{ width: 110 }}>任务ID</Table.Th>
|
||||
<Table.Th style={{ width: 160 }}>状态</Table.Th>
|
||||
<Table.Th style={{ width: 90 }}>标注员</Table.Th>
|
||||
<Table.Th style={{ width: 90 }}>审核员</Table.Th>
|
||||
<Table.Th style={{ width: 90 }}>复审员</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text lineClamp={1}>{record.project_name ?? "-"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
const labelType = record.label_type
|
||||
if ([4, 5, 6].includes(labelType)) {
|
||||
setBackProps("/management/person/dashboard")
|
||||
router.push(
|
||||
`/label?project_id=${record.project_id}&task_id=${record.id}`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (user_name && user_password) {
|
||||
setBackProps("/management/person/dashboard")
|
||||
router.push(
|
||||
`/label?project_id=${record.project_id}&task_id=${record.id}`
|
||||
)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer" }}>
|
||||
{record.id ?? "-"}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getStatusColor(record.label_status)}
|
||||
variant="light">
|
||||
{`${TaskStatusEnum.get(record.label_status) ?? "-"}${
|
||||
record.rejected ? "返工" : ""
|
||||
}`}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.label_username ?? "-"}</Table.Td>
|
||||
<Table.Td>{record.review1_username ?? "-"}</Table.Td>
|
||||
<Table.Td>{record.review2_username ?? "-"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
w={120}
|
||||
value={String(pageSize)}
|
||||
data={pageSizeOptions.map((v) => String(v))}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => {
|
||||
const next = Number(value)
|
||||
if (!Number.isFinite(next)) return
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Pagination value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
287
app/management/person/dashboard/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client"
|
||||
|
||||
import { getCurrentPersonData } from "@/components/label/api/project"
|
||||
import {
|
||||
BackgroundImage,
|
||||
Center,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import { JSX, useEffect, useMemo, useState } from "react"
|
||||
import { DataIcon, HourIcon, ReviewIcon } from "./components/Icon"
|
||||
import PersonalProjectTable from "./components/PersonalProjectTable"
|
||||
import PersonalTaskTable from "./components/PersonalTaskTable"
|
||||
import DataImage from "./resource/images/data.png"
|
||||
import DataLogoImage from "./resource/images/data_logo.png"
|
||||
import HourImage from "./resource/images/hour.png"
|
||||
import HourLogoImage from "./resource/images/hour_logo.png"
|
||||
import ReviewImage from "./resource/images/review.png"
|
||||
import ReviewLogoImage from "./resource/images/review_logo.png"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
function monthText(month: string) {
|
||||
const list = [
|
||||
"一月",
|
||||
"二月",
|
||||
"三月",
|
||||
"四月",
|
||||
"五月",
|
||||
"六月",
|
||||
"七月",
|
||||
"八月",
|
||||
"九月",
|
||||
"十月",
|
||||
"十一月",
|
||||
"十二月",
|
||||
]
|
||||
const idx = Number(month) - 1
|
||||
return list[idx] ?? ""
|
||||
}
|
||||
|
||||
function formatDurationSeconds(seconds?: number | null) {
|
||||
const value = seconds ?? 0
|
||||
if (!value) return "-"
|
||||
const durationObj = dayjs.duration(value, "seconds")
|
||||
const hours = durationObj.hours()
|
||||
const minutes = durationObj.minutes()
|
||||
if (hours) return `${hours}小时${minutes}分钟`
|
||||
if (minutes) return `${minutes}分钟`
|
||||
return "-"
|
||||
}
|
||||
|
||||
function StatCard(props: {
|
||||
title: string
|
||||
timeText: string
|
||||
sizeTitle: string
|
||||
sizeValue: number
|
||||
sizeColor: string
|
||||
background: string
|
||||
backgroundLogo: string
|
||||
Icon: () => JSX.Element
|
||||
}) {
|
||||
const {
|
||||
title,
|
||||
timeText,
|
||||
sizeTitle,
|
||||
sizeValue,
|
||||
sizeColor,
|
||||
background,
|
||||
backgroundLogo,
|
||||
Icon,
|
||||
} = props
|
||||
return (
|
||||
<Paper
|
||||
shadow="xs"
|
||||
p="md"
|
||||
withBorder
|
||||
radius={"md"}
|
||||
pos="relative"
|
||||
style={{
|
||||
borderColor: "rgba(0,0,0,0.06)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
}}>
|
||||
<BackgroundImage
|
||||
src={background}
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{ position: "absolute", top: 0, left: 0, zIndex: 0 }}
|
||||
/>
|
||||
<BackgroundImage
|
||||
src={backgroundLogo}
|
||||
w={108}
|
||||
h={108}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 8,
|
||||
zIndex: 0,
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
<Group gap="md" align="flex-start" style={{ flex: 1, zIndex: 1 }}>
|
||||
<div style={{ width: 32 }}>
|
||||
<Icon />
|
||||
</div>
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<div>
|
||||
<Text fw={700} fz="lg" lh={1.4}>
|
||||
{title}
|
||||
</Text>
|
||||
<Title order={3} style={{ marginTop: 6 }}>
|
||||
{timeText}
|
||||
</Title>
|
||||
</div>
|
||||
<Stack gap={6}>
|
||||
<Text fw={700} fz="lg">
|
||||
{sizeTitle}
|
||||
</Text>
|
||||
<Paper
|
||||
shadow="xs"
|
||||
px={8}
|
||||
py={4}
|
||||
radius="sm"
|
||||
style={{
|
||||
background: "#fff",
|
||||
width: "fit-content",
|
||||
color: sizeColor,
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}>
|
||||
{sizeValue}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PersonDashboardPage() {
|
||||
const [data, setData] = useState<{
|
||||
label_data_size: number
|
||||
label_work_time: number
|
||||
review1_data_size: number
|
||||
review1_work_time: number
|
||||
review2_data_size: number
|
||||
review2_work_time: number
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const res = await getCurrentPersonData({
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
})
|
||||
setData(res)
|
||||
}
|
||||
run()
|
||||
}, [])
|
||||
|
||||
const dataText = useMemo(() => {
|
||||
return {
|
||||
label: formatDurationSeconds(data?.label_work_time ?? 0),
|
||||
review1: formatDurationSeconds(data?.review1_work_time ?? 0),
|
||||
review2: formatDurationSeconds(data?.review2_work_time ?? 0),
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Group align="stretch" gap="md" wrap="nowrap" h={180}>
|
||||
<Paper
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
width: 160,
|
||||
borderColor: "rgba(0,0,0,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}>
|
||||
<Text fz="lg" fw={700}>
|
||||
今日
|
||||
</Text>
|
||||
<Center>
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
background: "rgba(0, 161, 255, 0.08)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
}}>
|
||||
<Text fz={24} fw={700} lh={1}>
|
||||
{dayjs().format("DD")}
|
||||
</Text>
|
||||
<Text fz={16}>{monthText(dayjs().format("MM"))}</Text>
|
||||
</Paper>
|
||||
</Center>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" style={{ flex: 1 }}>
|
||||
<StatCard
|
||||
title="标注工时"
|
||||
timeText={dataText.label}
|
||||
sizeTitle="标注数据量"
|
||||
sizeValue={data?.label_data_size ?? 0}
|
||||
sizeColor="#E03131"
|
||||
background={DataImage.src}
|
||||
backgroundLogo={DataLogoImage.src}
|
||||
Icon={DataIcon}
|
||||
/>
|
||||
<StatCard
|
||||
title="审核工时"
|
||||
timeText={dataText.review1}
|
||||
sizeTitle="审核数据量"
|
||||
sizeValue={data?.review1_data_size ?? 0}
|
||||
sizeColor="#2F9E44"
|
||||
background={HourImage.src}
|
||||
backgroundLogo={HourLogoImage.src}
|
||||
Icon={HourIcon}
|
||||
/>
|
||||
<StatCard
|
||||
title="复审工时"
|
||||
timeText={dataText.review2}
|
||||
sizeTitle="复审数据量"
|
||||
sizeValue={data?.review2_data_size ?? 0}
|
||||
sizeColor="#F08C00"
|
||||
background={ReviewImage.src}
|
||||
backgroundLogo={ReviewLogoImage.src}
|
||||
Icon={ReviewIcon}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" style={{ flex: 1 }}>
|
||||
<Paper
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: "rgba(0,0,0,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<Text fw={700} fz="lg" style={{ marginBottom: 8 }}>
|
||||
我的任务
|
||||
</Text>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<PersonalTaskTable />
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: "rgba(0,0,0,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<Text fw={700} fz="lg" style={{ marginBottom: 8 }}>
|
||||
参与项目
|
||||
</Text>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<PersonalProjectTable />
|
||||
</div>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
BIN
app/management/person/dashboard/resource/images/data.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
app/management/person/dashboard/resource/images/data_logo.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
app/management/person/dashboard/resource/images/hour.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app/management/person/dashboard/resource/images/hour_logo.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
app/management/person/dashboard/resource/images/review.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app/management/person/dashboard/resource/images/review_logo.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
12
app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.push("/show")
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
21
app/show/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
import { PathnameRecorder } from "@/components/layout/PathnameRecorder"
|
||||
import { componentList, showList } from "@/components/layout/common"
|
||||
|
||||
async function getMenu() {
|
||||
return [...componentList, ...showList]
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menu = await getMenu()
|
||||
return (
|
||||
<AppLayout menu={menu}>
|
||||
{children}
|
||||
<PathnameRecorder menu={menu} />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
12
app/show/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function ComponentPage() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.push("/show/user")
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
5
app/show/user/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client"
|
||||
|
||||
export default function ShowUserPage() {
|
||||
return <>ShowUserPage</>
|
||||
}
|
||||
22
app/store/InfoCheck.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useUserStore } from "@/app/store/user"
|
||||
|
||||
export function InfoCheck() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const user_info = useUserStore.getState().user_info
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/login")) {
|
||||
return
|
||||
}
|
||||
if (!user_info.account) {
|
||||
// tauri 打包后, 使用router.push跳转路由不执行,会导致白屏
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}, [pathname, user_info.account])
|
||||
return <></>
|
||||
}
|
||||
85
app/store/user.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
interface RbacDataItem {
|
||||
app: string
|
||||
cat: string
|
||||
platform: string
|
||||
tenant: string
|
||||
permission: {
|
||||
[x: string]: {
|
||||
[y: string]: [boolean, any, string]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
tenant: string
|
||||
user_info: {
|
||||
[x: string]: string
|
||||
}
|
||||
rbac: {
|
||||
data: RbacDataItem[]
|
||||
}
|
||||
needResetPassword: boolean
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
setUserInfo: (
|
||||
tenant: string,
|
||||
user_info: {
|
||||
[x: string]: string
|
||||
},
|
||||
rbac: {
|
||||
data: RbacDataItem[]
|
||||
}
|
||||
) => void
|
||||
removeUserInfo: () => void
|
||||
refreshToken: (params: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}) => void
|
||||
setNeedResetPassword: (needResetPassword: boolean) => void
|
||||
}
|
||||
|
||||
const initialUserState = {
|
||||
tenant: "",
|
||||
user_info: {},
|
||||
rbac: {
|
||||
data: [],
|
||||
},
|
||||
needResetPassword: false,
|
||||
access_token: "",
|
||||
refresh_token: "",
|
||||
}
|
||||
|
||||
export const useUserStore = create(
|
||||
persist<UserState>(
|
||||
(set) => ({
|
||||
tenant: initialUserState.tenant,
|
||||
user_info: initialUserState.user_info,
|
||||
rbac: initialUserState.rbac,
|
||||
needResetPassword: initialUserState.needResetPassword,
|
||||
access_token: initialUserState.access_token,
|
||||
refresh_token: initialUserState.refresh_token,
|
||||
setUserInfo: (tenant, user_info, rbac) =>
|
||||
set((state) => {
|
||||
return { ...state, tenant: tenant, user_info: user_info, rbac: rbac }
|
||||
}),
|
||||
removeUserInfo: () =>
|
||||
set((state) => {
|
||||
return { ...state, ...initialUserState }
|
||||
}),
|
||||
setNeedResetPassword: (needResetPassword) =>
|
||||
set((state) => {
|
||||
return { ...state, needResetPassword: needResetPassword }
|
||||
}),
|
||||
refreshToken: ({ access_token, refresh_token }) =>
|
||||
set((state) => {
|
||||
return { ...state, access_token, refresh_token }
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "user-store",
|
||||
}
|
||||
)
|
||||
)
|
||||
155
app/theme.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// theme.ts
|
||||
import { Button, createTheme, MantineColorsTuple } from "@mantine/core"
|
||||
|
||||
/* -------- 1. 自定义颜色(可删/可改) -------- */
|
||||
const brandColors: MantineColorsTuple = [
|
||||
"#E8F4FC",
|
||||
"#CEECFD",
|
||||
"#A7DAFF",
|
||||
"#69C0FF",
|
||||
"#4ABAFF",
|
||||
"#09ADFF",
|
||||
"#00A1FF",
|
||||
"#168DE8",
|
||||
"#0D73D0",
|
||||
"#0256A4",
|
||||
]
|
||||
|
||||
const greyColors: MantineColorsTuple = [
|
||||
"#FFFFFF",
|
||||
"#F7F8F9",
|
||||
"#F1F2F4",
|
||||
"#DCDFE4",
|
||||
"#B5BBC2",
|
||||
"#8F979F",
|
||||
"#5D6872",
|
||||
"#47535F",
|
||||
"#1B2A38",
|
||||
"#0F151B",
|
||||
]
|
||||
|
||||
const darkColors: MantineColorsTuple = [
|
||||
"#FFFFFF",
|
||||
"#EBEEF7",
|
||||
"#D3D4D8",
|
||||
"#7E858F",
|
||||
"#3C4859",
|
||||
"#2B3648",
|
||||
"#212A39",
|
||||
"#1A222D",
|
||||
"#151C24",
|
||||
"#0D0E12",
|
||||
]
|
||||
|
||||
const successColors: MantineColorsTuple = [
|
||||
"#E6FCF5",
|
||||
"#C3FAE8",
|
||||
"#96F2D7",
|
||||
"#5BE7B6",
|
||||
"#38D9A9",
|
||||
"#20C997",
|
||||
"#0EA879",
|
||||
"#07815B",
|
||||
"#076B3F",
|
||||
"#043E22",
|
||||
]
|
||||
|
||||
/* -------- 2. 创建主题 -------- */
|
||||
export const theme = createTheme({
|
||||
/* ---- 颜色系统 ---- */
|
||||
primaryColor: "brand",
|
||||
cursorType: "pointer",
|
||||
// primaryShade: { light: 5, dark: 6 },
|
||||
colors: {
|
||||
brand: brandColors,
|
||||
success: successColors,
|
||||
grey: greyColors,
|
||||
dark: darkColors,
|
||||
},
|
||||
|
||||
/* ---- 圆角 ---- */
|
||||
defaultRadius: "md",
|
||||
radius: {
|
||||
xs: "2px",
|
||||
sm: "4px",
|
||||
md: "8px",
|
||||
lg: "12px",
|
||||
xl: "16px",
|
||||
xxl: "24px",
|
||||
},
|
||||
|
||||
/* ---- 字体排版 ---- */
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontFamilyMonospace:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSizes: {
|
||||
xs: "0.75rem",
|
||||
sm: "0.875rem",
|
||||
md: "1rem",
|
||||
lg: "1.125rem",
|
||||
xl: "1.25rem",
|
||||
xxl: "1.5rem",
|
||||
},
|
||||
headings: {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontWeight: "700",
|
||||
sizes: {
|
||||
h1: { fontSize: "2.25rem", lineHeight: "1.2" },
|
||||
h2: { fontSize: "1.875rem", lineHeight: "1.3" },
|
||||
h3: { fontSize: "1.5rem", lineHeight: "1.35" },
|
||||
h4: { fontSize: "1.25rem", lineHeight: "1.4" },
|
||||
h5: { fontSize: "1.125rem", lineHeight: "1.45" },
|
||||
h6: { fontSize: "1rem", lineHeight: "1.5" },
|
||||
},
|
||||
},
|
||||
|
||||
/* ---- 间距 ---- */
|
||||
spacing: {
|
||||
xs: "8px",
|
||||
sm: "12px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
xl: "32px",
|
||||
xxl: "48px",
|
||||
},
|
||||
|
||||
/* ---- 阴影 ---- */
|
||||
shadows: {
|
||||
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
sm: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
|
||||
md: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||
},
|
||||
|
||||
/* ---- 响应式断点 ---- */
|
||||
breakpoints: {
|
||||
xs: "36em", // 576px
|
||||
sm: "48em", // 768px
|
||||
md: "62em", // 992px
|
||||
lg: "75em", // 1200px
|
||||
xl: "88em", // 1408px
|
||||
},
|
||||
|
||||
/* ---- 默认渐变 ---- */
|
||||
defaultGradient: {
|
||||
from: "brand",
|
||||
to: "success",
|
||||
deg: 45,
|
||||
},
|
||||
|
||||
/* ---- 组件默认行为(示例) ---- */
|
||||
components: {
|
||||
Button: Button.extend({
|
||||
styles() {
|
||||
return {
|
||||
root: {
|
||||
outline: "none",
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||