commit f37a119eff0e5b8b14fedb5c3095e23855a131f2 Author: shay7sev Date: Tue Feb 3 18:05:47 2026 +0800 feat(project): init diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..f16261a --- /dev/null +++ b/.env.development @@ -0,0 +1,9 @@ +NEXT_PUBLIC_BASE_PATH="" + +# redis +# NEXT_PUBLIC_REDIS_URL="172.16.112.128" +NEXT_PUBLIC_REDIS_URL="192.168.193.237" +NEXT_PUBLIC_REDIS_PORT=9765 +NEXT_PUBLIC_REDIS_PASSWORD=password +NEXT_PUBLIC_REDIS_USERNAME=default +NEXT_PUBLIC_REDIS_DB=0 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..ec75cf0 --- /dev/null +++ b/.env.production @@ -0,0 +1,8 @@ +NEXT_PUBLIC_BASE_PATH="" + +# redis +NEXT_PUBLIC_REDIS_URL="172.16.105.154" +NEXT_PUBLIC_REDIS_PORT=8015 +NEXT_PUBLIC_REDIS_PASSWORD=aAXJM2uMzPu76L93 +NEXT_PUBLIC_REDIS_USERNAME=default +NEXT_PUBLIC_REDIS_DB=10 \ No newline at end of file diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..4d981e6 --- /dev/null +++ b/.env.staging @@ -0,0 +1,8 @@ +NEXT_PUBLIC_BASE_PATH="" + +# redis +NEXT_PUBLIC_REDIS_URL="172.16.112.128" +NEXT_PUBLIC_REDIS_PORT=9765 +NEXT_PUBLIC_REDIS_PASSWORD=password +NEXT_PUBLIC_REDIS_USERNAME=default +NEXT_PUBLIC_REDIS_DB=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..010a409 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +# .env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +pnpm-lock.yaml + +redislocal/data +redislocal/logs +redislocal/conf + + +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..c789399 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,45 @@ +module.exports = { + //仅在必需时为对象的key添加引号 + quoteProps: "as-needed", + // jsx中使用单引号 + jsxSingleQuote: false, + //多属性html标签的‘>’折行放置 + jsxBracketSameLine: true, + bracketSameLine: true, + //单参数箭头函数参数周围使用圆括号-eg: (x) => x + arrowParens: "always", + //对HTML全局空白不敏感 + htmlWhitespaceSensitivity: "ignore", + endOfLine: "lf", + // 一行的字符数,如果超过会进行换行,默认为80 + printWidth: 80, + // 一个tab代表几个空格数,默认为2 + tabWidth: 2, + // 是否使用tab进行缩进,默认为false,表示用空格进行缩减 + useTabs: false, + // 字符串是否使用单引号,默认为false,使用双引号 + singleQuote: false, + // 行位是否使用分号,默认为true + semi: false, + // 在 HTML、Vue 和 JSX 中每行强制执行单个属性 + singleAttributePerLine: false, + // 是否使用尾逗号,有三个可选值"" + trailingComma: "es5", + // 对象大括号直接是否有空格,默认为true,效果:{ foo: bar } + bracketSpacing: true, + // plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")], + // importOrder: [ + // "^vite", + // "^react", + // "^antd", + // "", + // "components/", + // "pages/", + // "hooks/", + // "utils/", + // "^[./]", + // ], + // importOrderSortSpecifiers: true, + // importOrderGroupNamespaceSpecifiers: true, + // importOrderCaseInsensitive: true, +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/api/fetch.ts b/api/fetch.ts new file mode 100644 index 0000000..890efd3 --- /dev/null +++ b/api/fetch.ts @@ -0,0 +1,295 @@ +/* + +Fetch 接口 + fetch() 包含了 fetch() 方法,用于获取资源。 + Headers 表示响应/请求的标头信息,允许你查询它们,或者针对不同的结果做不同的操作。 + Request 相当于一个资源请求。 + Response 相当于请求的响应 + +fetch 参数: + method: 请求使用的方法,如 GET、POST。 + headers: 请求的头信息,形式为 Headers 的对象或包含 ByteString 值的对象字面量 + body: 请求的 body 信息:可能是一个 Blob、BufferSource(x禁止)、FormData、URLSearchParams 或者 USVString 对象。注意 GET 或 HEAD 方法的请求不能包含 body 信息。 + mode: 请求的模式,如 cors、no-cors 或者 same-origin。 + credentials: 请求的 credentials,如 omit、same-origin 或者 include。为了在当前域名内自动发送 cookie,必须提供这个选项,从 Chrome 50 开始,这个属性也可以接受 FederatedCredential (en-US) 实例或是一个 PasswordCredential (en-US) 实例。 + +*/ +"use client" + +import { useUserStore } from "@/app/store/user" +import { deleteCookie, refreshKey } from "@/components/login/api" +import { isNumber } from "@/libs/util" + +interface OptionsProps { + url: string + method: string + body?: BodyInit + data?: any + needCredentials?: boolean + isUpload?: boolean + isDownload?: boolean + isFormData?: boolean + isLogin?: boolean + isRefresh?: boolean + isDeleteCookie?: boolean + notJson?: boolean + headerAttr?: { + [key: string]: string + } +} + +async function httpfetch(options: OptionsProps): Promise { + const { removeUserInfo, refreshToken } = useUserStore.getState() + + const access_token = useUserStore.getState().access_token + const refresh_token = useUserStore.getState().refresh_token + + let Message = { + error: (params: { content: string }) => { + console.log(params.content) + }, + } + + const createSsrRequest: (options: OptionsProps, token?: string) => Request = ( + options + ) => { + // 设置请求头 + let myHeaders = new Headers() + if (!options.isUpload && !options.isFormData && !options.notJson) { + myHeaders.append("Content-Type", "application/json") + } + if (options.isDownload) { + myHeaders.append("Response-Type", "blob") + } + if (options.isFormData) { + options.body && + (options.body as FormData)?.append && + (options.body as FormData).append("options", JSON.stringify(options)) + } + + // 配置常用设置 method 请求方式 headers 请求头 + let myInit: RequestInit = { + method: "POST", + headers: myHeaders, + mode: "cors", + cache: "default", + body: options.isFormData + ? options.body + : (JSON.stringify({ data: options.body, ...options }) as BodyInit), + } + const pathList = options.url.split("/") + const request = new Request( + `/api/${options.isFormData ? "transferFormData" : "transfer"}/${pathList[pathList.length - 1]}`, + myInit + ) + return request + } + + const createCsrRequest: (options: OptionsProps, token?: string) => Request = ( + options, + token = "" + ) => { + let request = new Request(options.url) + // 设置请求头 + let myHeaders = new Headers() + myHeaders.append("Authorization", `bearer ${token}`) + if (!options.isUpload && !options.isFormData && !options.notJson) { + myHeaders.append("Content-Type", "application/json") + } + if (options.isDownload) { + myHeaders.append("Response-Type", "blob") + } + if (options.headerAttr) { + Object.entries(options.headerAttr).map(([key, value]) => { + myHeaders.append(key, value) + }) + } + // myHeaders.append("X-Machine-Code", "machine_code0") + + // 配置常用设置 method 请求方式 headers 请求头 + let myInit: RequestInit = { + method: options.method, + headers: myHeaders, + mode: "cors", + cache: "default", + credentials: options.needCredentials ? "include" : undefined, + body: options.body as BodyInit, + } + + let publicUrl = options.url.includes("http") + ? "" + : process.env.NEXT_PUBLIC_URL + if (options.method === "GET") { + let searchParams = new URLSearchParams() + Object.entries(options.data || {})?.map((m) => { + searchParams.append(m[0], m[1] as string) + }) + let concatenatedURL = searchParams.toString() + ? options.url + "?" + searchParams.toString() + : options.url + request = new Request(publicUrl + concatenatedURL, myInit) + } else { + request = new Request(publicUrl + options.url, myInit) + } + return request + } + + const createNewRequest = + process.env.NEXT_PUBLIC_OUTPUT_TYPE === "standalone" + ? createSsrRequest + : createCsrRequest + + const request = createNewRequest(options, access_token) + + return new Promise((resolved, rejected) => { + fetch(request) + .then(async (response) => { + if (response.status === 200) { + if (options.isDownload) { + return response.blob() + } else { + return response.headers + .get("Content-Type") + ?.includes("application/json") + ? response.json() + : response.text() + } + } else { + const { status } = response + + if (status === 400) { + if ( + response.headers.get("Content-Type")?.includes("application/json") + ) { + const data = await response.json() + const { message: errInfo } = data + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else { + const errInfo = await response.text() + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } + } else if (status === 403) { + if ( + response.headers.get("Content-Type")?.includes("application/json") + ) { + const data = await response.json() + const { message: errInfo } = data + Message.error({ content: errInfo || "" }) + location.href = `${process.env.NEXT_PUBLIC_HOME_URL}` + throw new Error(errInfo) + } else { + const errInfo = await response.text() + Message.error({ content: errInfo || "" }) + location.href = `${process.env.NEXT_PUBLIC_HOME_URL}` + throw new Error(errInfo) + } + } else if (status === 500) { + if ( + response.headers.get("Content-Type")?.includes("application/json") + ) { + const data = await response.json() + const { message: errInfo } = data + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else { + const errInfo = await response.text() + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } + } else if (status === 406) { + const data = await response.json() + const { message: errInfo } = data + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else if (status >= 510) { + Message.error({ content: "申请资源不足!" }) + throw new Error("申请资源不足!") + } else { + Message.error({ content: "网络服务有误!" }) + throw new Error("Something went wrong on API server!") + } + } + }) + .then(async (response) => { + const { code, message: errInfo } = response + if (response) { + if (isNumber(code)) { + let status = code + // 日后做相关处理 + if (status === 401) { + try { + const refreshResponse = await refreshKey({ + token: refresh_token, + }) + if (refreshResponse.code === 200) { + let access_token = refreshResponse.message?.access || "" + let refresh_token = refreshResponse.message?.refresh || "" + refreshToken({ + refresh_token, + access_token, + }) + const retry_request = createNewRequest(options, access_token) + const retry_response = await fetch(retry_request) + return options.isDownload + ? retry_response.blob() + : retry_response.json() + } else { + const { code } = refreshResponse + if (code === 406) { + try { + await deleteCookie() + } catch (error) { + rejected(error) + } finally { + // 清空身份信息并跳转至登录页面 + removeUserInfo() + window.location.href = "/login" + } + } else { + throw new Error("重新获取token失败!") + } + } + } catch (error) { + rejected(error) + } + } else if (status === 400) { + const { message: errInfo } = response + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else if (status === 403) { + const { message: errInfo } = response + Message.error({ content: errInfo || "" }) + // location.href = `${process.env.NEXT_PUBLIC_HOME_URL}` + throw new Error(errInfo) + } else if (status === 500) { + const { message: errInfo } = response + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else if (status === 406) { + const { message: errInfo } = response + Message.error({ content: errInfo || "" }) + throw new Error(errInfo) + } else if (status >= 510) { + Message.error({ content: "申请资源不足!" }) + throw new Error("申请资源不足!") + } else if (status === 200) { + resolved(response) + } else { + Message.error({ content: "网络服务有误!" }) + throw new Error("Something went wrong on API server!") + } + } + resolved(response) + } else { + throw new Error(errInfo) + } + }) + .catch((error) => { + rejected(error) + }) + }) +} + +export default httpfetch diff --git a/api/typing.ts b/api/typing.ts new file mode 100644 index 0000000..360ce04 --- /dev/null +++ b/api/typing.ts @@ -0,0 +1,30 @@ +// * 请求响应参数(不包含data) +export interface Result { + code: number + status: boolean +} +// * 请求响应参数(包含data) +export interface ResultDataList extends Result { + data: T[] +} + +export interface ResultData extends Result { + message: T +} + +export interface ResultList extends Result { + list: T +} + +export interface ResPage { + list: T[] + page_number?: number + page_size?: number + total: number + total_pages?: number +} + +export interface ReqPage { + page_num?: number + page_size?: number +} diff --git a/app/api/transfer/[path]/route.ts b/app/api/transfer/[path]/route.ts new file mode 100644 index 0000000..1d56b7e --- /dev/null +++ b/app/api/transfer/[path]/route.ts @@ -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 }) + } +} diff --git a/app/component/base/department/page.tsx b/app/component/base/department/page.tsx new file mode 100644 index 0000000..2d1e5a3 --- /dev/null +++ b/app/component/base/department/page.tsx @@ -0,0 +1,5 @@ +"use client" + +export default function ComponentBaseDepartmentPage() { + return <>ComponentBaseDepartmentPage +} diff --git a/app/component/base/placement/page.tsx b/app/component/base/placement/page.tsx new file mode 100644 index 0000000..cb539bf --- /dev/null +++ b/app/component/base/placement/page.tsx @@ -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) => ( + {item.title} + )) + + return ( + <> + + {items} + + + + + + + + + + + + ) +} diff --git a/app/component/base/select/page.tsx b/app/component/base/select/page.tsx new file mode 100644 index 0000000..a369830 --- /dev/null +++ b/app/component/base/select/page.tsx @@ -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) => ( + {item.title} + )) + + const [value, setValue] = useState("") + + const [treeData, setTreeData] = useState([]) + const asyncGetCityTree = useCallback(async () => { + const res = await getCityTree() + setTreeData(convertTreeNodeToTreeNodeData(res)) + }, []) + + useEffect(() => { + queueMicrotask(() => asyncGetCityTree()) + }, [asyncGetCityTree]) + + return ( + <> + + {items} + + + + + {}} + w={"200px"} + /> + + {value} + + + + ) +} diff --git a/app/component/label/ffmpeg/README.md b/app/component/label/ffmpeg/README.md new file mode 100644 index 0000000..9878605 --- /dev/null +++ b/app/component/label/ffmpeg/README.md @@ -0,0 +1,14 @@ +# Video Frame Extractor (FFmpeg.wasm) + +## 运行位置 +- 纯前端:所有转码/抽帧在浏览器 Web Worker + WASM 内完成,不依赖后端服务。 + +## 大文件最佳实践 +- 优先使用 WORKERFS 挂载输入文件,避免 `writeFile(arrayBuffer)` 将整段视频复制进 WASM 内存。 +- 抽帧建议走“单帧模式”:每次 `-ss