commit 1ae8509ccebad39905619e2801809c97c7642530 Author: shay7sev Date: Tue Mar 10 17:06:22 2026 +0800 feat(project): init diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..f0c4a53 --- /dev/null +++ b/.env.development @@ -0,0 +1,14 @@ +NEXT_PUBLIC_BASE_PATH="/image" + + +NEXT_PUBLIC_ENV=development + + +# redis +# NEXT_PUBLIC_REDIS_URL="172.16.112.128" +NEXT_PUBLIC_REDIS_URL="localhost" +NEXT_PUBLIC_REDIS_PORT=9765 +NEXT_PUBLIC_REDIS_PASSWORD=password +NEXT_PUBLIC_REDIS_USERNAME=default +NEXT_PUBLIC_REDIS_DB=0 + diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..35b86bb --- /dev/null +++ b/.env.production @@ -0,0 +1,10 @@ +NEXT_PUBLIC_BASE_PATH="" + +NEXT_PUBLIC_ENV=production + +# 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..cf50bf9 --- /dev/null +++ b/.env.staging @@ -0,0 +1,10 @@ +NEXT_PUBLIC_BASE_PATH="" + +NEXT_PUBLIC_ENV=staging + +# 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..5df6ec3 --- /dev/null +++ b/api/fetch.ts @@ -0,0 +1,323 @@ +/* + +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( + `${process.env.NEXT_PUBLIC_BASE_PATH}/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 === 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() + } + } catch (error) { + rejected(error) + try { + await deleteCookie() + } catch (error) { + rejected(error) + } finally { + // 清空身份信息并跳转至登录页面 + removeUserInfo() + window.location.href = "/login" + } + } + } 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 { + if (refreshResponse.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..a9ed8fc --- /dev/null +++ b/app/api/transfer/[path]/route.ts @@ -0,0 +1,174 @@ +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) + + let fetchOptions: any = {} + + if (isRefresh) { + if (!userData?.refresh_token) { + return NextResponse.json( + { message: "Refresh token is empty", code: 406 }, + { status: 200 } + ) + } + fetchOptions = { + method, + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + token: userData?.refresh_token, + }), + } + } else { + const authorization = `bearer ${userData?.access_token}` + fetchOptions = { + method, + headers: { + "content-type": "application/json", + authorization: authorization, + }, + } + if (method === "POST" || method === "PUT") { + fetchOptions.body = 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) { + try { + const data = await (res.headers + .get("Content-Type") + ?.includes("application/json") + ? res.json() + : res.text()) + + if (isLogin) { + const header = await headers() + let clientIP = + header.get("x-forwarded-for")?.split(",")[0] || + req?.socket?.remoteAddress + // ip v6地址 + if (clientIP.startsWith("::")) { + clientIP = clientIP.slice(7) + } + const newData = await handleLoginData({ + clientIP: clientIP, + 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", + }, + }) + } catch (err) { + return NextResponse.json({ error: err }, { status: 500 }) + } + } else { + return res + } + } catch (err) { + return NextResponse.json({ error: err }, { status: 500 }) + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..e76a859 --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..290ac0d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,68 @@ +"use client" + +import "@mantine/core/styles.css" +import "@mantine/core/styles.layer.css" +import "@mantine/notifications/styles.css" +import "mantine-datatable/styles.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" + +const GLOBAL_NOTICE_Z_INDEX = 4000 + +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 ( + + + +