Initial project import
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
.idea
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
enable-pre-post-scripts=true
|
||||
+1501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTypescript from 'eslint-config-next/typescript';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
|
||||
const eslintConfig = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off', // 不检查未使用的变量
|
||||
'@typescript-eslint/no-explicit-any': 'off', // 关闭 any 报错
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { NextConfig } from 'next'
|
||||
import { parseChangelog } from './src/lib/release'
|
||||
|
||||
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const version = readFileSync(join(projectRoot, 'VERSION'), 'utf-8').trim()
|
||||
return version || '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
|
||||
let appReleases = '[]'
|
||||
try {
|
||||
appReleases = JSON.stringify(parseChangelog(readFileSync(join(projectRoot, 'CHANGELOG.md'), 'utf-8')))
|
||||
} catch {}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['127.0.0.1'],
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||
NEXT_PUBLIC_APP_RELEASES: appReleases,
|
||||
},
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "next-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"immer": "^10.1.3",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-medium-image-zoom": "^5.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@umijs/openapi": "^1.13.15",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1,925 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
FileText,
|
||||
Files,
|
||||
KeyRound,
|
||||
LoaderCircle,
|
||||
LogIn,
|
||||
ServerCog,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createAccounts,
|
||||
finishOAuthLogin,
|
||||
startOAuthLogin,
|
||||
type Account,
|
||||
type AccountImportPayload,
|
||||
type OAuthLoginStartResponse,
|
||||
} from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImportMethod = "menu" | "token" | "session" | "codex-auth" | "account-json" | "oauth";
|
||||
|
||||
type AccountImportDialogProps = {
|
||||
disabled?: boolean;
|
||||
onImported: (items: Account[]) => void;
|
||||
};
|
||||
|
||||
type PendingAccountJsonImport = {
|
||||
tokens: string[];
|
||||
accounts: AccountImportPayload[];
|
||||
parsedAccountCount: number;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
const sessionUrl = "https://chatgpt.com/api/auth/session";
|
||||
|
||||
function splitTokens(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getSessionAccessToken(value: unknown) {
|
||||
const token = (value as { accessToken?: unknown })?.accessToken;
|
||||
return typeof token === "string" ? token.trim() : "";
|
||||
}
|
||||
|
||||
function getAccountJsonAccount(value: unknown): AccountImportPayload | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
const tokenValue = raw.access_token ?? raw.accessToken;
|
||||
const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: AccountImportPayload = {
|
||||
...raw,
|
||||
access_token: token,
|
||||
source_type: "codex",
|
||||
};
|
||||
delete payload.accessToken;
|
||||
if (payload.type === "codex") {
|
||||
payload.export_type = "codex";
|
||||
delete payload.type;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getAccountJsonAccounts(value: unknown): AccountImportPayload[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => getAccountJsonAccount(item))
|
||||
.filter((item): item is AccountImportPayload => Boolean(item));
|
||||
}
|
||||
|
||||
const singleAccount = getAccountJsonAccount(value);
|
||||
if (singleAccount) {
|
||||
return [singleAccount];
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const raw = value as Record<string, unknown>;
|
||||
const nested = raw.accounts ?? raw.items;
|
||||
if (Array.isArray(nested)) {
|
||||
return nested
|
||||
.map((item) => getAccountJsonAccount(item))
|
||||
.filter((item): item is AccountImportPayload => Boolean(item));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getCodexAuthAccount(value: unknown): AccountImportPayload | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
const tokenValue = raw.access_token ?? raw.accessToken;
|
||||
const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: AccountImportPayload = {
|
||||
...raw,
|
||||
access_token: token,
|
||||
export_type: "codex",
|
||||
source_type: "codex",
|
||||
};
|
||||
delete payload.accessToken;
|
||||
if (payload.type === "codex") {
|
||||
delete payload.type;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readFileAsText(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function MethodCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof KeyRound;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white p-0 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
||||
>
|
||||
<Card className="rounded-2xl border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex items-start gap-4 p-4">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-stone-700">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-stone-900">{title}</div>
|
||||
<div className="text-sm leading-6 text-stone-500">{description}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [method, setMethod] = useState<ImportMethod>("menu");
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const [sessionInput, setSessionInput] = useState("");
|
||||
const [codexAuthInput, setCodexAuthInput] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [pendingAccountJsonImport, setPendingAccountJsonImport] = useState<PendingAccountJsonImport | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [oauthEmailHint, setOauthEmailHint] = useState("");
|
||||
const [oauthSession, setOauthSession] = useState<OAuthLoginStartResponse | null>(null);
|
||||
const [oauthCallbackInput, setOauthCallbackInput] = useState("");
|
||||
const [oauthStarting, setOauthStarting] = useState(false);
|
||||
|
||||
const txtInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const accountJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setMethod("menu");
|
||||
setTokenInput("");
|
||||
setSessionInput("");
|
||||
setCodexAuthInput("");
|
||||
setPendingAccountJsonImport(null);
|
||||
setConfirmOpen(false);
|
||||
setOauthEmailHint("");
|
||||
setOauthSession(null);
|
||||
setOauthCallbackInput("");
|
||||
setOauthStarting(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
resetState();
|
||||
}
|
||||
};
|
||||
|
||||
const submitTokens = async (tokens: string[], successText?: string, accountPayloads: AccountImportPayload[] = []) => {
|
||||
const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean);
|
||||
|
||||
if (normalizedTokens.length === 0) {
|
||||
toast.error("请先提供至少一个可用 Token");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const data = await createAccounts(normalizedTokens, accountPayloads);
|
||||
onImported(data.items);
|
||||
setOpen(false);
|
||||
resetState();
|
||||
|
||||
if ((data.errors?.length ?? 0) > 0) {
|
||||
const firstError = data.errors?.[0]?.error;
|
||||
toast.error(
|
||||
`${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "导入账户失败";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportTokenText = async () => {
|
||||
await submitTokens(splitTokens(tokenInput), "Access Token 导入完成");
|
||||
};
|
||||
|
||||
// 起授权:拿 authorize URL,立刻在新窗口打开,方便用户登录
|
||||
const handleStartOAuth = async () => {
|
||||
setOauthStarting(true);
|
||||
try {
|
||||
const data = await startOAuthLogin(oauthEmailHint.trim());
|
||||
setOauthSession(data);
|
||||
setOauthCallbackInput("");
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(data.authorize_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
toast.success("已打开 OpenAI 授权页面,请在登录后复制 callback URL 回来");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "OAuth 起始失败";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setOauthStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 用粘贴回来的 callback URL 完成换 token + 落盘
|
||||
const handleFinishOAuth = async () => {
|
||||
if (!oauthSession) {
|
||||
toast.error("请先点击\"打开授权页面\"获取 session");
|
||||
return;
|
||||
}
|
||||
const trimmed = oauthCallbackInput.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("请粘贴 callback URL 或 code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const data = await finishOAuthLogin(oauthSession.session_id, trimmed);
|
||||
onImported(data.items);
|
||||
setOpen(false);
|
||||
resetState();
|
||||
|
||||
if ((data.errors?.length ?? 0) > 0) {
|
||||
const firstError = data.errors?.[0]?.error;
|
||||
toast.error(
|
||||
`OAuth 登录完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`OAuth 登录完成,新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "OAuth 换 token 失败";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 复制 authorize URL 到剪贴板(适配浏览器和 fallback)
|
||||
const handleCopyAuthorizeUrl = async () => {
|
||||
if (!oauthSession) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(oauthSession.authorize_url);
|
||||
toast.success("授权 URL 已复制到剪贴板");
|
||||
} else {
|
||||
toast.error("当前环境不支持自动复制,请手动选择并复制");
|
||||
}
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选择并复制");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTxtSelected = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFileAsText(file);
|
||||
const tokens = splitTokens(content);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
toast.error("TXT 文件里没有读取到有效 Token");
|
||||
return;
|
||||
}
|
||||
|
||||
setTokenInput((prev) => {
|
||||
const next = [...splitTokens(prev), ...tokens];
|
||||
return next.join("\n");
|
||||
});
|
||||
toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "读取 TXT 文件失败";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportSessionJson = async () => {
|
||||
if (!sessionInput.trim()) {
|
||||
toast.error("请先粘贴完整 Session JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(sessionInput) as unknown;
|
||||
const token = getSessionAccessToken(payload);
|
||||
|
||||
if (!token) {
|
||||
toast.error("未从 Session JSON 中提取到 accessToken");
|
||||
return;
|
||||
}
|
||||
|
||||
await submitTokens([token], "Session JSON 导入完成");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Session JSON 解析失败";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCodexAuthJson = async () => {
|
||||
if (!codexAuthInput.trim()) {
|
||||
toast.error("请先粘贴 Codex 认证 JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(codexAuthInput) as unknown;
|
||||
const account = getCodexAuthAccount(payload);
|
||||
|
||||
if (!account) {
|
||||
toast.error("未从 Codex 认证 JSON 中提取到 access_token");
|
||||
return;
|
||||
}
|
||||
|
||||
await submitTokens([account.access_token], "Codex 认证 JSON 导入完成", [account]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Codex 认证 JSON 解析失败";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountJsonSelected = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const raw = await readFileAsText(file);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const accounts = getAccountJsonAccounts(parsed);
|
||||
return {
|
||||
accounts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const accounts = results.flatMap((item) => item.accounts);
|
||||
const tokens = accounts.map((item) => item.access_token);
|
||||
const parsedAccountCount = accounts.length;
|
||||
const errorCount = results.filter((item) => item.accounts.length === 0).length;
|
||||
|
||||
if (parsedAccountCount === 0) {
|
||||
toast.error("这些账号 JSON 文件里没有读取到可用 access_token");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAccountJsonImport({
|
||||
tokens,
|
||||
accounts,
|
||||
parsedAccountCount,
|
||||
errorCount,
|
||||
});
|
||||
setConfirmOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "读取账号 JSON 文件失败";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMethodBody = () => {
|
||||
if (method === "token") {
|
||||
const tokenCount = splitTokens(tokenInput).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("menu")}
|
||||
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回导入方式
|
||||
</button>
|
||||
<span className="text-xs text-stone-400">当前识别 {tokenCount} 个 Token</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">Access Token 列表</label>
|
||||
<Textarea
|
||||
placeholder="每行一个 Access Token..."
|
||||
value={tokenInput}
|
||||
onChange={(event) => setTokenInput(event.target.value)}
|
||||
className="min-h-56 resize-none rounded-xl border-stone-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-stone-800">从 TXT 文件导入</div>
|
||||
<div className="text-sm leading-6 text-stone-500">支持 `.txt`,文件内容也是一行一个 Token。</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-xl border-stone-200 bg-white"
|
||||
onClick={() => txtInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
选择 TXT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={txtInputRef}
|
||||
type="file"
|
||||
accept=".txt,text/plain"
|
||||
className="hidden"
|
||||
onChange={(event) => void handleTxtSelected(event)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "session") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("menu")}
|
||||
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回导入方式
|
||||
</button>
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600">
|
||||
打开
|
||||
{" "}
|
||||
<a
|
||||
href={sessionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-stone-900 underline underline-offset-4"
|
||||
>
|
||||
{sessionUrl}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
,复制页面返回的完整 JSON,系统会自动提取其中的 `accessToken` 导入。
|
||||
</div>
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
||||
<div className="font-medium">风险提示</div>
|
||||
<div>
|
||||
不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">Session JSON</label>
|
||||
<Textarea
|
||||
placeholder='粘贴完整 JSON,例如包含 "accessToken" 的对象...'
|
||||
value={sessionInput}
|
||||
onChange={(event) => setSessionInput(event.target.value)}
|
||||
className="min-h-56 resize-none rounded-xl border-stone-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "oauth") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("menu")}
|
||||
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回导入方式
|
||||
</button>
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm leading-6 text-stone-600 space-y-2">
|
||||
<div className="font-medium text-stone-800">操作步骤</div>
|
||||
<ol className="list-decimal pl-5 space-y-1">
|
||||
<li>(可选)填写你 ChatGPT 账号的邮箱,登录页会预填。</li>
|
||||
<li>点击下方"打开授权页面",在新标签里登录自己的 ChatGPT 账号。</li>
|
||||
<li>登录完成后浏览器会跳到 <code className="rounded bg-stone-200 px-1">platform.openai.com/auth/callback?code=...</code>。立刻从地址栏复制整段 URL(或开 F12 在 Network 里抓到 callback 那一行,右键 Copy → Copy URL)。</li>
|
||||
<li>把 callback URL 粘到下面输入框,点"完成导入"。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">邮箱(可选预填)</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={oauthEmailHint}
|
||||
onChange={(event) => setOauthEmailHint(event.target.value)}
|
||||
disabled={Boolean(oauthSession) || oauthStarting}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
|
||||
/>
|
||||
</div>
|
||||
{!oauthSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleStartOAuth()}
|
||||
disabled={oauthStarting}
|
||||
>
|
||||
{oauthStarting ? <LoaderCircle className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}
|
||||
打开授权页面
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3 text-xs leading-6 text-stone-600 break-all font-mono">
|
||||
{oauthSession.authorize_url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-xl border-stone-200 bg-white"
|
||||
onClick={() => void handleCopyAuthorizeUrl()}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
复制授权 URL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-xl border-stone-200 bg-white"
|
||||
onClick={() => window.open(oauthSession.authorize_url, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
再次打开
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-xl border-stone-200 bg-white"
|
||||
onClick={() => {
|
||||
setOauthSession(null);
|
||||
setOauthCallbackInput("");
|
||||
}}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">粘贴 callback URL(或仅 code)</label>
|
||||
<Textarea
|
||||
placeholder={"https://platform.openai.com/auth/callback?code=...&state=..."}
|
||||
value={oauthCallbackInput}
|
||||
onChange={(event) => setOauthCallbackInput(event.target.value)}
|
||||
className="min-h-24 resize-none rounded-xl border-stone-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
||||
<div className="font-medium">注意</div>
|
||||
<div>
|
||||
授权码(code)只能使用一次。如果浏览器的 callback 页加载完成、显示了 OpenAI 的错误页,那 code 大概率已经被消耗,
|
||||
请点击"重新生成"再走一次。整个流程在 10 分钟内完成即可。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "account-json") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("menu")}
|
||||
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回导入方式
|
||||
</button>
|
||||
<div className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 p-5">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-stone-800">选择本地账号 JSON 文件</div>
|
||||
<div className="text-sm leading-6 text-stone-500">
|
||||
支持本项目导出的单账号对象或全部账号数组,也兼容每个文件一个账号对象的 CPA JSON。
|
||||
系统会自动提取 `access_token` 或 `accessToken`。
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-4 rounded-xl bg-stone-950 text-white hover:bg-stone-800"
|
||||
onClick={() => accountJsonInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Files className="size-4" />
|
||||
选择 JSON 文件
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={accountJsonInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void handleAccountJsonSelected(event)}
|
||||
/>
|
||||
{pendingAccountJsonImport ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-4 text-sm leading-6 text-stone-600">
|
||||
最近一次读取到 {pendingAccountJsonImport.parsedAccountCount} 个 Token
|
||||
{pendingAccountJsonImport.errorCount > 0 ? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功` : ""}。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "codex-auth") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("menu")}
|
||||
className="inline-flex items-center gap-1 text-sm text-stone-500 transition hover:text-stone-800"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回导入方式
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">Codex 认证 JSON</label>
|
||||
<Textarea
|
||||
placeholder='粘贴包含 "access_token"、"refresh_token"、"id_token" 的 Codex 认证 JSON...'
|
||||
value={codexAuthInput}
|
||||
onChange={(event) => setCodexAuthInput(event.target.value)}
|
||||
className="min-h-64 resize-none rounded-xl border-stone-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MethodCard
|
||||
title="OAuth 登录已有账号(带自动刷新)"
|
||||
description="用浏览器登录自己的 ChatGPT 账号,回填 callback URL 即可拿到 refresh_token,后台会自动续期。"
|
||||
icon={LogIn}
|
||||
onClick={() => setMethod("oauth")}
|
||||
/>
|
||||
<MethodCard
|
||||
title="导入 Access Token"
|
||||
description="支持直接粘贴,一行一个;也支持从 TXT 文件读取,一行一个。"
|
||||
icon={KeyRound}
|
||||
onClick={() => setMethod("token")}
|
||||
/>
|
||||
<MethodCard
|
||||
title="导入 Session JSON"
|
||||
description="从 chatgpt.com 的 session 接口复制完整 JSON,自动提取 accessToken。"
|
||||
icon={FileJson}
|
||||
onClick={() => setMethod("session")}
|
||||
/>
|
||||
<MethodCard
|
||||
title="导入 Codex 认证 JSON"
|
||||
description="粘贴 Codex 认证 JSON,导入后账号来源标记为 codex。"
|
||||
icon={FileJson}
|
||||
onClick={() => setMethod("codex-auth")}
|
||||
/>
|
||||
<MethodCard
|
||||
title="导入账号 JSON 文件"
|
||||
description="支持本项目导出的单账号 JSON 或全部账号数组,也兼容 CPA JSON 文件。"
|
||||
icon={Files}
|
||||
onClick={() => setMethod("account-json")}
|
||||
/>
|
||||
<MethodCard
|
||||
title="从远程 CPA 服务器导入"
|
||||
description="前往设置页面配置远程 CPA 服务器后再执行导入。"
|
||||
icon={Files}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetState();
|
||||
router.push("/settings");
|
||||
}}
|
||||
/>
|
||||
<MethodCard
|
||||
title="从 Sub2API 服务器导入"
|
||||
description="前往设置页面配置 Sub2API 服务器,再选择其中的 OpenAI 账号导入。"
|
||||
icon={ServerCog}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetState();
|
||||
router.push("/settings");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const footerDisabled = disabled || isSubmitting;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
导入
|
||||
</Button>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>
|
||||
{method === "menu"
|
||||
? "导入账户"
|
||||
: method === "token"
|
||||
? "导入 Access Token"
|
||||
: method === "session"
|
||||
? "导入 Session JSON"
|
||||
: method === "codex-auth"
|
||||
? "导入 Codex 认证 JSON"
|
||||
: method === "oauth"
|
||||
? "OAuth 登录已有账号"
|
||||
: "导入账号 JSON"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{method === "menu"
|
||||
? "选择一种导入方式。导入成功后会自动拉取邮箱、类型和额度。"
|
||||
: method === "token"
|
||||
? "支持手动粘贴或从 TXT 文件导入,一行一个 Token。"
|
||||
: method === "session"
|
||||
? "粘贴完整 Session JSON,系统会自动提取 accessToken。"
|
||||
: method === "codex-auth"
|
||||
? "粘贴 Codex 认证 JSON,系统会按 codex 来源导入。"
|
||||
: method === "oauth"
|
||||
? "用浏览器跑一遍 OpenAI 标准 OAuth,拿回 refresh_token 后系统会自动续期。"
|
||||
: "支持读取本项目导出的单账号对象或全部账号数组,并在提交前做数量确认。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{renderMethodBody()}
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={footerDisabled}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{method === "token" ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleImportTokenText()}
|
||||
disabled={footerDisabled}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
导入 Token
|
||||
</Button>
|
||||
) : null}
|
||||
{method === "session" ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleImportSessionJson()}
|
||||
disabled={footerDisabled}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
导入 JSON
|
||||
</Button>
|
||||
) : null}
|
||||
{method === "codex-auth" ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleImportCodexAuthJson()}
|
||||
disabled={footerDisabled}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
导入 JSON
|
||||
</Button>
|
||||
) : null}
|
||||
{method === "oauth" ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
|
||||
!oauthSession ? "hidden" : "",
|
||||
)}
|
||||
onClick={() => void handleFinishOAuth()}
|
||||
disabled={footerDisabled || !oauthSession || !oauthCallbackInput.trim()}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
完成导入
|
||||
</Button>
|
||||
) : null}
|
||||
{method === "account-json" ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800",
|
||||
!pendingAccountJsonImport ? "hidden" : "",
|
||||
)}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={footerDisabled || !pendingAccountJsonImport}
|
||||
>
|
||||
查看导入确认
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>确认导入账号 Token</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{pendingAccountJsonImport
|
||||
? `确认识别到 ${pendingAccountJsonImport.parsedAccountCount} 个 Token,是否确认导入?`
|
||||
: "尚未读取到可导入的 Token。"}
|
||||
{pendingAccountJsonImport?.errorCount
|
||||
? `,另有 ${pendingAccountJsonImport.errorCount} 个文件未提取成功。`
|
||||
: "。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() =>
|
||||
void submitTokens(
|
||||
pendingAccountJsonImport?.tokens ?? [],
|
||||
"账号 JSON 导入完成",
|
||||
pendingAccountJsonImport?.accounts ?? [],
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting || !pendingAccountJsonImport}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
确认导入
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ImagePlus, LoaderCircle, Send, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { httpRequest } from "@/lib/request";
|
||||
|
||||
import { pretty, type ChatCompletionResponse, type ChatContentPart, type ChatMessage } from "./types";
|
||||
|
||||
type SelectedImage = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function readImage(file: File): Promise<SelectedImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
reject(new Error(`${file.name} 不是图片文件`));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
reject(new Error(`${file.name} 超过 10MB`));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = String(reader.result || "");
|
||||
if (!url.startsWith("data:image/")) {
|
||||
reject(new Error(`${file.name} 读取失败`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2)}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error(`${file.name} 读取失败`));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function messageText(message: ChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function messageImages(message: ChatMessage): string[] {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return [];
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is { type: "image_url"; image_url: { url: string } } => part.type === "image_url")
|
||||
.map((part) => part.image_url.url);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [model, setModel] = useState("auto");
|
||||
const [reasoningEffort, setReasoningEffort] = useState("");
|
||||
const [input, setInput] = useState("你好,先记住我的项目叫 chatgpt2api。");
|
||||
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [raw, setRaw] = useState<ChatCompletionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleImagesChange = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setError("");
|
||||
try {
|
||||
const images = await Promise.all(Array.from(files).map(readImage));
|
||||
setSelectedImages((current) => [...current, ...images].slice(0, 4));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const sendChat = async () => {
|
||||
const text = input.trim();
|
||||
if (!text && !selectedImages.length) return;
|
||||
const content: string | ChatContentPart[] = selectedImages.length
|
||||
? [
|
||||
...(text ? [{ type: "text" as const, text }] : []),
|
||||
...selectedImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.url } })),
|
||||
]
|
||||
: text;
|
||||
const nextMessages: ChatMessage[] = [...messages, { role: "user", content }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setSelectedImages([]);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = {
|
||||
model: model.trim() || "auto",
|
||||
messages: nextMessages,
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
};
|
||||
const result = await httpRequest<ChatCompletionResponse>("/v1/chat/completions", { method: "POST", body });
|
||||
setRaw(result);
|
||||
setMessages([...nextMessages, { role: "assistant", content: String(result.choices?.[0]?.message?.content || "") }]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
setSelectedImages([]);
|
||||
setRaw(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-8 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="flex min-h-0 flex-col lg:border-r lg:border-stone-200/70 lg:pr-8 dark:lg:border-white/10">
|
||||
<div className="border-b border-stone-200/70 pb-3 dark:border-white/10">
|
||||
<h2 className="text-sm font-medium text-stone-500 dark:text-stone-400">请求</h2>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-auto pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat-model">Model</Label>
|
||||
<Input id="chat-model" value={model} onChange={(event) => setModel(event.target.value)} className="rounded-md border-stone-200/70 bg-transparent shadow-none dark:border-white/10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat-reasoning-effort">思考强度</Label>
|
||||
<Select value={reasoningEffort || "default"} onValueChange={(value) => setReasoningEffort(value === "default" ? "" : value)}>
|
||||
<SelectTrigger id="chat-reasoning-effort" className="h-10 rounded-md border-stone-200/70 bg-transparent shadow-none dark:border-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">默认</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="xhigh">超高</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat-input">Message</Label>
|
||||
<Textarea id="chat-input" value={input} onChange={(event) => setInput(event.target.value)} className="min-h-32 rounded-md border-stone-200/70 bg-transparent shadow-none dark:border-white/10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat-images">图片</Label>
|
||||
<label htmlFor="chat-images" className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-stone-300 bg-stone-50/70 px-3 py-3 text-sm text-stone-600 transition hover:border-stone-400 hover:bg-stone-100 dark:border-white/10 dark:bg-white/[0.03] dark:text-stone-300 dark:hover:bg-white/[0.06]">
|
||||
<ImagePlus className="size-4" />
|
||||
选择图片
|
||||
</label>
|
||||
<input id="chat-images" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple className="sr-only" onChange={(event) => {
|
||||
void handleImagesChange(event.target.files);
|
||||
event.currentTarget.value = "";
|
||||
}} />
|
||||
{selectedImages.length ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{selectedImages.map((image) => (
|
||||
<div key={image.id} className="group relative overflow-hidden rounded-md border border-stone-200 bg-white dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<img src={image.url} alt={image.name} className="aspect-square w-full object-cover" />
|
||||
<button type="button" aria-label={`移除 ${image.name}`} onClick={() => setSelectedImages((current) => current.filter((item) => item.id !== image.id))} className="absolute top-1 right-1 flex size-7 items-center justify-center rounded-md bg-white/90 text-stone-700 shadow-sm transition hover:bg-white dark:bg-stone-950/90 dark:text-stone-100">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-white/90 px-2 py-1 text-xs text-stone-600 dark:bg-stone-950/90 dark:text-stone-300">{image.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => void sendChat()} disabled={loading || (!input.trim() && !selectedImages.length)}>
|
||||
{loading ? <LoaderCircle className="animate-spin" /> : <Send />}
|
||||
发送
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={clearChat}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <div className="rounded-md border border-rose-200 bg-rose-50/60 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300">{error}</div> : null}
|
||||
<Textarea value={raw ? pretty(raw) : "{\n \"messages\": []\n}"} readOnly className="min-h-72 resize-none rounded-md border-stone-200/70 bg-stone-50/50 p-4 font-mono text-xs leading-5 text-stone-600 shadow-none dark:border-white/10 dark:bg-white/[0.03] dark:text-stone-300" />
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex min-h-0 flex-col">
|
||||
<div className="border-b border-stone-200/70 pb-3 dark:border-white/10">
|
||||
<h2 className="text-sm font-medium text-stone-500 dark:text-stone-400">对话</h2>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-auto pt-4">
|
||||
{messages.length ? messages.map((message, index) => (
|
||||
<div key={`${message.role}-${index}`} className="space-y-1.5 text-sm">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-stone-500">{message.role}</div>
|
||||
{messageImages(message).length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{messageImages(message).map((url, imageIndex) => (
|
||||
<img key={`${index}-${imageIndex}`} src={url} alt="" className="h-28 w-28 rounded-md border border-stone-200 object-cover dark:border-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{messageText(message) ? <div className="whitespace-pre-wrap leading-7 text-stone-700 dark:text-stone-300">{messageText(message)}</div> : null}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-stone-400 dark:text-stone-500">暂无对话消息</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, CheckCircle2, Clock3, FileArchive, FileText, History, ImagePlus, LoaderCircle, Pencil, Play, Plus, RefreshCw, Trash2, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { httpRequest } from "@/lib/request";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
listDeletedEditableFileIds,
|
||||
listEditableFileDrafts,
|
||||
saveDeletedEditableFileIds,
|
||||
saveEditableFileDrafts,
|
||||
type EditableFileDraft,
|
||||
} from "@/store/editable-file-history";
|
||||
|
||||
import type { EditableFileTask } from "./types";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
kind: "ppt" | "psd";
|
||||
endpoint: string;
|
||||
defaultPrompt: string;
|
||||
imageRequired?: boolean;
|
||||
};
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
const DRAFT_ID = "__draft__";
|
||||
const taskIdOf = (task: EditableFileTask | null | undefined) => task?.taskId || task?.id || "";
|
||||
const isRunning = (task: EditableFileTask | null | undefined) => task?.status === "queued" || task?.status === "running";
|
||||
const statusText = (status: string) => ({ queued: "排队中", running: "生成中", success: "已完成", error: "失败" }[status] || status);
|
||||
const statusClass = (status: string) => status === "success" ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/20 dark:text-emerald-300" : status === "error" ? "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300" : "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-300";
|
||||
const formatElapsed = (seconds: number) => `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
||||
const titleOfPrompt = (prompt: string, fallback: string) => prompt.trim().replace(/\s+/g, " ").slice(0, 24) || fallback;
|
||||
const createClientTaskId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const readFile = (file: File) => new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const mergeTasks = (current: EditableFileTask[], updates: EditableFileTask[]) => {
|
||||
const next = [...current];
|
||||
for (const update of updates) {
|
||||
const id = taskIdOf(update);
|
||||
if (!id) continue;
|
||||
const index = next.findIndex((item) => taskIdOf(item) === id);
|
||||
const merged = { ...(index >= 0 ? next[index] : {}), ...update, polled_at: Date.now() };
|
||||
if (index >= 0) next[index] = merged;
|
||||
else next.unshift(merged);
|
||||
}
|
||||
return next.slice(0, MAX_HISTORY);
|
||||
};
|
||||
|
||||
const removeTasks = (current: EditableFileTask[], ids: string[]) => {
|
||||
const missing = new Set(ids);
|
||||
return missing.size ? current.filter((task) => !missing.has(taskIdOf(task))) : current;
|
||||
};
|
||||
|
||||
const fileNameOf = (url: string) => {
|
||||
try {
|
||||
return decodeURIComponent(new URL(url).pathname.split("/").pop() || "");
|
||||
} catch {
|
||||
return decodeURIComponent(url.split("/").pop() || "");
|
||||
}
|
||||
};
|
||||
|
||||
function ResultFile({ href, icon, label }: { href?: string; icon: ReactNode; label: string }) {
|
||||
if (!href) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-stone-200 bg-stone-50/80 px-3 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-stone-700 shadow-sm dark:bg-white/10 dark:text-stone-200">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-stone-950 dark:text-stone-50">{label}</div>
|
||||
<div className="truncate text-xs text-stone-500 dark:text-stone-400">{fileNameOf(href)}</div>
|
||||
</div>
|
||||
<Button size="sm" asChild>
|
||||
<a href={href} target="_blank" rel="noreferrer">下载</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableFilePanel({ title, kind, endpoint, defaultPrompt, imageRequired }: Props) {
|
||||
const [prompt, setPrompt] = useState(defaultPrompt);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [tasks, setTasks] = useState<EditableFileTask[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [drafts, setDrafts] = useState<Record<string, EditableFileDraft>>({});
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
||||
const [renamingId, setRenamingId] = useState("");
|
||||
const [renamingTitle, setRenamingTitle] = useState("");
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: "one"; id: string } | { type: "all"; ids: string[] } | null>(null);
|
||||
const visibleTasks = useMemo(() => tasks.filter((task) => task.kind === kind && !deletedIds.has(taskIdOf(task))).slice(0, MAX_HISTORY), [deletedIds, kind, tasks]);
|
||||
const selectedTask = selectedId === DRAFT_ID ? null : visibleTasks.find((task) => taskIdOf(task) === selectedId) || visibleTasks[0] || null;
|
||||
const running = visibleTasks.some(isRunning);
|
||||
const runningIds = visibleTasks.filter(isRunning).map(taskIdOf).join(",");
|
||||
|
||||
const elapsedOf = (task: EditableFileTask | null | undefined) => {
|
||||
if (!task) return 0;
|
||||
const base = Math.max(0, Number(task.elapsed_seconds || 0));
|
||||
return Math.max(0, isRunning(task) && task.polled_at ? base + Math.floor((now - task.polled_at) / 1000) : base);
|
||||
};
|
||||
|
||||
const fetchTasks = useCallback(async (ids: string[] = []) => {
|
||||
const taskIds = Array.from(new Set(ids.filter(Boolean))).slice(0, MAX_HISTORY);
|
||||
setPolling(true);
|
||||
try {
|
||||
const path = taskIds.length ? `/v1/editable-file-tasks?ids=${taskIds.map(encodeURIComponent).join(",")}` : "/v1/editable-file-tasks";
|
||||
const result = await httpRequest<{ items: EditableFileTask[]; missing_ids?: string[] }>(path);
|
||||
const missingIds = result.missing_ids || [];
|
||||
const hidden = await listDeletedEditableFileIds(kind);
|
||||
setTasks((current) => (taskIds.length ? mergeTasks(removeTasks(current, missingIds), result.items || []) : (result.items || [])).filter((task) => !hidden.has(taskIdOf(task))));
|
||||
setSelectedId((current) => missingIds.includes(current) ? "" : current);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPolling(false);
|
||||
}
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
void listEditableFileDrafts(kind).then(setDrafts);
|
||||
void listDeletedEditableFileIds(kind).then(setDeletedIds);
|
||||
void fetchTasks();
|
||||
}, [fetchTasks, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === DRAFT_ID) return;
|
||||
const draft = drafts[selectedId];
|
||||
const task = visibleTasks.find((item) => taskIdOf(item) === selectedId);
|
||||
setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt);
|
||||
setImages(Array.isArray(draft?.images) ? draft.images : []);
|
||||
}, [defaultPrompt, drafts, selectedId, visibleTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === DRAFT_ID) return;
|
||||
if (visibleTasks.length && (!selectedId || !visibleTasks.some((task) => taskIdOf(task) === selectedId))) setSelectedId(taskIdOf(visibleTasks[0]));
|
||||
if (!visibleTasks.length && selectedId) setSelectedId("");
|
||||
}, [selectedId, visibleTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
const ids = runningIds.split(",").filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const timer = window.setInterval(() => void fetchTasks(ids), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchTasks, runningIds]);
|
||||
|
||||
const appendFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const values = await Promise.all(Array.from(files).map(readFile));
|
||||
setImages((current) => [...current, ...values]);
|
||||
};
|
||||
|
||||
const persistDrafts = (updater: (current: Record<string, EditableFileDraft>) => Record<string, EditableFileDraft>) => {
|
||||
setDrafts((current) => {
|
||||
const next = updater(current);
|
||||
void saveEditableFileDrafts(kind, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const createDraft = () => {
|
||||
setError("");
|
||||
setSelectedId(DRAFT_ID);
|
||||
setPrompt(defaultPrompt);
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
const renameTask = (id: string, title: string) => {
|
||||
const trimmed = title.trim();
|
||||
if (!id || !trimmed) return;
|
||||
persistDrafts((current) => ({ ...current, [id]: { ...(current[id] || {}), title: trimmed } }));
|
||||
};
|
||||
|
||||
const deleteTask = (id: string) => {
|
||||
if (!id) return;
|
||||
const nextDeleted = new Set(deletedIds);
|
||||
nextDeleted.add(id);
|
||||
void saveDeletedEditableFileIds(kind, nextDeleted);
|
||||
setDeletedIds(nextDeleted);
|
||||
setTasks((current) => current.filter((task) => taskIdOf(task) !== id));
|
||||
persistDrafts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
if (selectedId === id) setSelectedId("");
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
const ids = deleteConfirm?.type === "all" ? deleteConfirm.ids : tasks.filter((task) => task.kind === kind).map(taskIdOf).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const nextDeleted = new Set([...deletedIds, ...ids]);
|
||||
void saveDeletedEditableFileIds(kind, nextDeleted);
|
||||
setDeletedIds(nextDeleted);
|
||||
setTasks((current) => current.filter((task) => task.kind !== kind || !ids.includes(taskIdOf(task))));
|
||||
persistDrafts((current) => {
|
||||
const next = { ...current };
|
||||
ids.forEach((id) => delete next[id]);
|
||||
return next;
|
||||
});
|
||||
setSelectedId("");
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
if (deleteConfirm.type === "one") deleteTask(deleteConfirm.id);
|
||||
else clearHistory();
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const base64_images = images;
|
||||
if (imageRequired && !base64_images.length) throw new Error("base64_images is empty");
|
||||
const task = await httpRequest<EditableFileTask>(endpoint, { method: "POST", body: { client_task_id: createClientTaskId(), prompt, base64_images } });
|
||||
const polledAt = Date.now();
|
||||
const id = taskIdOf(task);
|
||||
const draft = { prompt: prompt.trim(), images: base64_images, title: titleOfPrompt(prompt, title) };
|
||||
const nextTask = { ...task, prompt_preview: draft.prompt, polled_at: polledAt };
|
||||
setNow(polledAt);
|
||||
persistDrafts((current) => ({ ...current, [id]: draft }));
|
||||
setTasks((current) => mergeTasks(current.filter((item) => taskIdOf(item) !== taskIdOf(nextTask)), [nextTask]));
|
||||
setSelectedId(id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = () => void fetchTasks();
|
||||
const selectTask = (id: string) => {
|
||||
setRenamingId("");
|
||||
setSelectedId(id);
|
||||
const draft = drafts[id];
|
||||
const task = visibleTasks.find((item) => taskIdOf(item) === id);
|
||||
setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt);
|
||||
setImages(Array.isArray(draft?.images) ? draft.images : []);
|
||||
};
|
||||
const startRename = (id: string) => {
|
||||
setRenamingId(id);
|
||||
setRenamingTitle(drafts[id]?.title || titleOfPrompt(drafts[id]?.prompt || "", `${kind.toUpperCase()} 任务`));
|
||||
};
|
||||
const commitRename = () => {
|
||||
renameTask(renamingId, renamingTitle);
|
||||
setRenamingId("");
|
||||
setRenamingTitle("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-full min-h-[calc(100vh-148px)] overflow-hidden rounded-lg border border-stone-200 bg-white shadow-sm dark:border-white/10 dark:bg-stone-950 lg:grid-cols-[288px_420px_minmax(0,1fr)]">
|
||||
<aside className="flex min-h-0 flex-col border-b border-stone-200 bg-stone-50/70 dark:border-white/10 dark:bg-white/[0.02] lg:border-r lg:border-b-0">
|
||||
<div className="flex h-14 items-center justify-between border-b border-stone-200 px-4 dark:border-white/10">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-stone-950 dark:text-stone-50">
|
||||
<History className="size-4" />
|
||||
历史记录
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={createDraft}>
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={refreshAll} disabled={polling}>
|
||||
{polling ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleteConfirm({ type: "all", ids: tasks.filter((task) => task.kind === kind).map(taskIdOf).filter(Boolean) })} disabled={!visibleTasks.length}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{visibleTasks.length ? visibleTasks.map((task) => {
|
||||
const id = taskIdOf(task);
|
||||
const taskTitle = drafts[id]?.title || titleOfPrompt(task.prompt_preview || drafts[id]?.prompt || "", task.kind?.toUpperCase() || title);
|
||||
return (
|
||||
<div key={id} className={cn("group mb-2 rounded-md border px-3 py-2.5 transition", selectedId === id ? "border-stone-950 bg-white shadow-sm dark:border-white/60 dark:bg-white/10" : "border-transparent bg-transparent hover:border-stone-200 hover:bg-white dark:hover:border-white/10 dark:hover:bg-white/[0.05]")}>
|
||||
<div className="flex items-start gap-2">
|
||||
<button type="button" onClick={() => selectTask(id)} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
{renamingId === id ? (
|
||||
<input
|
||||
value={renamingTitle}
|
||||
onChange={(event) => setRenamingTitle(event.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRename();
|
||||
if (event.key === "Escape") setRenamingId("");
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="min-w-0 flex-1 rounded border border-stone-300 bg-white px-2 py-1 text-sm font-semibold outline-none focus:border-stone-500 dark:border-white/15 dark:bg-stone-950"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate text-sm font-semibold text-stone-950 dark:text-stone-50">{taskTitle}</span>
|
||||
)}
|
||||
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-[11px]", statusClass(task.status))}>{statusText(task.status)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
<Clock3 className="size-3.5" />
|
||||
<span className="tabular-nums">{formatElapsed(elapsedOf(task))}</span>
|
||||
<span className="truncate">{task.created_at || id}</span>
|
||||
</div>
|
||||
{(task.prompt_preview || drafts[id]?.prompt) ? <div className="mt-2 line-clamp-2 text-xs leading-5 text-stone-500 dark:text-stone-400">{task.prompt_preview || drafts[id]?.prompt}</div> : null}
|
||||
</button>
|
||||
<div className="flex shrink-0 opacity-100 sm:opacity-0 sm:transition sm:group-hover:opacity-100">
|
||||
<Button size="sm" variant="ghost" onClick={() => startRename(id)} className="size-7 px-0">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleteConfirm({ type: "one", id })} className="size-7 px-0 text-stone-400 hover:text-rose-500">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-stone-400 dark:text-stone-500">暂无记录</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 flex-col border-b border-stone-200 dark:border-white/10 lg:border-r lg:border-b-0">
|
||||
<div className="flex h-14 items-center justify-between border-b border-stone-200 px-5 dark:border-white/10">
|
||||
<h2 className="text-sm font-semibold text-stone-950 dark:text-stone-50">{title}</h2>
|
||||
<Button size="sm" onClick={() => void submit()} disabled={submitting || running}>
|
||||
{submitting ? <LoaderCircle className="animate-spin" /> : <Play />}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-auto p-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${endpoint}-prompt`} className="text-xs font-semibold text-stone-700 dark:text-stone-300">需求</Label>
|
||||
<Textarea id={`${endpoint}-prompt`} value={prompt} onChange={(event) => setPrompt(event.target.value)} className="min-h-56 rounded-md border-stone-200 bg-white text-sm leading-6 shadow-none dark:border-white/10 dark:bg-white/[0.03]" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-stone-700 dark:text-stone-300">参考图</Label>
|
||||
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-xs text-stone-500 dark:bg-white/10 dark:text-stone-400">{images.length}</span>
|
||||
</div>
|
||||
<label className="group flex h-24 cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-stone-300 bg-stone-50 text-sm font-medium text-stone-600 transition hover:border-stone-950 hover:bg-white dark:border-white/15 dark:bg-white/[0.03] dark:text-stone-300 dark:hover:border-white/50">
|
||||
<ImagePlus className="size-4 transition group-hover:scale-110" />
|
||||
上传图片
|
||||
<Input type="file" accept="image/*" multiple onChange={(event) => void appendFiles(event.target.files)} className="hidden" />
|
||||
</label>
|
||||
{images.length ? (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.slice(0, 8).map((src, index) => (
|
||||
<img key={`${src.slice(0, 28)}-${index}`} src={src} alt="" className="aspect-square rounded-md border border-stone-200 object-cover dark:border-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setImages([])}>
|
||||
<Trash2 />
|
||||
清空图片
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <div className="flex gap-2 rounded-md border border-rose-200 bg-rose-50/70 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300"><AlertCircle className="mt-0.5 size-4 shrink-0" />{error}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col">
|
||||
<div className="flex h-14 items-center justify-between border-b border-stone-200 px-5 dark:border-white/10">
|
||||
<h2 className="text-sm font-semibold text-stone-950 dark:text-stone-50">生成状态</h2>
|
||||
{selectedTask ? <span className={cn("rounded-full border px-2.5 py-1 text-xs", statusClass(selectedTask.status))}>{statusText(selectedTask.status)}</span> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-5">
|
||||
{selectedTask ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-[150px_150px_minmax(0,1fr)]">
|
||||
<div className="rounded-md border border-stone-200 bg-stone-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">状态</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm font-semibold text-stone-950 dark:text-stone-50">
|
||||
{selectedTask.status === "success" ? <CheckCircle2 className="size-4 text-emerald-500" /> : selectedTask.status === "error" ? <XCircle className="size-4 text-rose-500" /> : <LoaderCircle className="size-4 animate-spin text-amber-500" />}
|
||||
{statusText(selectedTask.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-stone-200 bg-stone-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">已执行</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums text-stone-950 dark:text-stone-50">{formatElapsed(elapsedOf(selectedTask))}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-stone-200 bg-stone-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">Task ID</div>
|
||||
<div className="mt-2 truncate font-mono text-xs text-stone-700 dark:text-stone-300">{taskIdOf(selectedTask)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTask.result ? (
|
||||
<div className="space-y-3 rounded-md border border-stone-200 bg-white p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="text-sm font-semibold text-stone-950 dark:text-stone-50">生成结果</div>
|
||||
<ResultFile href={selectedTask.result.primary_url} icon={<FileText className="size-4" />} label={kind === "ppt" ? "PPT 文件" : "PSD 文件"} />
|
||||
<ResultFile href={selectedTask.result.zip_url} icon={<FileArchive className="size-4" />} label="素材包" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedTask.error ? <div className="rounded-md border border-rose-200 bg-rose-50/70 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300">{selectedTask.error}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-80 items-center justify-center text-sm text-stone-400 dark:text-stone-500">暂无任务</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||
<DialogContent className="rounded-xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{deleteConfirm?.type === "all" ? "清空历史记录" : "删除历史记录"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteConfirm?.type === "all" ? "确认清空当前类型的历史记录吗?" : "确认删除这条历史记录吗?"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>确认删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { EditableFilePanel } from "./editable-file-panel";
|
||||
|
||||
const defaultPrompt = "需要制作一份《2026年Q2 电商运营工作汇报》PPT,用于公司管理层季度会议汇报,整体页数控制在 8 页以内,风格偏商务科技感。重点体现销售增长、用户增长、广告投放效果以及618活动成果,并通过折线图、柱状图、环形图、漏斗图呈现。";
|
||||
|
||||
export function PptPanel() {
|
||||
return <EditableFilePanel title="PPT生成" kind="ppt" endpoint="/v1/ppt/generations" defaultPrompt={defaultPrompt} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { EditableFilePanel } from "./editable-file-panel";
|
||||
|
||||
const defaultPrompt = "按原图位置拆分海报元素并合成可编辑 PSD,保留背景和每个元素图层位置,同时输出每个图层素材 zip。";
|
||||
|
||||
export function PsdPanel() {
|
||||
return <EditableFilePanel title="PSD生成" kind="psd" endpoint="/v1/psd/generations" defaultPrompt={defaultPrompt} imageRequired />;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink, Globe2, LoaderCircle, Search } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { httpRequest } from "@/lib/request";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { SearchResult } from "./types";
|
||||
|
||||
const normalizeMarkdown = (text: string) =>
|
||||
text
|
||||
.replace(/\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201/g, "[$1]($2)")
|
||||
.replace(/\ue200cite\ue202[^\ue201]*\ue201/g, "")
|
||||
.replace(/\ue200[^\ue201]*\ue201/g, "")
|
||||
.replace(/\ue200[^\ue201]*$/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const cleanUrl = (url: string) => url.replace(/[\ue200-\ue202].*$/g, "").trim();
|
||||
|
||||
const sourceKind = (url: string) => {
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
return host.includes("github.com") ? "github" : "web";
|
||||
};
|
||||
|
||||
function MarkdownResult({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ className, ...props }) => <a className={cn("font-medium text-blue-700 underline decoration-blue-300 underline-offset-4 hover:text-blue-900 dark:text-blue-300 dark:decoration-blue-700", className)} target="_blank" rel="noreferrer" {...props} />,
|
||||
h1: ({ className, ...props }) => <h1 className={cn("mt-8 mb-4 text-2xl font-semibold tracking-tight text-stone-950 first:mt-0 dark:text-stone-50", className)} {...props} />,
|
||||
h2: ({ className, ...props }) => <h2 className={cn("mt-8 mb-4 border-b border-stone-200 pb-2 text-xl font-semibold tracking-tight text-stone-950 first:mt-0 dark:border-white/10 dark:text-stone-50", className)} {...props} />,
|
||||
h3: ({ className, ...props }) => <h3 className={cn("mt-6 mb-3 text-lg font-semibold text-stone-900 dark:text-stone-100", className)} {...props} />,
|
||||
p: ({ className, ...props }) => <p className={cn("my-4 leading-8 text-stone-800 dark:text-stone-200", className)} {...props} />,
|
||||
ul: ({ className, ...props }) => <ul className={cn("my-4 list-disc space-y-2 pl-6 leading-7 text-stone-800 dark:text-stone-200", className)} {...props} />,
|
||||
ol: ({ className, ...props }) => <ol className={cn("my-4 list-decimal space-y-2 pl-6 leading-7 text-stone-800 dark:text-stone-200", className)} {...props} />,
|
||||
blockquote: ({ className, ...props }) => <blockquote className={cn("my-5 border-l-4 border-stone-300 bg-white/70 py-3 pr-4 pl-5 text-stone-700 dark:border-white/20 dark:bg-white/[0.04] dark:text-stone-300", className)} {...props} />,
|
||||
code: ({ className, ...props }) => <code className={cn("rounded bg-stone-100 px-1.5 py-0.5 font-mono text-[0.9em] text-stone-800 dark:bg-white/10 dark:text-stone-100", className)} {...props} />,
|
||||
pre: ({ className, ...props }) => <pre className={cn("my-5 overflow-x-auto rounded-xl border border-stone-200 bg-stone-950 p-4 text-sm text-stone-50 dark:border-white/10", className)} {...props} />,
|
||||
table: ({ className, ...props }) => <div className="my-5 overflow-x-auto rounded-xl border border-stone-200 dark:border-white/10"><table className={cn("w-full border-collapse text-sm", className)} {...props} /></div>,
|
||||
th: ({ className, ...props }) => <th className={cn("border-b border-stone-200 bg-stone-100 px-3 py-2 text-left font-semibold dark:border-white/10 dark:bg-white/10", className)} {...props} />,
|
||||
td: ({ className, ...props }) => <td className={cn("border-b border-stone-100 px-3 py-2 align-top dark:border-white/10", className)} {...props} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchPanel() {
|
||||
const [prompt, setPrompt] = useState("帮我搜索 chatgpt2api 相关项目");
|
||||
const [result, setResult] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [startedAt, setStartedAt] = useState(0);
|
||||
const searched = loading || !!result || !!error;
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading || !startedAt) return;
|
||||
const timer = window.setInterval(() => setElapsedMs(Date.now() - startedAt), 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loading, startedAt]);
|
||||
|
||||
const runSearch = async () => {
|
||||
const value = prompt.trim();
|
||||
if (!value || loading) return;
|
||||
const start = Date.now();
|
||||
setStartedAt(start);
|
||||
setElapsedMs(0);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await httpRequest<SearchResult>("/v1/search", { method: "POST", body: { prompt: value } }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setElapsedMs(Date.now() - start);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cn("mx-auto flex min-h-[calc(100vh-142px)] w-full max-w-6xl flex-col px-4 transition-all", searched ? "py-5" : "justify-center")}>
|
||||
<div className={cn("mx-auto w-full max-w-3xl", searched && "sticky top-3 z-10")}>
|
||||
{!searched ? (
|
||||
<p className="mb-5 text-center text-sm text-stone-500 dark:text-stone-400">利用ChatGPT先进的网页搜索功能进行搜索</p>
|
||||
) : null}
|
||||
<form
|
||||
className={cn("mx-auto flex w-full items-center gap-3 rounded-full border border-stone-200 bg-white/95 backdrop-blur transition-all dark:border-white/10 dark:bg-stone-950/90", searched ? "px-4 py-2" : "px-5 py-3")}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void runSearch();
|
||||
}}
|
||||
>
|
||||
<img src="/openai.svg" alt="" aria-hidden="true" className="size-5 shrink-0 opacity-80 dark:invert" />
|
||||
<input
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="搜索网页"
|
||||
className={cn("min-w-0 flex-1 bg-transparent text-[15px] text-stone-900 outline-none placeholder:text-stone-400 dark:text-stone-100 dark:placeholder:text-stone-500", searched ? "h-8" : "h-10")}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !prompt.trim()} className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-stone-800 transition hover:bg-stone-100 disabled:cursor-not-allowed disabled:text-stone-300 dark:text-stone-100 dark:hover:bg-white/10 dark:disabled:text-stone-600">
|
||||
{loading ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{searched ? <div className="mx-auto w-full max-w-6xl flex-1 pt-6">
|
||||
{loading ? (
|
||||
<div className="mx-auto flex max-w-3xl items-center gap-3 rounded-2xl border border-stone-200 bg-white/75 px-4 py-3 text-sm text-stone-600 dark:border-white/10 dark:bg-white/[0.03] dark:text-stone-300">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
搜索中... {(elapsedMs / 1000).toFixed(1)}s
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/25 dark:text-rose-300">{error}</div> : null}
|
||||
|
||||
{result ? (
|
||||
<article className="grid gap-8 pb-12 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
<span className="rounded-full border border-stone-200 bg-white px-3 py-1 dark:border-white/10 dark:bg-white/[0.03]">{result.status || "done"}</span>
|
||||
<span className="rounded-full border border-stone-200 bg-white px-3 py-1 dark:border-white/10 dark:bg-white/[0.03]">{(elapsedMs / 1000).toFixed(2)}s</span>
|
||||
<span className="rounded-full border border-stone-200 bg-white px-3 py-1 dark:border-white/10 dark:bg-white/[0.03]">{result.sources?.length || 0} sources</span>
|
||||
</div>
|
||||
<div className="text-[15px]">
|
||||
<MarkdownResult content={normalizeMarkdown(result.answer || "")} />
|
||||
</div>
|
||||
</div>
|
||||
{result.sources?.length ? (
|
||||
<aside className="lg:sticky lg:top-24 lg:self-start">
|
||||
<div className="mb-3 text-sm font-semibold text-stone-900 dark:text-stone-100">来源</div>
|
||||
<div className="divide-y divide-stone-200 dark:divide-white/10">
|
||||
{result.sources.map((source, index) => {
|
||||
const url = cleanUrl(source.url || "");
|
||||
const kind = sourceKind(url);
|
||||
return (
|
||||
<a key={`${url || index}`} href={url} target="_blank" rel="noreferrer" className="flex gap-3 py-3 text-xs transition hover:text-stone-950 dark:hover:text-stone-50">
|
||||
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center text-stone-600 dark:text-stone-300">
|
||||
{kind === "github" ? <img src="/github.svg" alt="" aria-hidden="true" className="size-3.5 dark:invert" /> : <Globe2 className="size-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="line-clamp-2 font-medium leading-5 text-stone-800 dark:text-stone-200">{source.title || url || "source"}</span>
|
||||
<span className="mt-1 flex items-center gap-1 truncate text-stone-500 dark:text-stone-400">
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
{url}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { fetchSettingsConfig } from "@/lib/api";
|
||||
import { getStoredAuthSession } from "@/store/auth";
|
||||
|
||||
export function SkillPanel() {
|
||||
const [browserBaseUrl, setBrowserBaseUrl] = useState("");
|
||||
const [configuredBaseUrl, setConfiguredBaseUrl] = useState("");
|
||||
const [authKey, setAuthKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserBaseUrl(window.location.origin);
|
||||
void fetchSettingsConfig().then((data) => setConfiguredBaseUrl(String(data.config.base_url || "").replace(/\/$/, ""))).catch(() => undefined);
|
||||
void getStoredAuthSession().then((session) => setAuthKey(session?.key || ""));
|
||||
}, []);
|
||||
|
||||
const apiBaseUrl = configuredBaseUrl || webConfig.apiUrl.replace(/\/$/, "") || browserBaseUrl;
|
||||
const skillZh = useMemo(() => `---
|
||||
name: chatgpt2api-search
|
||||
description: 当用户需要联网搜索、查询最新信息、核实事实或需要来源链接时,调用本地 chatgpt2api 搜索接口。
|
||||
---
|
||||
|
||||
# ChatGPT2API 搜索
|
||||
|
||||
当用户要求联网搜索、查询最新信息、核实资料、查新闻、查价格、查文档更新或需要来源链接时,使用这个 skill。
|
||||
|
||||
## 接口
|
||||
|
||||
POST ${apiBaseUrl}/v1/search
|
||||
|
||||
Headers:
|
||||
|
||||
Authorization: Bearer ${authKey}
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
|
||||
{
|
||||
"prompt": "<用户要搜索的问题>"
|
||||
}
|
||||
|
||||
## 返回处理
|
||||
|
||||
- 使用接口返回的 \`answer\` 作为主要回答。
|
||||
- 如果有 \`sources\`,在回答里附上来源链接。
|
||||
- 如果接口报错,简要说明错误并询问是否重试。`, [apiBaseUrl, authKey]);
|
||||
|
||||
const skillEn = useMemo(() => `---
|
||||
name: chatgpt2api-search
|
||||
description: Use when current web search is needed through this chatgpt2api server. Call the configured HTTP search endpoint with a prompt and return the answer with source URLs.
|
||||
---
|
||||
|
||||
# ChatGPT2API Search
|
||||
|
||||
Use this skill when the user asks for current web search, online lookup, recent information, or source-backed answers. It calls the local chatgpt2api search endpoint and returns an answer with source links.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to search the web, look something up, verify current information, or find the latest status.
|
||||
- The answer needs source URLs, recent details, prices, releases, docs, laws, schedules, or news.
|
||||
- Do not use it for purely local codebase questions unless the user explicitly asks for web search.
|
||||
|
||||
## Request
|
||||
|
||||
POST ${apiBaseUrl}/v1/search
|
||||
|
||||
Headers:
|
||||
|
||||
Authorization: Bearer ${authKey}
|
||||
Content-Type: application/json
|
||||
|
||||
JSON body:
|
||||
|
||||
{
|
||||
"prompt": "<search question>"
|
||||
}
|
||||
|
||||
## Response handling
|
||||
|
||||
- Use \`answer\` as the main response.
|
||||
- Include source URLs from \`sources\` when available.
|
||||
- If the endpoint returns an error, summarize the error and ask the user whether to retry.
|
||||
- Keep the final answer concise unless the user asks for detail.`, [apiBaseUrl, authKey]);
|
||||
|
||||
const zhPrompt = useMemo(() => `请帮我在本机安装一个用于联网搜索的 skill。
|
||||
|
||||
要求:
|
||||
1. 请按你当前环境的 skill 安装规范,把它安装成本地 skill。
|
||||
2. skill 名称为:chatgpt2api-search
|
||||
3. 文件名为:SKILL.md
|
||||
4. 如果你无法确定本地 skills 目录在哪里,先告诉我需要放到哪个目录,不要猜路径。
|
||||
5. 只创建或更新这个 skill 文件,不要修改其他无关文件。
|
||||
6. SKILL.md 请写入下面的完整内容。
|
||||
|
||||
SKILL.md 内容:
|
||||
|
||||
\`\`\`markdown
|
||||
${skillZh}
|
||||
\`\`\``, [skillZh]);
|
||||
|
||||
const enPrompt = useMemo(() => `Please install a local web-search skill on this machine.
|
||||
|
||||
Requirements:
|
||||
1. Install this as a local skill according to the skill installation rules of your current environment.
|
||||
2. Skill name: chatgpt2api-search
|
||||
3. File name: SKILL.md
|
||||
4. If you cannot determine the local skills directory, tell me which directory is required before writing files.
|
||||
5. Only create or update this skill file. Do not modify unrelated files.
|
||||
6. Write the full content below into SKILL.md.
|
||||
|
||||
SKILL.md content:
|
||||
|
||||
\`\`\`markdown
|
||||
${skillEn}
|
||||
\`\`\``, [skillEn]);
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("已复制");
|
||||
};
|
||||
|
||||
const downloadSkill = (text: string) => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "SKILL.md";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const versions = [
|
||||
{ title: "中文安装指令", desc: "复制后直接发给 Codex 或 Claude,让它安装到本地。", prompt: zhPrompt, skill: skillZh },
|
||||
{ title: "English install prompt", desc: "Copy and send this to Codex or Claude to install locally.", prompt: enPrompt, skill: skillEn },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid items-stretch gap-4 lg:grid-cols-2">
|
||||
{versions.map((item) => (
|
||||
<div key={item.title} className="flex flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-200/70 bg-slate-50/80 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<h2 className="font-medium text-slate-900 dark:text-slate-100">{item.title}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{item.desc}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="cursor-pointer" onClick={() => downloadSkill(item.skill)}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
<Button size="sm" className="cursor-pointer" onClick={() => void copyText(item.prompt)}>
|
||||
<Copy />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="flex-1 whitespace-pre-wrap p-4 font-mono text-sm leading-6">
|
||||
{item.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type SearchResult = {
|
||||
conversation_id: string;
|
||||
status: string;
|
||||
answer: string;
|
||||
sources: Array<{ title?: string; url?: string; snippet?: string; source_type?: string }>;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | ChatContentPart[];
|
||||
};
|
||||
|
||||
export type ChatContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } };
|
||||
|
||||
export type ChatCompletionResponse = {
|
||||
choices?: Array<{ message?: { role?: string; content?: string } }>;
|
||||
};
|
||||
|
||||
export type EditableFileTask = {
|
||||
id: string;
|
||||
taskId?: string;
|
||||
status: "queued" | "running" | "success" | "error" | string;
|
||||
kind: "ppt" | "psd" | string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
elapsed_seconds?: number;
|
||||
polled_at?: number;
|
||||
prompt_preview?: string;
|
||||
error?: string;
|
||||
result?: {
|
||||
conversation_id?: string;
|
||||
primary_url?: string;
|
||||
zip_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const pretty = (value: unknown) => JSON.stringify(value, null, 2);
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuthGuard } from "@/lib/use-auth-guard";
|
||||
|
||||
import { ChatPanel } from "./components/chat-panel";
|
||||
import { PptPanel } from "./components/ppt-panel";
|
||||
import { PsdPanel } from "./components/psd-panel";
|
||||
import { SearchPanel } from "./components/search-panel";
|
||||
import { SkillPanel } from "./components/skill-panel";
|
||||
|
||||
const tabs = [
|
||||
{ value: "skills", title: "搜索Skills" },
|
||||
{ value: "search", title: "搜索" },
|
||||
{ value: "ppt", title: "PPT生成" },
|
||||
{ value: "psd", title: "PSD生成" },
|
||||
{ value: "chat", title: "对话" },
|
||||
];
|
||||
|
||||
export default function DebugPage() {
|
||||
const { isCheckingAuth, session } = useAuthGuard(["admin"]);
|
||||
|
||||
if (isCheckingAuth || !session || session.role !== "admin") {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-49px)] items-center justify-center">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="skills" className="mx-auto flex min-h-[calc(100vh-49px)] w-full max-w-[1600px] flex-col gap-4 px-4 pt-3 pb-6 md:px-8">
|
||||
<TabsList variant="line" className="w-full">
|
||||
{tabs.map(({ value, title }) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="skills">
|
||||
<SkillPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="search" className="min-h-0">
|
||||
<SearchPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="ppt" className="min-h-0">
|
||||
<PptPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="psd" className="min-h-0">
|
||||
<PsdPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="chat" className="min-h-0">
|
||||
<ChatPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,231 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), rgba(245, 239, 231, 0.96) 42%, rgba(240, 235, 227, 0.99) 100%);
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.dark,
|
||||
.dark body {
|
||||
background: radial-gradient(circle at top left, rgba(55, 48, 43, 0.72), rgba(28, 25, 23, 0.98) 40%, rgba(12, 10, 9, 1) 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .bg-white,
|
||||
.dark .bg-white\/85,
|
||||
.dark .bg-white\/90,
|
||||
.dark .bg-white\/95,
|
||||
.dark .hover\:bg-white:hover {
|
||||
background-color: rgb(28 25 23 / 0.94) !important;
|
||||
}
|
||||
|
||||
.dark .bg-white\/80,
|
||||
.dark .bg-white\/70,
|
||||
.dark .bg-white\/60 {
|
||||
background-color: rgb(41 37 36 / 0.78) !important;
|
||||
}
|
||||
|
||||
.dark .bg-stone-50,
|
||||
.dark .bg-stone-50\/70,
|
||||
.dark .hover\:bg-stone-50\/70:hover,
|
||||
.dark .bg-stone-100,
|
||||
.dark .hover\:bg-stone-100:hover,
|
||||
.dark .hover\:bg-stone-200:hover {
|
||||
background-color: rgb(41 37 36 / 0.86) !important;
|
||||
}
|
||||
|
||||
.dark .bg-stone-200 {
|
||||
background-color: rgb(68 64 60 / 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .border-white,
|
||||
.dark .border-white\/80,
|
||||
.dark .border-white\/70,
|
||||
.dark .border-stone-100,
|
||||
.dark .border-stone-100\/80,
|
||||
.dark .border-stone-100\/50,
|
||||
.dark .border-stone-200,
|
||||
.dark .border-stone-200\/70,
|
||||
.dark .border-stone-300,
|
||||
.dark .focus-visible\:border-stone-300:focus-visible {
|
||||
border-color: rgb(255 255 255 / 0.11) !important;
|
||||
}
|
||||
|
||||
.dark .text-stone-950,
|
||||
.dark .text-stone-900,
|
||||
.dark .text-stone-800 {
|
||||
color: rgb(250 250 249) !important;
|
||||
}
|
||||
|
||||
.dark .text-stone-700,
|
||||
.dark .text-stone-600 {
|
||||
color: rgb(214 211 209) !important;
|
||||
}
|
||||
|
||||
.dark .text-stone-500,
|
||||
.dark .text-stone-400 {
|
||||
color: rgb(168 162 158) !important;
|
||||
}
|
||||
|
||||
.dark .text-stone-300 {
|
||||
color: rgb(214 211 209) !important;
|
||||
}
|
||||
|
||||
.dark .placeholder\:text-stone-400::placeholder,
|
||||
.dark .placeholder\:text-muted-foreground::placeholder {
|
||||
color: rgb(120 113 108) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-stone-700:hover,
|
||||
.dark .hover\:text-stone-800:hover,
|
||||
.dark .hover\:text-stone-900:hover {
|
||||
color: rgb(250 250 249) !important;
|
||||
}
|
||||
|
||||
.dark .shadow-sm,
|
||||
.dark .shadow-xs,
|
||||
.dark [class*="shadow-"] {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@keyframes grow {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Copy, Download, ImageIcon, LoaderCircle, Maximize2, Plus, RefreshCw, Search, Tag, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DateRangeFilter } from "@/components/date-range-filter";
|
||||
import { ImageLightbox } from "@/components/image-lightbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { compressAllImages, deleteImageTag, deleteManagedImages, deleteToTarget, downloadImages, downloadSingleImage, fetchImageStorage, fetchImageTags, fetchManagedImages, setImageTags, type ImageStorageStats, type ManagedImage } from "@/lib/api";
|
||||
import { useAuthGuard } from "@/lib/use-auth-guard";
|
||||
|
||||
const LONG_PRESS_MS = 800;
|
||||
const IMAGE_MANAGER_CHECKBOX_CLASS = "border-stone-300 bg-white/80 dark:border-white/35 dark:bg-white/5 data-[state=checked]:border-stone-950 dark:data-[state=checked]:border-white";
|
||||
|
||||
function formatSize(size: number) {
|
||||
return size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(2)} MB` : `${Math.ceil(size / 1024)} KB`;
|
||||
}
|
||||
|
||||
function imageKey(item: ManagedImage) {
|
||||
return item.rel || item.url;
|
||||
}
|
||||
|
||||
function useLongPress(onLongPress: () => void, ms = LONG_PRESS_MS) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeRef = useRef(false);
|
||||
|
||||
const start = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
activeRef.current = true;
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (activeRef.current) {
|
||||
onLongPress();
|
||||
}
|
||||
}, ms);
|
||||
}, [onLongPress, ms]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
activeRef.current = false;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onMouseDown: start,
|
||||
onMouseUp: stop,
|
||||
onMouseLeave: stop,
|
||||
onTouchStart: start,
|
||||
onTouchEnd: stop,
|
||||
};
|
||||
}
|
||||
|
||||
function ImageManagerContent() {
|
||||
const [items, setItems] = useState<ManagedImage[]>([]);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deleteStartDate, setDeleteStartDate] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedImage | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [allTags, setAllTags] = useState<string[]>([]);
|
||||
const [storage, setStorage] = useState<ImageStorageStats | null>(null);
|
||||
const [storageLoading, setStorageLoading] = useState(false);
|
||||
const [compressResult, setCompressResult] = useState<string>("");
|
||||
const [targetFreeMb, setTargetFreeMb] = useState(500);
|
||||
|
||||
const loadStorage = useCallback(async () => {
|
||||
try {
|
||||
setStorageLoading(true);
|
||||
const data = await fetchImageStorage();
|
||||
setStorage(data);
|
||||
} catch { /* ignore */ }
|
||||
finally { setStorageLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadStorage(); }, [loadStorage]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tagEditTarget, setTagEditTarget] = useState<ManagedImage | null>(null);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const deleteTargetRef = useRef<ManagedImage | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
||||
const [deleteMode, setDeleteMode] = useState<"selected" | "filtered" | "byDate" | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const filteredItems = selectedTags.length > 0
|
||||
? items.filter((item) => selectedTags.every((t) => (item.tags ?? []).includes(t)))
|
||||
: items;
|
||||
|
||||
const lightboxImages = filteredItems.map((item) => ({
|
||||
id: item.name,
|
||||
src: item.url,
|
||||
sizeLabel: formatSize(item.size),
|
||||
dimensions: item.width && item.height ? `${item.width} x ${item.height}` : undefined,
|
||||
}));
|
||||
const pageSize = 12;
|
||||
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const currentRows = filteredItems.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const selectedSet = useMemo(() => new Set(selectedPaths), [selectedPaths]);
|
||||
const selectedCount = deleteMode === "filtered" ? items.length : deleteMode === "byDate" ? 0 : selectedPaths.length;
|
||||
const currentPageSelected = currentRows.length > 0 && currentRows.every((item) => selectedSet.has(imageKey(item)));
|
||||
const allSelected = filteredItems.length > 0 && filteredItems.every((item) => selectedSet.has(imageKey(item)));
|
||||
|
||||
const loadImages = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [data, tagsData] = await Promise.all([
|
||||
fetchManagedImages({ start_date: startDate, end_date: endDate }),
|
||||
fetchImageTags(),
|
||||
]);
|
||||
setItems(data.items);
|
||||
setAllTags(tagsData.tags);
|
||||
setSelectedPaths((current) => current.filter((path) => data.items.some((item) => imageKey(item) === path)));
|
||||
setPage(1);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载图片失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogVisible(false);
|
||||
setTimeout(() => setDeleteTarget(null), 200);
|
||||
}, []);
|
||||
|
||||
const openDeleteDialog = useCallback((item: ManagedImage) => {
|
||||
deleteTargetRef.current = item;
|
||||
setDeleteTarget(item);
|
||||
setDialogVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteManagedImages({ paths: [deleteTarget.rel] });
|
||||
setItems((prev) => prev.filter((item) => item.rel !== deleteTarget.rel));
|
||||
setSelectedPaths((prev) => prev.filter((p) => p !== imageKey(deleteTarget)));
|
||||
toast.success("图片已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
closeDialog();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetTags = async (item: ManagedImage, tags: string[]) => {
|
||||
try {
|
||||
const result = await setImageTags(item.rel, tags);
|
||||
setItems((prev) => prev.map((i) => i.rel === item.rel ? { ...i, tags: result.tags } : i));
|
||||
const tagsData = await fetchImageTags();
|
||||
setAllTags(tagsData.tags);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "设置标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = (item: ManagedImage) => {
|
||||
const tag = tagInput.trim();
|
||||
if (!tag) return;
|
||||
const current = item.tags ?? [];
|
||||
if (current.includes(tag)) {
|
||||
toast.error("标签已存在");
|
||||
return;
|
||||
}
|
||||
void handleSetTags(item, [...current, tag]);
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const handleRemoveTag = (item: ManagedImage, tag: string) => {
|
||||
void handleSetTags(item, (item.tags ?? []).filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const toggleFilterTag = (tag: string) => {
|
||||
setSelectedTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const [pressingTag, setPressingTag] = useState<string | null>(null);
|
||||
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [tagDeleteTarget, setTagDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteTag = async (tag: string) => {
|
||||
try {
|
||||
const result = await deleteImageTag(tag);
|
||||
setAllTags((prev) => prev.filter((t) => t !== tag));
|
||||
setSelectedTags((prev) => prev.filter((t) => t !== tag));
|
||||
setItems((prev) => prev.map((item) => ({
|
||||
...item,
|
||||
tags: (item.tags ?? []).filter((t) => t !== tag),
|
||||
})));
|
||||
toast.success(`标签"${tag}"已删除,影响 ${result.removed_from} 张图片`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const startTagPress = useCallback((tag: string) => {
|
||||
setPressingTag(tag);
|
||||
pressTimerRef.current = setTimeout(() => {
|
||||
setPressingTag(null);
|
||||
setTagDeleteTarget(tag);
|
||||
}, LONG_PRESS_MS);
|
||||
}, []);
|
||||
|
||||
const stopTagPress = useCallback(() => {
|
||||
setPressingTag(null);
|
||||
if (pressTimerRef.current) {
|
||||
clearTimeout(pressTimerRef.current);
|
||||
pressTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearFilters = () => {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
const togglePaths = (paths: string[], checked: boolean) => {
|
||||
setSelectedPaths((current) => checked ? Array.from(new Set([...current, ...paths])) : current.filter((path) => !paths.includes(path)));
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteMode || selectedCount === 0) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const data = await deleteManagedImages(deleteMode === "filtered" ? { start_date: startDate, end_date: endDate, all_matching: true } : { paths: selectedPaths });
|
||||
toast.success(`已删除 ${data.removed} 张图片`);
|
||||
setDeleteMode(null);
|
||||
setSelectedPaths([]);
|
||||
await loadImages();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除图片失败");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const paths = deleteMode === "filtered" ? items.map((item) => item.rel) : selectedPaths;
|
||||
if (paths.length === 0) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadImages(paths);
|
||||
toast.success(`已下载 ${paths.length} 张图片`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleDownload = async (item: ManagedImage) => {
|
||||
await downloadSingleImage(item.rel);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadImages();
|
||||
}, [startDate, endDate]);
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Images</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">图片管理</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DateRangeFilter startDate={startDate} endDate={endDate} onChange={(start, end) => { setStartDate(start); setEndDate(end); }} />
|
||||
<Button variant="outline" onClick={clearFilters} className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700">
|
||||
清除筛选条件
|
||||
</Button>
|
||||
<Button onClick={() => void loadImages()} disabled={isLoading} className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800">
|
||||
{isLoading ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
|
||||
查询
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setDeleteMode("filtered")} disabled={isDeleting || items.length === 0 || (!startDate && !endDate)} className="h-10 rounded-xl border-rose-200 bg-white px-4 text-rose-600 hover:bg-rose-50">
|
||||
<Trash2 className="size-4" />
|
||||
删除匹配日期
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-stone-500">
|
||||
<Tag className="mr-1 inline size-3.5" />
|
||||
标签筛选:
|
||||
</span>
|
||||
{allTags.map((tag) => {
|
||||
const isPressing = pressingTag === tag;
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="relative inline-flex items-center"
|
||||
onMouseDown={() => startTagPress(tag)}
|
||||
onMouseUp={stopTagPress}
|
||||
onMouseLeave={stopTagPress}
|
||||
onTouchStart={() => startTagPress(tag)}
|
||||
onTouchEnd={stopTagPress}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFilterTag(tag)}
|
||||
>
|
||||
<Badge
|
||||
variant={selectedTags.includes(tag) ? "default" : "outline"}
|
||||
className={`cursor-pointer rounded-md transition-all hover:opacity-80 ${isPressing ? "ring-2 ring-red-400 ring-offset-1" : ""}`}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
</button>
|
||||
{isPressing ? (
|
||||
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-md">
|
||||
<span className="absolute inset-0 animate-[grow_800ms_linear_forwards] rounded-md bg-red-400/20" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{selectedTags.length > 0 ? (
|
||||
<button type="button" onClick={() => setSelectedTags([])}>
|
||||
<Badge variant="secondary" className="cursor-pointer rounded-md">
|
||||
<X className="mr-0.5 size-3" />
|
||||
清除
|
||||
</Badge>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Storage Stats Panel */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3 mb-4">
|
||||
{storage ? (
|
||||
<>
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3">
|
||||
<div className="text-xs text-stone-500">磁盘总量</div>
|
||||
<div className="text-lg font-bold text-stone-800">{storage.disk_total_mb >= 1024 ? `${(storage.disk_total_mb / 1024).toFixed(1)} GB` : `${storage.disk_total_mb} MB`}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3">
|
||||
<div className="text-xs text-stone-500">剩余空间</div>
|
||||
<div className={`text-lg font-bold ${storage.disk_free_mb < 200 ? "text-red-500" : storage.disk_free_mb < 500 ? "text-yellow-500" : "text-green-600"}`}>{storage.disk_free_mb >= 1024 ? `${(storage.disk_free_mb / 1024).toFixed(1)} GB` : `${storage.disk_free_mb} MB`}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3">
|
||||
<div className="text-xs text-stone-500">图片数量</div>
|
||||
<div className="text-lg font-bold text-stone-800">{storage.image_count}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3">
|
||||
<div className="text-xs text-stone-500">图片占用</div>
|
||||
<div className="text-lg font-bold text-stone-800">{storage.image_size_mb >= 1024 ? `${(storage.image_size_mb / 1024).toFixed(1)} GB` : `${storage.image_size_mb} MB`}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3 col-span-2 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-stone-500 w-full">快捷操作</span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" disabled={storageLoading} onClick={() => { void loadStorage(); }}>
|
||||
<RefreshCw className={`size-3 mr-1 ${storageLoading ? "animate-spin" : ""}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={async () => {
|
||||
try { const r = await compressAllImages(); setCompressResult(`已压缩${r.saved_mb}MB`); void loadStorage(); }
|
||||
catch { setCompressResult("压缩失败"); }
|
||||
}}>
|
||||
🗜️ 压缩优化
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs border-rose-200 text-rose-600"
|
||||
onClick={() => setDeleteMode("byDate")}>
|
||||
🗑️ 按日期删除
|
||||
</Button>
|
||||
<form onSubmit={async (e) => { e.preventDefault();
|
||||
try {
|
||||
const r = await deleteToTarget(targetFreeMb);
|
||||
toast.success(`已删除 ${r.removed} 张图片,释放 ${r.freed_mb ?? 0}MB`);
|
||||
void loadStorage();
|
||||
} catch { toast.error("清理失败"); }
|
||||
}} className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs border-amber-200 text-amber-700" type="submit">
|
||||
🧹 清理至
|
||||
</Button>
|
||||
<Input className="h-7 w-14 text-xs text-center px-1" type="number" min={50} value={targetFreeMb}
|
||||
onChange={(e) => setTargetFreeMb(Number(e.target.value) || 500)} />
|
||||
<span className="text-xs text-stone-400">MB 剩余</span>
|
||||
</form>
|
||||
{compressResult ? <span className="text-xs text-green-600 ml-1">{compressResult}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-stone-200 bg-white/80 p-3 col-span-full text-center text-sm text-stone-400">
|
||||
{storageLoading ? "加载存储信息..." : "存储信息加载失败"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete by date dialog */}
|
||||
<Dialog open={deleteMode === "byDate"} onOpenChange={() => setDeleteMode(null)}>
|
||||
<DialogContent className="sm:max-w-md rounded-2xl">
|
||||
<DialogHeader><DialogTitle>按日期删除图片</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-stone-600 shrink-0">删除</label>
|
||||
<Input className="h-9 text-sm" type="date" value={deleteStartDate} onChange={(e) => setDeleteStartDate(e.target.value)} />
|
||||
<span className="text-sm text-stone-400">之前的图片</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">此操作不可撤销,将永久删除所有匹配日期的图片及其缩略图。</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteMode(null)}>取消</Button>
|
||||
<Button variant="destructive" disabled={!deleteStartDate || isDeleting}
|
||||
onClick={async () => {
|
||||
if (!deleteStartDate) return;
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const r = await deleteManagedImages({ end_date: deleteStartDate, all_matching: true });
|
||||
toast.success(`已删除 ${r.removed} 张图片`);
|
||||
setDeleteMode(null);
|
||||
void loadStorage();
|
||||
void loadImages();
|
||||
} catch { toast.error("删除失败"); }
|
||||
finally { setIsDeleting(false); }
|
||||
}}>
|
||||
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-100 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-stone-600">
|
||||
<ImageIcon className="size-4" />
|
||||
共 {filteredItems.length} 张
|
||||
{selectedTags.length > 0 ? <span className="text-stone-400">(筛选自 {items.length} 张)</span> : null}
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox className={IMAGE_MANAGER_CHECKBOX_CLASS} checked={currentPageSelected} onCheckedChange={(checked) => togglePaths(currentRows.map(imageKey), Boolean(checked))} />
|
||||
本页全选
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox className={IMAGE_MANAGER_CHECKBOX_CLASS} checked={allSelected} onCheckedChange={(checked) => togglePaths(filteredItems.map(imageKey), Boolean(checked))} />
|
||||
全选结果
|
||||
</label>
|
||||
{selectedPaths.length > 0 ? <span>已选 {selectedPaths.length} 张</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-500" onClick={() => void loadImages()} disabled={isLoading}>
|
||||
<RefreshCw className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<button type="button" className="text-sm text-stone-500 hover:text-stone-900 disabled:text-stone-300" onClick={() => setSelectedPaths([])} disabled={selectedPaths.length === 0 || isDeleting}>
|
||||
取消选择
|
||||
</button>
|
||||
<Button variant="outline" className="h-8 rounded-lg border-stone-200 bg-white px-3 text-stone-600 hover:bg-stone-50" onClick={() => void handleBatchDownload()} disabled={selectedPaths.length === 0 || isDownloading || isDeleting}>
|
||||
{isDownloading ? <LoaderCircle className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
下载所选
|
||||
</Button>
|
||||
<Button variant="outline" className="h-8 rounded-lg border-rose-200 bg-white px-3 text-rose-600 hover:bg-rose-50" onClick={() => setDeleteMode("selected")} disabled={selectedPaths.length === 0 || isDeleting}>
|
||||
<Trash2 className="size-4" />
|
||||
删除所选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{currentRows.map((item) => {
|
||||
const imageIndex = filteredItems.findIndex((row) => row.url === item.url);
|
||||
return (
|
||||
<div key={item.rel} className="group border-r border-b border-stone-100 p-4 transition hover:bg-stone-50 dark:hover:bg-white/5">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="relative block aspect-square w-full cursor-zoom-in overflow-hidden rounded-lg bg-stone-100 text-left"
|
||||
onClick={() => {
|
||||
setLightboxIndex(imageIndex);
|
||||
setLightboxOpen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.thumbnail_url || item.url}
|
||||
alt={item.name}
|
||||
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== item.url) {
|
||||
event.currentTarget.src = item.url;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-full bg-black/50 p-2 text-white opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<Maximize2 className="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-full bg-black/50 text-white opacity-100 transition hover:bg-red-600 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
title="删除图片"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs text-stone-500">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 font-medium text-stone-700">
|
||||
<CalendarDays className="size-3.5" />
|
||||
{item.created_at}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg text-stone-400 hover:bg-stone-100 hover:text-stone-700"
|
||||
onClick={() => void handleSingleDownload(item)}
|
||||
title="下载图片"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg text-stone-400 hover:bg-stone-100 hover:text-stone-700"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(item.url);
|
||||
toast.success("图片地址已复制");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
<Checkbox className={IMAGE_MANAGER_CHECKBOX_CLASS} checked={selectedSet.has(imageKey(item))} onCheckedChange={(checked) => togglePaths([imageKey(item)], Boolean(checked))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{formatSize(item.size)}</span>
|
||||
<span>{item.width && item.height ? `${item.width} x ${item.height}` : "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{(item.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-0.5 rounded-md py-0 pr-0.5 text-[10px]">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-3.5 items-center justify-center rounded-full hover:bg-stone-300"
|
||||
onClick={() => handleRemoveTag(item, tag)}
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Popover open={tagEditTarget?.rel === item.rel} onOpenChange={(open) => { setTagEditTarget(open ? item : null); setTagInput(""); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-5 items-center justify-center rounded-full border border-dashed border-stone-300 text-stone-400 hover:border-stone-500 hover:text-stone-600"
|
||||
title="添加标签"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-stone-500">添加标签</div>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签名"
|
||||
className="h-8 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddTag(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => handleAddTag(item)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{allTags.filter((t) => !(item.tags ?? []).includes(t)).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 border-t border-stone-100 pt-2">
|
||||
{allTags.filter((t) => !(item.tags ?? []).includes(t)).map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleSetTags(item, [...(item.tags ?? []), tag]);
|
||||
setTagEditTarget(null);
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="cursor-pointer rounded-md text-[10px] hover:bg-stone-100">
|
||||
{tag}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 text-sm text-stone-500">
|
||||
<span>第 {safePage} / {pageCount} 页,共 {filteredItems.length} 张</span>
|
||||
<Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoading && filteredItems.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到图片</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogVisible} onOpenChange={(open) => { if (!open) closeDialog(); }}>
|
||||
<DialogContent className="max-w-sm overflow-hidden rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-8">确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-stone-600">
|
||||
确定要删除这张图片吗?此操作不可恢复。
|
||||
</p>
|
||||
{deleteTarget ? (
|
||||
<div className="flex items-center gap-3 overflow-hidden rounded-xl border border-stone-200 bg-stone-50 p-3">
|
||||
<img
|
||||
src={deleteTarget.thumbnail_url || deleteTarget.url}
|
||||
alt=""
|
||||
className="size-16 shrink-0 rounded-lg object-cover"
|
||||
onError={(e) => { if (e.currentTarget.src !== deleteTarget.url) e.currentTarget.src = deleteTarget.url; }}
|
||||
/>
|
||||
<div className="min-w-0 overflow-hidden text-xs text-stone-500">
|
||||
<div className="truncate font-medium text-stone-700">{deleteTarget.name}</div>
|
||||
<div className="truncate">{deleteTarget.created_at}</div>
|
||||
<div>{formatSize(deleteTarget.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog} className="rounded-xl">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleDelete()} disabled={isDeleting} className="rounded-xl">
|
||||
{isDeleting ? <LoaderCircle className="mr-1 size-4 animate-spin" /> : <Trash2 className="mr-1 size-4" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImageLightbox
|
||||
images={lightboxImages}
|
||||
currentIndex={lightboxIndex}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
<Dialog open={deleteMode === "selected" || deleteMode === "filtered"} onOpenChange={(open) => (!open ? setDeleteMode(null) : null)}>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>{deleteMode === "filtered" ? "删除匹配日期的图片" : "删除所选图片"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-stone-600">
|
||||
确认删除 {selectedCount} 张图片吗?删除后无法恢复。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl" onClick={() => setDeleteMode(null)} disabled={isDeleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="rounded-xl bg-rose-600 text-white hover:bg-rose-700" onClick={() => void confirmDelete()} disabled={isDeleting || selectedCount === 0}>
|
||||
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(tagDeleteTarget)} onOpenChange={(open) => { if (!open) setTagDeleteTarget(null); }}>
|
||||
<DialogContent className="max-w-sm rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除标签</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-stone-600">
|
||||
确定要删除标签 <span className="font-semibold">"{tagDeleteTarget}"</span> 吗?将从所有图片中移除该标签。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl" onClick={() => setTagDeleteTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => {
|
||||
if (tagDeleteTarget) void handleDeleteTag(tagDeleteTarget);
|
||||
setTagDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageManagerPage() {
|
||||
const { isCheckingAuth, session } = useAuthGuard(["admin"]);
|
||||
if (isCheckingAuth || !session || session.role !== "admin") {
|
||||
return <div className="flex min-h-[40vh] items-center justify-center"><LoaderCircle className="size-5 animate-spin text-stone-400" /></div>;
|
||||
}
|
||||
return <ImageManagerContent />;
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
"use client";
|
||||
import { ArrowUp, ChevronDown, ImagePlus, Info, LoaderCircle, RectangleHorizontal, RectangleVertical, Square, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type RefObject } from "react";
|
||||
|
||||
import { ImageLightbox } from "@/components/image-lightbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ImageModel } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImageComposerProps = {
|
||||
prompt: string;
|
||||
imageCount: string;
|
||||
imageRatio: string;
|
||||
imageTier: string;
|
||||
imageWidth: string;
|
||||
imageHeight: string;
|
||||
imageQuality: string;
|
||||
imageModel: ImageModel;
|
||||
imageModels: ImageModel[];
|
||||
availableQuota: string;
|
||||
activeTaskCount: number;
|
||||
referenceImages: Array<{ name: string; dataUrl: string }>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onPromptChange: (value: string) => void;
|
||||
onImageCountChange: (value: string) => void;
|
||||
onImageRatioChange: (value: string) => void;
|
||||
onImageTierChange: (value: string) => void;
|
||||
onImageWidthChange: (value: string) => void;
|
||||
onImageHeightChange: (value: string) => void;
|
||||
onImageQualityChange: (value: string) => void;
|
||||
onImageModelChange: (value: ImageModel) => void;
|
||||
onSubmit: () => void | Promise<void>;
|
||||
onPickReferenceImage: () => void;
|
||||
onReferenceImageChange: (files: File[]) => void | Promise<void>;
|
||||
onRemoveReferenceImage: (index: number) => void;
|
||||
};
|
||||
|
||||
const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i;
|
||||
|
||||
function isImageFile(file: File) {
|
||||
return file.type.startsWith("image/") || (!file.type && imageFileNamePattern.test(file.name));
|
||||
}
|
||||
|
||||
function hasDraggedImages(dataTransfer: DataTransfer) {
|
||||
const items = Array.from(dataTransfer.items || []);
|
||||
if (items.length > 0) {
|
||||
return items.some((item) => item.kind === "file" && (item.type.startsWith("image/") || !item.type));
|
||||
}
|
||||
return Array.from(dataTransfer.files || []).some(isImageFile);
|
||||
}
|
||||
|
||||
function getDraggedImageFiles(dataTransfer: DataTransfer) {
|
||||
return Array.from(dataTransfer.files || []).filter(isImageFile);
|
||||
}
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "high", label: "高" },
|
||||
];
|
||||
const aspectOptions = [
|
||||
{ ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square },
|
||||
{ ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical },
|
||||
{ ratio: "3:2", tier: "1k", width: "1536", height: "1024", label: "3:2", icon: RectangleHorizontal },
|
||||
{ ratio: "3:4", tier: "1k", width: "1024", height: "1365", label: "3:4", icon: RectangleVertical },
|
||||
{ ratio: "4:3", tier: "1k", width: "1365", height: "1024", label: "4:3", icon: RectangleHorizontal },
|
||||
{ ratio: "9:16", tier: "1k", width: "1088", height: "1920", label: "9:16", icon: RectangleVertical },
|
||||
{ ratio: "16:9", tier: "1k", width: "1920", height: "1088", label: "16:9", icon: RectangleHorizontal },
|
||||
{ ratio: "1:1", tier: "2k", width: "2048", height: "2048", label: "1:1(2k)", icon: Square },
|
||||
{ ratio: "16:9", tier: "2k", width: "2560", height: "1440", label: "16:9(2k)", icon: RectangleHorizontal },
|
||||
{ ratio: "9:16", tier: "2k", width: "1440", height: "2560", label: "9:16(2k)", icon: RectangleVertical },
|
||||
{ ratio: "16:9", tier: "4k", width: "3840", height: "2160", label: "16:9(4k)", icon: RectangleHorizontal },
|
||||
{ ratio: "9:16", tier: "4k", width: "2160", height: "3840", label: "9:16(4k)", icon: RectangleVertical },
|
||||
{ ratio: "auto", tier: "auto", width: "1024", height: "1024", label: "auto", icon: null },
|
||||
];
|
||||
const countOptions = Array.from({ length: 10 }, (_, index) => String(index + 1));
|
||||
|
||||
export function ImageComposer({
|
||||
prompt,
|
||||
imageCount,
|
||||
imageRatio,
|
||||
imageTier,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
imageQuality,
|
||||
imageModel,
|
||||
imageModels,
|
||||
availableQuota,
|
||||
activeTaskCount,
|
||||
referenceImages,
|
||||
textareaRef,
|
||||
fileInputRef,
|
||||
onPromptChange,
|
||||
onImageCountChange,
|
||||
onImageRatioChange,
|
||||
onImageTierChange,
|
||||
onImageWidthChange,
|
||||
onImageHeightChange,
|
||||
onImageQualityChange,
|
||||
onImageModelChange,
|
||||
onSubmit,
|
||||
onPickReferenceImage,
|
||||
onReferenceImageChange,
|
||||
onRemoveReferenceImage,
|
||||
}: ImageComposerProps) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [isSizeMenuOpen, setIsSizeMenuOpen] = useState(false);
|
||||
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
||||
const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const sizeMenuRef = useRef<HTMLDivElement>(null);
|
||||
const sizeMenuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const lightboxImages = useMemo(
|
||||
() => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
|
||||
[referenceImages],
|
||||
);
|
||||
const modelOptions = useMemo(
|
||||
() => imageModels.map((model) => ({ value: model, label: model })),
|
||||
[imageModels],
|
||||
);
|
||||
const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
|
||||
const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
|
||||
const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
|
||||
const selectedModelLabel = modelOptions.find((option) => option.value === imageModel)?.label || imageModel;
|
||||
const isCodexModel = imageModel.toLowerCase().includes("codex");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSizeMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('[data-slot="select-content"], [data-slot="select-trigger"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!sizeMenuRef.current?.contains(target as Node)) {
|
||||
setIsSizeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", handlePointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handlePointerDown);
|
||||
};
|
||||
}, [isSizeMenuOpen]);
|
||||
|
||||
const handleTextareaPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const imageFiles = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (imageFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void onReferenceImageChange(imageFiles);
|
||||
};
|
||||
|
||||
const handleComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (!hasDraggedImages(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
setIsSizeMenuOpen(false);
|
||||
setIsDraggingImage(true);
|
||||
};
|
||||
|
||||
const handleComposerDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (!hasDraggedImages(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
setIsDraggingImage(true);
|
||||
};
|
||||
|
||||
const handleComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsDraggingImage(false);
|
||||
};
|
||||
|
||||
const handleComposerDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
const imageFiles = getDraggedImageFiles(event.dataTransfer);
|
||||
if (event.dataTransfer.files.length > 0 || imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
setIsDraggingImage(false);
|
||||
if (imageFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onReferenceImageChange(imageFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex justify-center px-1 sm:px-0">
|
||||
<div style={{ width: "min(980px, 100%)" }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onReferenceImageChange(Array.from(event.target.files || []));
|
||||
}}
|
||||
/>
|
||||
|
||||
{referenceImages.length > 0 ? (
|
||||
<div className="mb-2 flex gap-2 overflow-x-auto px-1 pb-1 sm:mb-3 sm:flex-wrap sm:overflow-visible sm:pb-0">
|
||||
{referenceImages.map((image, index) => (
|
||||
<div key={`${image.name}-${index}`} className="relative size-14 shrink-0 sm:size-16">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
}}
|
||||
className="group size-14 overflow-hidden rounded-2xl border border-stone-200 bg-stone-50 transition hover:border-stone-300 sm:size-16"
|
||||
aria-label={`预览参考图 ${image.name || index + 1}`}
|
||||
>
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name || `参考图 ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemoveReferenceImage(index);
|
||||
}}
|
||||
className="absolute -right-1 -top-1 inline-flex size-5 items-center justify-center rounded-full border border-stone-200 bg-white text-stone-500 transition hover:border-stone-300 hover:text-stone-800"
|
||||
aria-label={`移除参考图 ${image.name || index + 1}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[24px] border border-stone-200 bg-white shadow-[0_14px_60px_-42px_rgba(15,23,42,0.45)] transition dark:border-white/10 dark:bg-stone-950/80 sm:rounded-[32px] sm:shadow-none",
|
||||
isDraggingImage && "border-stone-900 bg-stone-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative cursor-text"
|
||||
onDragEnter={handleComposerDragEnter}
|
||||
onDragOver={handleComposerDragOver}
|
||||
onDragLeave={handleComposerDragLeave}
|
||||
onDrop={handleComposerDrop}
|
||||
onClick={() => {
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<ImageLightbox
|
||||
images={lightboxImages}
|
||||
currentIndex={lightboxIndex}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={handleTextareaPaste}
|
||||
placeholder={
|
||||
referenceImages.length > 0
|
||||
? "描述你希望如何修改参考图"
|
||||
: "输入你想要生成的画面,也可直接粘贴图片"
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}
|
||||
}}
|
||||
className="min-h-[82px] resize-none rounded-[24px] border-0 bg-transparent px-4 pt-4 pb-2 text-[15px] leading-6 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0 dark:text-stone-100 dark:placeholder:text-stone-500 sm:min-h-[148px] sm:rounded-[32px] sm:px-6 sm:pt-6 sm:pb-20 sm:leading-7"
|
||||
/>
|
||||
{isDraggingImage ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[24px] border-2 border-dashed border-stone-900 bg-white/85 text-sm font-medium text-stone-900 backdrop-blur-[1px] sm:rounded-[32px]">
|
||||
<div className="flex items-center gap-2 rounded-full bg-stone-950 px-4 py-2 text-white shadow-lg">
|
||||
<ImagePlus className="size-4" />
|
||||
<span>松开以上传参考图</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-b-[24px] border-t border-stone-100 bg-white px-3 pb-3 pt-2 dark:border-white/10 dark:bg-stone-950/95 sm:absolute sm:inset-x-0 sm:bottom-0 sm:rounded-b-none sm:border-t-0 sm:bg-gradient-to-t sm:from-white sm:via-white/95 sm:to-transparent sm:px-6 sm:pb-4 sm:pt-6 sm:dark:from-stone-950 sm:dark:via-stone-950/95 sm:dark:to-stone-950/0" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex items-end justify-between gap-2 sm:gap-3">
|
||||
<div className="hide-scrollbar flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-x-auto pb-0.5 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:pb-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 shrink-0 rounded-full border-stone-200 bg-white px-3 text-xs font-medium text-stone-700 shadow-none sm:h-10 sm:px-4 sm:text-sm"
|
||||
onClick={onPickReferenceImage}
|
||||
aria-label={referenceImages.length > 0 ? "添加参考图" : "上传"}
|
||||
>
|
||||
<ImagePlus className="size-3.5 sm:size-4" />
|
||||
<span className="hidden sm:inline">{referenceImages.length > 0 ? "添加参考图" : "上传"}</span>
|
||||
</Button>
|
||||
<div className="shrink-0 rounded-full bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-600 sm:px-3 sm:py-2 sm:text-xs">
|
||||
<span className="hidden sm:inline">剩余额度 </span>{availableQuota}
|
||||
</div>
|
||||
{activeTaskCount > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full bg-amber-50 px-2 py-1 text-[10px] font-medium text-amber-700 sm:gap-1.5 sm:px-3 sm:py-2 sm:text-xs">
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
{activeTaskCount}<span className="hidden sm:inline"> 个任务处理中</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex h-9 min-w-0 shrink items-center rounded-full bg-transparent text-[11px] sm:h-auto sm:shrink-0 sm:text-[13px]">
|
||||
<button
|
||||
ref={sizeMenuBtnRef}
|
||||
type="button"
|
||||
className="inline-flex h-9 w-fit max-w-[calc(100vw-12rem)] items-center justify-between gap-2 rounded-full bg-stone-100 px-4 text-left text-xs font-semibold text-stone-900 sm:h-10 sm:max-w-none sm:text-sm"
|
||||
onClick={() => {
|
||||
if (!isSizeMenuOpen && sizeMenuBtnRef.current) {
|
||||
const rect = sizeMenuBtnRef.current.getBoundingClientRect();
|
||||
const menuWidth = Math.min(460, window.innerWidth - 32);
|
||||
setSizeMenuPos({ top: rect.top - 8, left: Math.max(16, Math.min(rect.left, window.innerWidth - menuWidth - 16)) });
|
||||
}
|
||||
setIsSizeMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{imageSizeLabel}</span>
|
||||
<ChevronDown className={cn("size-4 shrink-0 opacity-60 transition", isSizeMenuOpen && "rotate-180")} />
|
||||
</button>
|
||||
{isSizeMenuOpen ? (
|
||||
<div
|
||||
ref={sizeMenuRef}
|
||||
className="fixed z-[80] max-h-[62dvh] overflow-y-auto rounded-[24px] border border-stone-200/70 bg-white p-4 shadow-[0_30px_90px_-34px_rgba(15,23,42,0.42)] sm:max-h-none sm:overflow-visible"
|
||||
style={{
|
||||
top: sizeMenuPos.top,
|
||||
left: sizeMenuPos.left,
|
||||
transform: "translateY(-100%)",
|
||||
width: "min(460px, calc(100vw - 2rem))",
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-3 text-base font-semibold text-stone-950">图像设置</h3>
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 text-sm font-medium text-stone-900">模型</div>
|
||||
<Select
|
||||
value={imageModel}
|
||||
onValueChange={(value) => {
|
||||
onImageModelChange(value as ImageModel);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white text-sm shadow-none">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
src="/openai.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-stone-700"
|
||||
/>
|
||||
<span className="truncate">{selectedModelLabel}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[120]">
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="pl-10"
|
||||
style={{
|
||||
backgroundImage: "url('/openai.svg')",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "12px center",
|
||||
backgroundSize: "16px 16px",
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 text-sm font-medium text-stone-900">质量</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{qualityOptions.map((option) => {
|
||||
const active = option.value === imageQuality;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-9 cursor-pointer rounded-full border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
|
||||
active && "border-stone-950 bg-white font-medium text-stone-950",
|
||||
)}
|
||||
onClick={() => onImageQualityChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-stone-900">
|
||||
尺寸 <Info className="size-3.5 text-stone-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<div className="flex items-center rounded-lg bg-stone-100 px-3 py-1.5 text-sm text-stone-700">
|
||||
<span className="mr-2 text-stone-500">W</span>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
value={imageWidth}
|
||||
onChange={(event) => onImageWidthChange(event.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-stone-400">×</span>
|
||||
<div className="flex items-center rounded-lg bg-stone-100 px-3 py-1.5 text-sm text-stone-700">
|
||||
<span className="mr-2 text-stone-500">H</span>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
value={imageHeight}
|
||||
onChange={(event) => onImageHeightChange(event.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-stone-900">
|
||||
宽高比 <Info className="size-3.5 text-stone-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
|
||||
{aspectOptions.map((option) => {
|
||||
const active = option.ratio === imageRatio && option.tier === imageTier && option.width === imageWidth && option.height === imageHeight;
|
||||
const Icon = option.icon;
|
||||
const disabled = !isCodexModel && (option.tier === "2k" || option.tier === "4k");
|
||||
return (
|
||||
<button
|
||||
key={`${option.ratio}-${option.tier}-${option.label}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-[64px] cursor-pointer flex-col items-center justify-center gap-1 rounded-2xl border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
|
||||
active && "border-stone-950",
|
||||
disabled && "cursor-not-allowed border-stone-100 bg-stone-50 text-stone-300 hover:border-stone-100 hover:bg-stone-50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onImageRatioChange(option.ratio);
|
||||
onImageTierChange(option.tier);
|
||||
onImageWidthChange(option.width);
|
||||
onImageHeightChange(option.height);
|
||||
}}
|
||||
>
|
||||
{Icon ? (
|
||||
<>
|
||||
<Icon className="size-3.5 stroke-[1.8]" />
|
||||
<span>{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{option.label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-stone-100 pt-3">
|
||||
<div className="mb-2 text-sm font-medium text-stone-900">生成数量</div>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
|
||||
{countOptions.map((option) => {
|
||||
const active = imageCount === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-9 cursor-pointer rounded-full border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
|
||||
active && "border-stone-950 bg-white font-medium text-stone-950",
|
||||
)}
|
||||
onClick={() => onImageCountChange(option)}
|
||||
>
|
||||
{option} 张
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
value={imageCount}
|
||||
onChange={(event) => onImageCountChange(event.target.value)}
|
||||
className="h-9 rounded-full border-stone-200 bg-white px-3 text-center text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={!prompt.trim()}
|
||||
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-stone-950 text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-300 sm:size-11"
|
||||
aria-label={referenceImages.length > 0 ? "编辑图片" : "生成图片"}
|
||||
>
|
||||
<ArrowUp className="size-3.5 sm:size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { Clock3, Download, EyeOff, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
||||
|
||||
export type ImageLightboxItem = {
|
||||
id: string;
|
||||
src: string;
|
||||
sizeLabel?: string;
|
||||
dimensions?: string;
|
||||
};
|
||||
|
||||
type ImageResultsProps = {
|
||||
selectedConversation: ImageConversation | null;
|
||||
onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
|
||||
onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
|
||||
onDeletePrompt: (conversationId: string, turnId: string) => void;
|
||||
onDeleteResults: (conversationId: string, turnId: string) => void;
|
||||
onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
|
||||
onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
|
||||
onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
|
||||
onTimeoutRetryContinue: (taskId: string) => void | Promise<void>;
|
||||
onDismissErrors: (conversationId: string, turnId: string) => void | Promise<void>;
|
||||
formatConversationTime: (value: string) => string;
|
||||
};
|
||||
|
||||
// Blob URL 缓存:避免 base64 超长字符串在 DOM 中,改用短小的 blob: URL
|
||||
const b64BlobUrlCache = new Map<string, string>();
|
||||
|
||||
function getStoredImageSrc(image: StoredImage) {
|
||||
if (image.b64_json) {
|
||||
let url = b64BlobUrlCache.get(image.b64_json);
|
||||
if (!url) {
|
||||
const binary = atob(image.b64_json);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: "image/png" });
|
||||
url = URL.createObjectURL(blob);
|
||||
b64BlobUrlCache.set(image.b64_json, url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
return image.url || "";
|
||||
}
|
||||
|
||||
async function downloadStoredImage(image: StoredImage, index: number) {
|
||||
let blob: Blob | null = null;
|
||||
try {
|
||||
if (image.b64_json) {
|
||||
const binary = atob(image.b64_json);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
blob = new Blob([bytes], { type: "image/png" });
|
||||
} else if (image.url) {
|
||||
// 确保 URL 是绝对路径
|
||||
const url = image.url.startsWith("http") ? image.url : `${window.location.origin}${image.url}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
blob = await res.blob();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to download image:", err);
|
||||
// 如果 fetch 失败,尝试直接在新窗口打开
|
||||
if (image.url) {
|
||||
window.open(image.url, "_blank");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `image-${index + 1}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function ImageResults({
|
||||
selectedConversation,
|
||||
onOpenLightbox,
|
||||
onContinueEdit,
|
||||
onDeletePrompt,
|
||||
onDeleteResults,
|
||||
onReuseTurnConfig,
|
||||
onRegenerateTurn,
|
||||
onRetryImage,
|
||||
onTimeoutRetryContinue,
|
||||
onDismissErrors,
|
||||
formatConversationTime,
|
||||
}: ImageResultsProps) {
|
||||
const imageDimensionsRef = useRef<Record<string, string>>({});
|
||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
||||
|
||||
// 仅在存在 loading 图片时启动定时器,避免空闲时无谓重渲染
|
||||
const hasLoadingImages = selectedConversation?.turns.some(
|
||||
(turn) => !turn.resultsDeleted && turn.images.some((image) => image.status === "loading"),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasLoadingImages) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLoadingImages]);
|
||||
|
||||
const updateImageDimensions = (id: string, width: number, height: number) => {
|
||||
const dimensions = formatImageDimensions(width, height);
|
||||
// 使用 ref 存储,不触发 React 重渲染,消除级联重渲染
|
||||
if (imageDimensionsRef.current[id] !== dimensions) {
|
||||
imageDimensionsRef.current[id] = dimensions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedConversation) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
|
||||
<div className="w-full max-w-4xl">
|
||||
<h1
|
||||
className="text-2xl font-semibold tracking-tight text-stone-950 sm:text-3xl md:text-5xl"
|
||||
style={{
|
||||
fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
|
||||
}}
|
||||
>
|
||||
Turn ideas into images
|
||||
</h1>
|
||||
<p
|
||||
className="mx-auto mt-3 max-w-[280px] text-sm italic tracking-[0.01em] text-stone-500 sm:mt-4 sm:max-w-none sm:text-[15px]"
|
||||
style={{
|
||||
fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
|
||||
}}
|
||||
>
|
||||
在同一窗口里保留本地历史与任务状态,并从已有结果图继续发起新的无状态编辑。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[980px] flex-col gap-5 sm:gap-8">
|
||||
{selectedConversation.turns.map((turn, turnIndex) => {
|
||||
const referenceLightboxImages = turn.referenceImages.map((image, index) => ({
|
||||
id: `${turn.id}-reference-${index}`,
|
||||
src: image.dataUrl,
|
||||
}));
|
||||
const successfulTurnImages = turn.images.flatMap((image) => {
|
||||
const src = image.status === "success" ? getStoredImageSrc(image) : "";
|
||||
return src
|
||||
? [
|
||||
{
|
||||
id: image.id,
|
||||
src,
|
||||
sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
|
||||
dimensions: imageDimensionsRef.current[image.id],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={turn.id} className="flex flex-col gap-3 sm:gap-4">
|
||||
{!turn.promptDeleted ? (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[90%] px-1 py-1 text-[14px] leading-6 text-stone-900 sm:max-w-[82%] sm:text-[15px] sm:leading-7">
|
||||
<div className="mb-1.5 flex flex-wrap justify-end gap-2 text-[11px] text-stone-400 sm:mb-2">
|
||||
<span>第 {turnIndex + 1} 轮</span>
|
||||
<span>
|
||||
{turn.mode === "edit" ? "编辑图" : "文生图"}
|
||||
</span>
|
||||
<span>{getTurnStatusLabel(turn.status)}</span>
|
||||
<span>{formatConversationTime(turn.createdAt)}</span>
|
||||
</div>
|
||||
<div className="text-right">{turn.prompt}</div>
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReuseTurnConfig(selectedConversation.id, turn.id)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:bg-stone-200 hover:text-stone-900"
|
||||
>
|
||||
复用配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeletePrompt(selectedConversation.id, turn.id)}
|
||||
className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
aria-label="删除提示词记录"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!turn.resultsDeleted ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="w-full p-1">
|
||||
{turn.referenceImages.length > 0 ? (
|
||||
<div className="mb-4 flex flex-col items-end">
|
||||
<div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
{turn.referenceImages.map((image, index) => (
|
||||
<div key={`${turn.id}-${image.name}-${index}`} className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenLightbox(referenceLightboxImages, index)}
|
||||
className="group relative h-24 w-24 overflow-hidden border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
|
||||
aria-label={`预览参考图 ${image.name || index + 1}`}
|
||||
>
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name || `参考图 ${index + 1}`}
|
||||
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
|
||||
onClick={() => onContinueEdit(selectedConversation.id, image)}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
加入编辑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 sm:mb-4 sm:gap-2 sm:text-xs">
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
|
||||
{turn.status === "queued" ? (
|
||||
<span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:block sm:columns-2 sm:gap-4 sm:space-y-4 xl:columns-3">
|
||||
{turn.images.map((image, index) => {
|
||||
const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
|
||||
if (image.status === "success" && imageSrc) {
|
||||
const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
|
||||
const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
|
||||
const dimensions = imageDimensionsRef.current[image.id];
|
||||
const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className="break-inside-avoid"
|
||||
>
|
||||
<LazyImage
|
||||
src={imageSrc}
|
||||
alt={`Generated result ${index + 1}`}
|
||||
className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
|
||||
onLoad={(event) => {
|
||||
updateImageDimensions(
|
||||
image.id,
|
||||
event.currentTarget.naturalWidth,
|
||||
event.currentTarget.naturalHeight,
|
||||
);
|
||||
}}
|
||||
onOpen={() => onOpenLightbox(successfulTurnImages, currentIndex)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
|
||||
<div className="min-w-0 text-stone-500">
|
||||
<span>结果 {index + 1}</span>
|
||||
{image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
|
||||
{imageMeta ? <span className="block text-stone-400">{imageMeta}</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
|
||||
onClick={() => onContinueEdit(selectedConversation.id, image)}
|
||||
aria-label="加入编辑"
|
||||
>
|
||||
<Sparkles className="size-3 sm:size-4" />
|
||||
<span className="hidden sm:inline">加入编辑</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
|
||||
onClick={() => void downloadStoredImage(image, index)}
|
||||
aria-label="下载"
|
||||
>
|
||||
<Download className="size-3 sm:size-4" />
|
||||
<span className="hidden sm:inline">下载</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (image.status === "error") {
|
||||
const isTimeoutError = image.error?.includes("超时") && image.taskId;
|
||||
return (
|
||||
<div key={image.id} className="break-inside-avoid">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-rose-200 bg-rose-50",
|
||||
"aspect-square",
|
||||
turn.ratio === "1:1" && "sm:aspect-square",
|
||||
turn.ratio === "16:9" && "sm:aspect-video",
|
||||
turn.ratio === "9:16" && "sm:aspect-[9/16]",
|
||||
turn.ratio === "4:3" && "sm:aspect-[4/3]",
|
||||
turn.ratio === "3:4" && "sm:aspect-[3/4]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
|
||||
<p className="font-medium">图片 {index + 1}/{turn.images.length}</p>
|
||||
<span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTimeoutError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onTimeoutRetryContinue(image.taskId!)}
|
||||
className="rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-medium text-emerald-600 shadow-sm transition hover:bg-emerald-200 sm:px-3 sm:text-xs"
|
||||
>
|
||||
继续等待
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
|
||||
className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
|
||||
>
|
||||
重新生成这一张
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
|
||||
<div className="min-w-0 text-stone-500">
|
||||
<span>结果 {index + 1}</span>
|
||||
{image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
|
||||
<span className="block text-transparent">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageTaskStatus = image.taskStatus || (turn.status === "queued" ? "queued" : "running");
|
||||
const imageStatusLabel = imageTaskStatus === "queued" ? "排队中" : getProgressLabel(image.progress);
|
||||
const showElapsed = imageTaskStatus === "running" && image.elapsedSecs != null;
|
||||
const elapsedDisplay = showElapsed
|
||||
? formatElapsed(
|
||||
image.elapsedUpdatedAt != null
|
||||
? image.elapsedSecs! + (currentTime - image.elapsedUpdatedAt!) / 1000
|
||||
: image.elapsedSecs!,
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<div key={image.id} className="break-inside-avoid">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 relative",
|
||||
turn.ratio === "1:1" && "aspect-square",
|
||||
turn.ratio === "16:9" && "aspect-video",
|
||||
turn.ratio === "9:16" && "aspect-[9/16]",
|
||||
turn.ratio === "4:3" && "aspect-[4/3]",
|
||||
turn.ratio === "3:4" && "aspect-[3/4]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
|
||||
<div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
|
||||
{imageTaskStatus === "queued" ? (
|
||||
<Clock3 className="size-4 sm:size-5" />
|
||||
) : (
|
||||
<LoaderCircle className="size-4 animate-spin sm:size-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] font-medium leading-4 sm:text-sm">
|
||||
图片 {index + 1}/{turn.images.length}
|
||||
</p>
|
||||
<p className="text-[10px] leading-4 text-stone-400 sm:text-xs">
|
||||
{imageStatusLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{elapsedDisplay != null && (
|
||||
<div className="px-0.5 py-1 text-[10px] text-stone-400 sm:px-3 sm:py-3 sm:text-xs">{elapsedDisplay}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{turn.status === "error" && turn.error ? (
|
||||
<div className="mt-4 flex items-center justify-between border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
|
||||
<span>{turn.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDismissErrors(selectedConversation.id, turn.id)}
|
||||
className="ml-3 inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:bg-amber-200 hover:text-amber-900"
|
||||
>
|
||||
<EyeOff className="size-3" />
|
||||
忽略错误
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[11px] sm:mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRegenerateTurn(selectedConversation.id, turn.id)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-medium text-stone-500 transition hover:bg-stone-200 hover:text-stone-900"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
全部重新生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteResults(selectedConversation.id, turn.id)}
|
||||
className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
aria-label="删除生成结果"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTurnStatusLabel(status: ImageTurnStatus) {
|
||||
if (status === "queued") {
|
||||
return "排队中";
|
||||
}
|
||||
if (status === "generating") {
|
||||
return "处理中";
|
||||
}
|
||||
if (status === "success") {
|
||||
return "已完成";
|
||||
}
|
||||
return "失败";
|
||||
}
|
||||
|
||||
const PROGRESS_LABELS: Record<string, string> = {
|
||||
getting_account: "确认可用账号",
|
||||
switching_upload_account: "当前账号上传受限,正在尝试其他账号",
|
||||
uploading: "上传图片",
|
||||
bootstrapping: "预热首页",
|
||||
getting_token: "获取 token",
|
||||
preparing_conversation: "准备会话",
|
||||
starting_generation: "等待上游开始生成",
|
||||
image_stream_resolve_start: "确认生成结果",
|
||||
generating: "生成中",
|
||||
receiving_image: "接收图片中",
|
||||
};
|
||||
|
||||
function getProgressLabel(progress?: string) {
|
||||
if (!progress) {
|
||||
return "生成中";
|
||||
}
|
||||
return PROGRESS_LABELS[progress] || "生成中";
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const base64SizeCache = new Map<string, string>();
|
||||
function formatBase64ImageSize(base64: string) {
|
||||
let cached = base64SizeCache.get(base64);
|
||||
if (cached !== undefined) return cached;
|
||||
const normalized = base64.replace(/\s/g, "");
|
||||
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
||||
const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
||||
|
||||
if (bytes >= 1024 * 1024) {
|
||||
cached = `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
} else if (bytes >= 1024) {
|
||||
cached = `${(bytes / 1024).toFixed(1)} KB`;
|
||||
} else {
|
||||
cached = `${bytes} B`;
|
||||
}
|
||||
base64SizeCache.set(base64, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function formatImageDimensions(width: number, height: number) {
|
||||
return `${width} x ${height}`;
|
||||
}
|
||||
|
||||
const LazyImage = memo(function LazyImage({ src, alt, className, onLoad, onOpen }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className: string;
|
||||
onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const imgRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = imgRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={imgRef} className="relative">
|
||||
{isVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={className}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className={`animate-pulse rounded-xl bg-stone-100 min-h-[200px] sm:min-h-[280px] ${className}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LoaderCircle, MessageSquarePlus, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getImageConversationStats, type ImageConversation } from "@/store/image-conversations";
|
||||
|
||||
type ImageSidebarProps = {
|
||||
conversations: ImageConversation[];
|
||||
isLoadingHistory: boolean;
|
||||
selectedConversationId: string | null;
|
||||
onCreateDraft: () => void;
|
||||
onClearHistory: () => void | Promise<void>;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void | Promise<void>;
|
||||
onRenameConversation: (id: string, title: string) => void | Promise<void>;
|
||||
formatConversationTime: (value: string) => string;
|
||||
hideActionButtons?: boolean;
|
||||
};
|
||||
|
||||
export function ImageSidebar({
|
||||
conversations,
|
||||
isLoadingHistory,
|
||||
selectedConversationId,
|
||||
onCreateDraft,
|
||||
onClearHistory,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onRenameConversation,
|
||||
formatConversationTime,
|
||||
hideActionButtons = false,
|
||||
}: ImageSidebarProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
editInputRef.current.select();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
const startRename = useCallback((conversation: ImageConversation, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingId(conversation.id);
|
||||
setEditingTitle(conversation.title);
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
const trimmed = editingTitle.trim();
|
||||
if (editingId && trimmed) {
|
||||
void onRenameConversation(editingId, trimmed);
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditingTitle("");
|
||||
}, [editingId, editingTitle, onRenameConversation]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setEditingTitle("");
|
||||
}, []);
|
||||
return (
|
||||
<aside className="h-full min-h-0 overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
|
||||
{!hideActionButtons && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="h-10 flex-1 rounded-xl bg-stone-950 text-white hover:bg-stone-800" onClick={onCreateDraft}>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
新建对话
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white/85 px-3 text-stone-600 hover:bg-white"
|
||||
onClick={() => void onClearHistory()}
|
||||
disabled={conversations.length === 0}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto [scrollbar-color:rgba(120,113,108,.45)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-stone-400/45 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
hideActionButtons ? "space-y-1 pr-0" : "space-y-2 pr-1",
|
||||
)}
|
||||
>
|
||||
{isLoadingHistory ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在读取会话记录
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="px-2 py-3 text-sm leading-6 text-stone-500">还没有图片记录,输入提示词后会在这里显示。</div>
|
||||
) : (
|
||||
conversations.map((conversation) => {
|
||||
const active = conversation.id === selectedConversationId;
|
||||
const stats = getImageConversationStats(conversation);
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={cn(
|
||||
"group relative w-full border-l-2 text-left transition",
|
||||
hideActionButtons ? "px-4 py-3.5" : "px-3 py-2 sm:py-3",
|
||||
active
|
||||
? "border-stone-900 bg-black/[0.035] text-stone-950"
|
||||
: "border-transparent text-stone-700 hover:border-stone-300 hover:bg-white/40",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
className="block w-full pr-8 text-left"
|
||||
>
|
||||
<div className={cn("truncate font-semibold", hideActionButtons ? "text-base" : "text-sm")}>
|
||||
{editingId === conversation.id ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editingTitle}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") cancelRename();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full truncate rounded border border-stone-300 bg-white px-1 py-0.5 text-sm outline-none focus:border-stone-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{conversation.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
|
||||
{conversation.turns.length} 轮 · {formatConversationTime(conversation.updatedAt)}
|
||||
</div>
|
||||
{stats.running > 0 || stats.queued > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
{stats.running > 0 ? (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-1 text-blue-600">处理中 {stats.running}</span>
|
||||
) : null}
|
||||
{stats.queued > 0 ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-1 text-amber-700">排队 {stats.queued}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="absolute top-2.5 right-1.5 flex items-center gap-0.5 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => startRename(conversation, e)}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-stone-400 hover:bg-stone-100 hover:text-stone-600"
|
||||
aria-label="重命名会话"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDeleteConversation(conversation.id)}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-stone-400 hover:bg-stone-100 hover:text-rose-500"
|
||||
aria-label="删除会话"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import { ThemeScript } from "@/components/theme-script";
|
||||
import { TopNav } from "@/components/top-nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ChatGPT 号池管理",
|
||||
description: "ChatGPT account pool management dashboard",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f0ebe3" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#12110f" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body
|
||||
className="antialiased"
|
||||
style={{
|
||||
fontFamily:
|
||||
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||
}}
|
||||
>
|
||||
<Toaster position="top-center" richColors offset={48} />
|
||||
<main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 transition-colors duration-300 dark:bg-[radial-gradient(circle_at_top_left,_rgba(55,48,43,0.72),_rgba(28,25,23,0.98)_40%,_rgba(12,10,9,1)_100%)] dark:text-stone-100 sm:px-6 sm:pt-2 lg:px-8">
|
||||
<div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
|
||||
<TopNav />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { LoaderCircle, LockKeyhole } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { HeaderActions } from "@/components/header-actions";
|
||||
import { login } from "@/lib/api";
|
||||
import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
|
||||
import { getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [authKey, setAuthKey] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { isCheckingAuth } = useRedirectIfAuthenticated();
|
||||
|
||||
const handleLogin = async () => {
|
||||
const normalizedAuthKey = authKey.trim();
|
||||
if (!normalizedAuthKey) {
|
||||
toast.error("请输入 密钥");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const data = await login(normalizedAuthKey);
|
||||
await setStoredAuthSession({
|
||||
key: normalizedAuthKey,
|
||||
role: data.role,
|
||||
subjectId: data.subject_id,
|
||||
name: data.name,
|
||||
});
|
||||
router.replace(getDefaultRouteForRole(data.role));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "登录失败";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isCheckingAuth) {
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-1rem)] w-full place-items-center px-4 py-6">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-1rem)] w-full place-items-center px-4 py-6">
|
||||
<HeaderActions className="fixed top-4 right-4 z-10" />
|
||||
<Card className="w-full max-w-[505px] rounded-[30px] border-white/80 bg-white/95 shadow-[0_28px_90px_rgba(28,25,23,0.10)]">
|
||||
<CardContent className="space-y-7 p-6 sm:p-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto inline-flex size-14 items-center justify-center rounded-[18px] bg-stone-950 text-white shadow-sm">
|
||||
<LockKeyhole className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-stone-950">欢迎回来</h1>
|
||||
<p className="text-sm leading-6 text-stone-500">输入密钥后继续使用账号管理和图片生成功能。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label htmlFor="auth-key" className="block text-sm font-medium text-stone-700">
|
||||
密钥
|
||||
</label>
|
||||
<Input
|
||||
id="auth-key"
|
||||
type="password"
|
||||
value={authKey}
|
||||
onChange={(event) => setAuthKey(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleLogin();
|
||||
}
|
||||
}}
|
||||
placeholder="请输入密钥"
|
||||
className="h-13 rounded-2xl border-stone-200 bg-white px-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-13 w-full rounded-2xl bg-stone-950 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleLogin()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
登录
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, ImageIcon, LoaderCircle, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DateRangeFilter } from "@/components/date-range-filter";
|
||||
import { ImageLightbox } from "@/components/image-lightbox";
|
||||
import { ImageThumbnail, getImageThumbnailUrl } from "@/components/image-thumbnail";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { deleteSystemLogs, fetchSystemLogs, type SystemLog } from "@/lib/api";
|
||||
import { useAuthGuard } from "@/lib/use-auth-guard";
|
||||
|
||||
const LogType = {
|
||||
Call: "call",
|
||||
Account: "account",
|
||||
} as const;
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
[LogType.Call]: "调用日志",
|
||||
[LogType.Account]: "账号管理日志",
|
||||
};
|
||||
|
||||
function getDetailText(item: SystemLog, key: string) {
|
||||
const value = item.detail?.[key];
|
||||
return typeof value === "string" || typeof value === "number" ? String(value) : "-";
|
||||
}
|
||||
|
||||
function formatDuration(item: SystemLog) {
|
||||
const value = item.detail?.duration_ms;
|
||||
return typeof value === "number" ? `${(value / 1000).toFixed(2)} s` : "-";
|
||||
}
|
||||
|
||||
function getUrls(item: SystemLog | null) {
|
||||
const urls = item?.detail?.urls;
|
||||
return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === "string") : [];
|
||||
}
|
||||
|
||||
function getStatus(item: SystemLog) {
|
||||
const status = item.detail?.status;
|
||||
if (status === "success") return "成功";
|
||||
if (status === "failed") return "失败";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function LogsContent() {
|
||||
const [items, setItems] = useState<SystemLog[]>([]);
|
||||
const [type, setType] = useState<string>(LogType.Call);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [deletingItems, setDeletingItems] = useState<SystemLog[]>([]);
|
||||
const detailUrls = getUrls(detailLog);
|
||||
const detailImages = detailUrls.map((url, index) => ({ id: `${index}`, src: url }));
|
||||
const isCallLog = type === LogType.Call;
|
||||
const pageSize = 10;
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const currentRows = items.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const currentPageSelected = currentRows.length > 0 && currentRows.every((item) => selectedSet.has(item.id));
|
||||
const allSelected = items.length > 0 && items.every((item) => selectedSet.has(item.id));
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchSystemLogs({ type, start_date: startDate, end_date: endDate });
|
||||
setItems(data.items);
|
||||
setSelectedIds((current) => current.filter((id) => data.items.some((item) => item.id === id)));
|
||||
setPage(1);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载日志失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
};
|
||||
|
||||
const openDetail = (item: SystemLog) => {
|
||||
setDetailLog(item);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const openLogImage = (item: SystemLog, index: number) => {
|
||||
setDetailLog(item);
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
|
||||
const toggleIds = (ids: string[], checked: boolean) => {
|
||||
setSelectedIds((current) => checked ? Array.from(new Set([...current, ...ids])) : current.filter((id) => !ids.includes(id)));
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const ids = deletingItems.map((item) => item.id);
|
||||
if (ids.length === 0) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const data = await deleteSystemLogs(ids);
|
||||
toast.success(`已删除 ${data.removed} 条日志`);
|
||||
setDeletingItems([]);
|
||||
setSelectedIds((current) => current.filter((id) => !ids.includes(id)));
|
||||
if (detailLog && ids.includes(detailLog.id)) {
|
||||
setDetailOpen(false);
|
||||
setDetailLog(null);
|
||||
}
|
||||
await loadLogs();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除日志失败");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [type, startDate, endDate]);
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Logs</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">日志管理</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger className="h-10 w-[150px] rounded-xl border-stone-200 bg-white"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LogType.Call}>调用日志</SelectItem>
|
||||
<SelectItem value={LogType.Account}>账号管理日志</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DateRangeFilter startDate={startDate} endDate={endDate} onChange={(start, end) => { setStartDate(start); setEndDate(end); }} />
|
||||
<Button variant="outline" onClick={clearFilters} className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700">
|
||||
清除筛选条件
|
||||
</Button>
|
||||
<Button onClick={() => void loadLogs()} disabled={isLoading} className="h-10 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800">
|
||||
{isLoading ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-100 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-stone-600">
|
||||
<span>共 {items.length} 条</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={currentPageSelected} onCheckedChange={(checked) => toggleIds(currentRows.map((item) => item.id), Boolean(checked))} />
|
||||
本页全选
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(checked) => toggleIds(items.map((item) => item.id), Boolean(checked))} />
|
||||
全选结果
|
||||
</label>
|
||||
{selectedIds.length > 0 ? <span>已选 {selectedIds.length} 条</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-500" onClick={() => void loadLogs()} disabled={isLoading}>
|
||||
<RefreshCw className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<button type="button" className="text-sm text-stone-500 hover:text-stone-900 disabled:text-stone-300" onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0 || isDeleting}>
|
||||
取消选择
|
||||
</button>
|
||||
<Button variant="outline" className="h-8 rounded-lg border-rose-200 bg-white px-3 text-rose-600 hover:bg-rose-50" onClick={() => setDeletingItems(items.filter((item) => selectedSet.has(item.id)))} disabled={selectedIds.length === 0 || isDeleting}>
|
||||
<Trash2 className="size-4" />
|
||||
删除所选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
{isCallLog ? <TableHead>令牌名称</TableHead> : null}
|
||||
{isCallLog ? <TableHead>调用耗时</TableHead> : null}
|
||||
{isCallLog ? <TableHead>状态</TableHead> : null}
|
||||
{isCallLog ? <TableHead className="w-36">图片</TableHead> : null}
|
||||
<TableHead>简述</TableHead>
|
||||
<TableHead className="w-40">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentRows.map((item) => {
|
||||
const urls = getUrls(item);
|
||||
return (
|
||||
<TableRow key={item.id} className="text-stone-600">
|
||||
<TableCell>
|
||||
<Checkbox checked={selectedSet.has(item.id)} onCheckedChange={(checked) => toggleIds([item.id], Boolean(checked))} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{item.time}</TableCell>
|
||||
<TableCell><Badge variant="secondary" className="rounded-md">{typeLabels[item.type] || item.type}</Badge></TableCell>
|
||||
{isCallLog ? <TableCell>{getDetailText(item, "key_name")}</TableCell> : null}
|
||||
{isCallLog ? <TableCell>{formatDuration(item)}</TableCell> : null}
|
||||
{isCallLog ? (
|
||||
<TableCell>
|
||||
<Badge variant={item.detail?.status === "failed" ? "danger" : "success"} className="rounded-md">
|
||||
{getStatus(item)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{isCallLog ? (
|
||||
<TableCell>
|
||||
{urls.length ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{urls.slice(0, 3).map((url, imageIndex) => (
|
||||
<button
|
||||
key={`${url}-${imageIndex}`}
|
||||
type="button"
|
||||
className="relative size-9 overflow-hidden rounded-lg border border-stone-200 bg-stone-100"
|
||||
onClick={() => openLogImage(item, imageIndex)}
|
||||
title="预览图片"
|
||||
>
|
||||
<ImageThumbnail src={url} thumbnailSrc={getImageThumbnailUrl(url)} className="h-full w-full" />
|
||||
</button>
|
||||
))}
|
||||
{urls.length > 3 ? <span className="text-xs text-stone-400">+{urls.length - 3}</span> : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-stone-400">
|
||||
<ImageIcon className="size-3.5" />
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => openDetail(item)}>
|
||||
查看详情
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-8 rounded-lg px-3 text-rose-600 hover:bg-rose-50 hover:text-rose-700" onClick={() => setDeletingItems([item])}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 text-sm text-stone-500">
|
||||
<span>第 {safePage} / {pageCount} 页,共 {items.length} 条</span>
|
||||
<Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="size-9 rounded-lg border-stone-200 bg-white" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="flex h-[min(88vh,860px)] w-[min(92vw,920px)] flex-col overflow-hidden rounded-2xl p-0">
|
||||
<DialogHeader className="shrink-0 border-b border-stone-100 px-6 py-5">
|
||||
<DialogTitle>日志详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-600 md:grid-cols-2">
|
||||
{Object.entries(detailLog?.detail || {})
|
||||
.filter(([key, value]) => key !== "urls" && typeof value !== "object")
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-start justify-between gap-4">
|
||||
<span className="text-stone-400">{key}</span>
|
||||
<span className="text-right font-medium break-all text-stone-700">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{detailUrls.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{detailUrls.map((url, index) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
className="aspect-square overflow-hidden rounded-xl border border-stone-200 bg-stone-100"
|
||||
onClick={() => {
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
}}
|
||||
>
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="max-h-[72vh] overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-4 text-xs leading-6 text-stone-700">
|
||||
{JSON.stringify(detailLog?.detail || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ImageLightbox
|
||||
images={detailImages}
|
||||
currentIndex={lightboxIndex}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
<Dialog open={deletingItems.length > 0} onOpenChange={(open) => (!open ? setDeletingItems([]) : null)}>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>{deletingItems.length === 1 ? "删除日志" : "删除所选日志"}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
确认删除 {deletingItems.length} 条日志吗?删除后无法恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl" onClick={() => setDeletingItems([])} disabled={isDeleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="rounded-xl bg-rose-600 text-white hover:bg-rose-700" onClick={() => void confirmDelete()} disabled={isDeleting || deletingItems.length === 0}>
|
||||
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { isCheckingAuth, session } = useAuthGuard(["admin"]);
|
||||
if (isCheckingAuth || !session || session.role !== "admin") {
|
||||
return <div className="flex min-h-[40vh] items-center justify-center"><LoaderCircle className="size-5 animate-spin text-stone-400" /></div>;
|
||||
}
|
||||
return <LogsContent />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getValidatedAuthSession } from "@/lib/auth-session";
|
||||
import { getDefaultRouteForRole } from "@/store/auth";
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const redirect = async () => {
|
||||
const session = await getValidatedAuthSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
router.replace(session ? getDefaultRouteForRole(session.role) : "/login");
|
||||
};
|
||||
|
||||
void redirect();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, FileArchive, FileText, KeyRound, ListChecks, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { getStoredAuthSession } from "@/store/auth";
|
||||
|
||||
type ParamRow = [string, string, string];
|
||||
|
||||
type ApiDoc = {
|
||||
title: string;
|
||||
method: string;
|
||||
path: string;
|
||||
icon: LucideIcon;
|
||||
input: ParamRow[];
|
||||
output: ParamRow[];
|
||||
example: (baseUrl: string, key: string) => string;
|
||||
};
|
||||
|
||||
const docs: ApiDoc[] = [
|
||||
{
|
||||
title: "模型列表",
|
||||
method: "GET",
|
||||
path: "/v1/models",
|
||||
icon: ListChecks,
|
||||
input: [
|
||||
["Authorization", "header", "Bearer <auth-key>。"],
|
||||
],
|
||||
output: [
|
||||
["data", "array", "模型列表,包含 id、object、created、owned_by。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/models \\
|
||||
-H "Authorization: Bearer ${key}"`,
|
||||
},
|
||||
{
|
||||
title: "聊天补全",
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
icon: FileText,
|
||||
input: [
|
||||
["model", "string", "模型名,例如 gpt-5-mini,也可用于图片兼容场景。"],
|
||||
["messages", "array", "OpenAI 兼容消息数组。"],
|
||||
["stream", "boolean", "可选,是否流式返回。"],
|
||||
["n", "number", "可选,图片兼容场景会解析为生成数量。"],
|
||||
],
|
||||
output: [
|
||||
["id", "string", "响应 ID。"],
|
||||
["choices", "array", "OpenAI 兼容 choices。"],
|
||||
["usage", "object", "可选,token 使用信息。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"你好"}]}'`,
|
||||
},
|
||||
{
|
||||
title: "Responses",
|
||||
method: "POST",
|
||||
path: "/v1/responses",
|
||||
icon: FileText,
|
||||
input: [
|
||||
["model", "string", "模型名。"],
|
||||
["input", "string | array | object", "用户输入,图片生成会从中解析提示词。"],
|
||||
["tools", "array", "可选,Responses 工具定义。"],
|
||||
["stream", "boolean", "可选,是否流式返回。"],
|
||||
],
|
||||
output: [
|
||||
["id", "string", "响应 ID。"],
|
||||
["output", "array", "Responses 兼容输出。"],
|
||||
["status", "string", "响应状态。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/responses \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"model":"gpt-5-mini","input":"生成一张未来城市图片"}'`,
|
||||
},
|
||||
{
|
||||
title: "搜索",
|
||||
method: "POST",
|
||||
path: "/v1/search",
|
||||
icon: ListChecks,
|
||||
input: [
|
||||
["prompt", "string", "搜索问题或检索指令。"],
|
||||
],
|
||||
output: [
|
||||
["answer", "string", "搜索后的回答内容,具体字段以返回结果为准。"],
|
||||
["sources", "array", "可选,搜索引用来源。"],
|
||||
["_account_email", "string", "本次使用的账号邮箱。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/search \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"prompt":"搜索 chatgpt2api 最新使用方式"}'`,
|
||||
},
|
||||
{
|
||||
title: "图片生成",
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
icon: FileArchive,
|
||||
input: [
|
||||
["prompt", "string", "图片生成提示词。"],
|
||||
["model", "string", "可选,默认 gpt-image-2。"],
|
||||
["n", "number", "可选,生成数量,当前限制 1-4。"],
|
||||
["size", "string", "可选,图片尺寸。"],
|
||||
["quality", "string", "可选,默认 auto。"],
|
||||
["response_format", "string", "可选,默认 b64_json。"],
|
||||
],
|
||||
output: [
|
||||
["data", "array", "图片结果列表。"],
|
||||
["data[].b64_json", "string", "base64 图片内容。"],
|
||||
["data[].url", "string", "部分配置下返回图片 URL。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/images/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"model":"gpt-image-2","prompt":"一张极简产品海报","n":1}'`,
|
||||
},
|
||||
{
|
||||
title: "图片编辑",
|
||||
method: "POST",
|
||||
path: "/v1/images/edits",
|
||||
icon: FileArchive,
|
||||
input: [
|
||||
["image", "file | file[] | URL", "参考图,支持 multipart 上传,也支持 JSON 图片链接。"],
|
||||
["prompt", "string", "编辑提示词。"],
|
||||
["model", "string", "可选,默认 gpt-image-2。"],
|
||||
["n", "number", "可选,生成数量,当前限制 1-4。"],
|
||||
["size", "string", "可选,图片尺寸。"],
|
||||
["quality", "string", "可选,默认 auto。"],
|
||||
],
|
||||
output: [
|
||||
["data", "array", "编辑后的图片结果列表。"],
|
||||
["data[].b64_json", "string", "base64 图片内容。"],
|
||||
["data[].url", "string", "部分配置下返回图片 URL。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/images/edits \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-F "model=gpt-image-2" \\
|
||||
-F "prompt=改成赛博朋克夜景" \\
|
||||
-F "image=@./input.png"`,
|
||||
},
|
||||
{
|
||||
title: "创建 PPT 任务",
|
||||
method: "POST",
|
||||
path: "/v1/ppt/generations",
|
||||
icon: FileText,
|
||||
input: [
|
||||
["prompt", "string", "PPT 需求描述,可为空但建议填写完整主题、页数、风格和内容结构。"],
|
||||
["base64_images", "string[]", "可选,图片 data URL/base64,用作 PPT 参考素材。"],
|
||||
["client_task_id", "string", "可选,客户端幂等任务 ID;重复提交同 ID 会返回已有任务。"],
|
||||
],
|
||||
output: [
|
||||
["id / taskId", "string", "任务 ID,用于轮询状态。"],
|
||||
["status", "queued | running | success | error", "任务状态。"],
|
||||
["kind", "ppt", "任务类型。"],
|
||||
["created_at / updated_at", "string", "任务创建和更新时间。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/ppt/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"prompt":"制作一份 8 页以内的季度业务汇报 PPT","base64_images":[]}'`,
|
||||
},
|
||||
{
|
||||
title: "创建 PSD 任务",
|
||||
method: "POST",
|
||||
path: "/v1/psd/generations",
|
||||
icon: FileArchive,
|
||||
input: [
|
||||
["prompt", "string", "PSD 拆分与合成要求,例如保留图层、位置、背景和素材 zip。"],
|
||||
["base64_images", "string[]", "必填,至少一张图片 data URL/base64,作为 PSD 拆分源图。"],
|
||||
["client_task_id", "string", "可选,客户端幂等任务 ID。"],
|
||||
],
|
||||
output: [
|
||||
["id / taskId", "string", "任务 ID,用于轮询状态。"],
|
||||
["status", "queued | running | success | error", "任务状态。"],
|
||||
["kind", "psd", "任务类型。"],
|
||||
["error", "string", "失败时返回错误信息。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl ${baseUrl}/psd/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-d '{"prompt":"按原图位置拆分海报元素并合成可编辑 PSD","base64_images":["data:image/png;base64,..."]}'`,
|
||||
},
|
||||
{
|
||||
title: "任务状态查询",
|
||||
method: "GET",
|
||||
path: "/v1/editable-file-tasks?ids={taskId1,taskId2}",
|
||||
icon: ListChecks,
|
||||
input: [
|
||||
["ids", "string", "可选,逗号分隔任务 ID;不传则返回当前用户全部可编辑文件任务。"],
|
||||
],
|
||||
output: [
|
||||
["items", "array", "任务列表。成功任务的 result 内包含 primary_url 和 zip_url。"],
|
||||
["missing_ids", "string[]", "查询指定 ids 时,返回未找到的任务 ID。"],
|
||||
["result.primary_url", "string", "主文件下载地址。"],
|
||||
["result.zip_url", "string", "素材 zip 下载地址。"],
|
||||
],
|
||||
example: (baseUrl: string, key: string) => `curl "${baseUrl}/editable-file-tasks?ids=<task_id>" \\
|
||||
-H "Authorization: Bearer ${key}"`,
|
||||
},
|
||||
{
|
||||
title: "结果文件下载",
|
||||
method: "GET",
|
||||
path: "/files/{file_path}",
|
||||
icon: FileArchive,
|
||||
input: [
|
||||
["file_path", "string", "由任务 result.primary_url 或 result.zip_url 返回,通常不需要手动拼接。"],
|
||||
],
|
||||
output: [
|
||||
["binary", "file", "返回 pptx/psd/zip 文件流。"],
|
||||
],
|
||||
example: (baseUrl: string, _key: string) => `curl ${baseUrl.replace(/\/v1$/, "")}/files/<file_path> -o result.zip`,
|
||||
},
|
||||
];
|
||||
|
||||
const usableModels = ["gpt-image-2", "codex-gpt-image-2", "auto", "gpt-5", "gpt-5-1", "gpt-5-2", "gpt-5-3", "gpt-5-3-mini", "gpt-5-mini"];
|
||||
|
||||
function ParamTable({ rows }: { rows: ParamRow[] }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-stone-50 text-stone-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">参数</th>
|
||||
<th className="px-3 py-2 font-medium">类型</th>
|
||||
<th className="px-3 py-2 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100 bg-white">
|
||||
{rows.map(([name, type, desc]) => (
|
||||
<tr key={name}>
|
||||
<td className="px-3 py-2 font-mono text-stone-800">{name}</td>
|
||||
<td className="px-3 py-2 font-mono text-stone-500">{type}</td>
|
||||
<td className="px-3 py-2 text-stone-600">{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiDocsCard() {
|
||||
const [authKey, setAuthKey] = useState("");
|
||||
const serviceBaseUrl = webConfig.apiUrl.replace(/\/$/, "") || (typeof window !== "undefined" ? window.location.origin : "");
|
||||
const openAIBaseUrl = `${serviceBaseUrl}/v1`;
|
||||
const displayKey = authKey || "<当前密钥>";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getStoredAuthSession().then((session) => {
|
||||
if (active) setAuthKey(session?.key || "");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-5 p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-base font-semibold text-stone-900">
|
||||
<KeyRound className="size-5 text-stone-500" />
|
||||
接口接入说明
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-6 text-stone-500">
|
||||
第三方应用按 OpenAI 兼容接口接入;文件任务接口也使用同一套鉴权方式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1 rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||
<div className="text-xs text-stone-500">服务地址</div>
|
||||
<div className="break-all font-mono text-xs text-stone-800">{serviceBaseUrl}</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||
<div className="text-xs text-stone-500">Base URL(OpenAI)</div>
|
||||
<div className="break-all font-mono text-xs text-stone-800">{openAIBaseUrl}</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||
<div className="text-xs text-stone-500">API Key</div>
|
||||
<div className="break-all font-mono text-xs text-stone-800">{displayKey}</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||
<div className="text-xs text-stone-500">请求头</div>
|
||||
<div className="break-all font-mono text-xs text-stone-800">Authorization: Bearer {displayKey}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-stone-600">常用模型,也可请求 /v1/models 获取</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{usableModels.map((model) => (
|
||||
<span key={model} className="rounded-md border border-stone-200 bg-white px-2 py-1 font-mono text-xs text-stone-700">{model}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{docs.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<details key={item.path} className="group rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-stone-100 text-stone-600">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-stone-900">{item.title}</span>
|
||||
<span className="mt-1 block truncate font-mono text-xs text-stone-500">{item.method} {item.path}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-stone-400 transition group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-stone-700">输入参数</h3>
|
||||
<ParamTable rows={item.input} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-stone-700">输出参数</h3>
|
||||
<ParamTable rows={item.output} />
|
||||
</div>
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<h3 className="text-xs font-semibold text-stone-700">调用示例</h3>
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-all rounded-xl bg-stone-950 px-3 py-3 text-xs leading-5 text-stone-100">{item.example(openAIBaseUrl, displayKey)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { CloudUpload, Download, Eye, LoaderCircle, Play, RefreshCcw, Shield, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { fetchBackupDetail, getBackupDownloadUrl, type BackupDetail, type BackupInclude } from "@/lib/api";
|
||||
import { getStoredAuthKey } from "@/store/auth";
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function getFilenameFromContentDisposition(value: string | null) {
|
||||
const header = String(value || "").trim();
|
||||
if (!header) {
|
||||
return "";
|
||||
}
|
||||
const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
return utf8Match[1];
|
||||
}
|
||||
}
|
||||
const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i);
|
||||
return plainMatch?.[1] || "";
|
||||
}
|
||||
|
||||
const includeLabels: Array<{ key: keyof BackupInclude; label: string }> = [
|
||||
{ key: "config", label: "系统配置" },
|
||||
{ key: "cpa", label: "CPA 配置" },
|
||||
{ key: "sub2api", label: "Sub2API 配置" },
|
||||
{ key: "logs", label: "调度与调用日志" },
|
||||
{ key: "image_tasks", label: "图片任务记录" },
|
||||
{ key: "accounts_snapshot", label: "账号快照" },
|
||||
{ key: "auth_keys_snapshot", label: "用户密钥快照" },
|
||||
{ key: "images", label: "图片文件目录" },
|
||||
];
|
||||
|
||||
export function BackupSettingsCard() {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<BackupDetail | null>(null);
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const backups = useSettingsStore((state) => state.backups);
|
||||
const backupState = useSettingsStore((state) => state.backupState);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const isLoadingBackups = useSettingsStore((state) => state.isLoadingBackups);
|
||||
const isRunningBackup = useSettingsStore((state) => state.isRunningBackup);
|
||||
const deletingBackupKey = useSettingsStore((state) => state.deletingBackupKey);
|
||||
const isTestingBackup = useSettingsStore((state) => state.isTestingBackup);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
const loadBackups = useSettingsStore((state) => state.loadBackups);
|
||||
const runBackup = useSettingsStore((state) => state.runBackup);
|
||||
const removeBackup = useSettingsStore((state) => state.removeBackup);
|
||||
const testBackup = useSettingsStore((state) => state.testBackup);
|
||||
const setBackupField = useSettingsStore((state) => state.setBackupField);
|
||||
const setBackupInclude = useSettingsStore((state) => state.setBackupInclude);
|
||||
|
||||
if (isLoadingConfig) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="flex items-center justify-center p-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const backup = config?.backup;
|
||||
if (!backup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOpenDetail = async (key: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailOpen(true);
|
||||
try {
|
||||
const data = await fetchBackupDetail(key);
|
||||
setDetail(data.item);
|
||||
} catch (error) {
|
||||
setDetail(null);
|
||||
toast.error(error instanceof Error ? error.message : "读取备份详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (key: string, name: string) => {
|
||||
try {
|
||||
const authKey = await getStoredAuthKey();
|
||||
if (!authKey) {
|
||||
toast.error("当前登录态已失效,请重新登录后再下载");
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`${webConfig.apiUrl.replace(/\/$/, "")}${getBackupDownloadUrl(key)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = "下载备份失败";
|
||||
try {
|
||||
const data = await response.json() as { detail?: { error?: string }; error?: string; message?: string };
|
||||
message = data.detail?.error || data.error || data.message || message;
|
||||
} catch {
|
||||
message = response.status === 401 ? "登录已失效,请重新登录后再试" : message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const downloadName = getFilenameFromContentDisposition(response.headers.get("Content-Disposition")) || name || "backup.bin";
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = downloadName;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("备份下载已开始");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载备份失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<CloudUpload className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">R2 备份管理</h2>
|
||||
<p className="text-sm text-stone-500">将关键数据定时备份到 Cloudflare R2,支持可选加密、轮替、手动执行与历史清理。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={backupState?.running ? "warning" : backupState?.last_status === "success" ? "success" : "secondary"} className="rounded-md">
|
||||
{backupState?.running ? "备份中" : backupState?.last_status === "success" ? "最近成功" : backupState?.last_status === "error" ? "最近失败" : "未执行"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-600">
|
||||
账号与用户密钥会从当前存储后端导出逻辑快照,不依赖底层是 `json`、`sqlite`、`postgres` 还是 `git`。图片目录默认不备份,避免备份体积过大。
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(backup.enabled)}
|
||||
onCheckedChange={(checked) => setBackupField("enabled", Boolean(checked))}
|
||||
/>
|
||||
启用定时备份
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(backup.encrypt)}
|
||||
onCheckedChange={(checked) => setBackupField("encrypt", Boolean(checked))}
|
||||
/>
|
||||
启用备份加密
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Cloudflare Account ID</label>
|
||||
<Input value={String(backup.account_id || "")} onChange={(event) => setBackupField("account_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Bucket 名称</label>
|
||||
<Input value={String(backup.bucket || "")} onChange={(event) => setBackupField("bucket", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Access Key ID</label>
|
||||
<Input value={String(backup.access_key_id || "")} onChange={(event) => setBackupField("access_key_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Secret Access Key</label>
|
||||
<Input type="password" value={String(backup.secret_access_key || "")} onChange={(event) => setBackupField("secret_access_key", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">备份前缀</label>
|
||||
<Input value={String(backup.prefix || "")} onChange={(event) => setBackupField("prefix", event.target.value)} placeholder="backups" className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
<p className="text-xs text-stone-500">R2 内对象前缀,例如 `backups/prod`。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">定时备份间隔</label>
|
||||
<Input value={String(backup.interval_minutes || "")} onChange={(event) => setBackupField("interval_minutes", event.target.value)} placeholder="360" className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
<p className="text-xs text-stone-500">单位分钟,服务启动后会按此间隔自动轮询执行。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">保留备份数量</label>
|
||||
<Input value={String(backup.rotation_keep || "")} onChange={(event) => setBackupField("rotation_keep", event.target.value)} placeholder="10" className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
<p className="text-xs text-stone-500">成功上传后自动删除更旧的备份。填 `0` 表示不自动轮替。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">加密口令</label>
|
||||
<Input type="password" value={String(backup.passphrase || "")} onChange={(event) => setBackupField("passphrase", event.target.value)} placeholder={backup.encrypt ? "启用加密后必填" : "留空"} className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
<p className="text-xs text-stone-500">仅在启用加密时使用。请妥善保管,否则无法解密备份内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-stone-800">备份内容</div>
|
||||
<p className="mt-1 text-xs text-stone-500">按组件勾选需要进入备份包的数据。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{includeLabels.map((item) => (
|
||||
<label key={item.key} className="flex items-center gap-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(backup.include[item.key])}
|
||||
onCheckedChange={(checked) => setBackupInclude(item.key, Boolean(checked))}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">最近开始</div>
|
||||
<div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_started_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">最近完成</div>
|
||||
<div className="mt-1 font-medium text-stone-800">{formatDateTime(backupState?.last_finished_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">最近对象</div>
|
||||
<div className="mt-1 break-all font-medium text-stone-800">{backupState?.last_object_key || "—"}</div>
|
||||
</div>
|
||||
{backupState?.last_error ? (
|
||||
<div className="md:col-span-3">
|
||||
<div className="text-xs text-rose-500">最近错误</div>
|
||||
<div className="mt-1 break-all rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-rose-700">{backupState.last_error}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void testBackup()} disabled={isTestingBackup}>
|
||||
{isTestingBackup ? <LoaderCircle className="size-4 animate-spin" /> : <Shield className="size-4" />}
|
||||
测试连接
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void loadBackups()} disabled={isLoadingBackups}>
|
||||
{isLoadingBackups ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCcw className="size-4" />}
|
||||
刷新列表
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void runBackup()} disabled={isRunningBackup || Boolean(backupState?.running)}>
|
||||
{isRunningBackup || backupState?.running ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
立即备份
|
||||
</Button>
|
||||
<Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={() => void saveConfig()} disabled={isSavingConfig}>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <CloudUpload className="size-4" />}
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-stone-800">历史备份</h3>
|
||||
<p className="text-xs text-stone-500">支持查看对象信息并直接删除远端备份。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingBackups ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
|
||||
暂无远端备份记录。保存配置并执行一次手动备份后会出现在这里。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{backups.map((item) => {
|
||||
const isDeleting = deletingBackupKey === item.key;
|
||||
return (
|
||||
<div key={item.key} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="break-all text-sm font-medium text-stone-800">{item.name}</div>
|
||||
{item.encrypted ? <Badge variant="secondary" className="rounded-md">已加密</Badge> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
|
||||
<span>大小 {formatBytes(item.size)}</span>
|
||||
<span>更新时间 {formatDateTime(item.updated_at)}</span>
|
||||
<span className="break-all">对象 key {item.key}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void handleDownload(item.key, item.name)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700" onClick={() => void handleOpenDetail(item.key)}>
|
||||
<Eye className="size-4" />
|
||||
查看详情
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-700"
|
||||
onClick={() => void removeBackup(item.key)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden rounded-2xl border-white/80 bg-white">
|
||||
<DialogHeader className="shrink-0 border-b border-stone-200 pb-3">
|
||||
<DialogTitle>备份详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
|
||||
暂时无法读取备份详情;如果这是加密备份,请确认当前已填写正确的加密口令并先保存配置。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-sm text-stone-600 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">对象名称</div>
|
||||
<div className="mt-1 break-all font-medium text-stone-800">{detail.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">创建时间</div>
|
||||
<div className="mt-1 font-medium text-stone-800">{formatDateTime(detail.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">触发方式</div>
|
||||
<div className="mt-1 font-medium text-stone-800">{detail.trigger || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-500">应用版本</div>
|
||||
<div className="mt-1 font-medium text-stone-800">{detail.app_version || "—"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-xs text-stone-500">存储后端</div>
|
||||
<pre className="mt-1 overflow-x-auto rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs text-stone-700">{JSON.stringify(detail.storage_backend || {}, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-stone-800">文件内容</h4>
|
||||
<div className="space-y-2">
|
||||
{detail.files.map((item) => (
|
||||
<div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
|
||||
<div className="break-all font-medium text-stone-800">{item.name}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
|
||||
<span>{item.exists ? "已包含" : "缺失"}</span>
|
||||
<span>大小 {formatBytes(item.size)}</span>
|
||||
<span>{item.content_type || "application/octet-stream"}</span>
|
||||
<span className="break-all">SHA256 {item.sha256 || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-stone-800">快照内容</h4>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{detail.snapshots.map((item) => (
|
||||
<div key={item.name} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm">
|
||||
<div className="font-medium text-stone-800">{item.name}</div>
|
||||
<div className="mt-2 text-xs text-stone-500">记录数 {item.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, LoaderCircle, Save } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function BaseUrlCard() {
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
|
||||
const baseUrl = String(config?.base_url || "");
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<Globe className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">基础地址</h2>
|
||||
<p className="text-sm text-stone-500">设置 `CHATGPT2API_BASE_URL` 的本地配置值,保存后立即生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={baseUrl.trim() ? "success" : "secondary"} className="w-fit rounded-md px-2.5 py-1">
|
||||
{baseUrl.trim() ? "已配置" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isLoadingConfig ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">Base URL</label>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-sm text-stone-500">留空则使用环境变量或默认值,保存时会自动去掉首尾空格。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud, LoaderCircle, PlugZap, RefreshCw, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ImageStorageMode } from "@/lib/api";
|
||||
import { testProxy, type ProxyTestResult } from "@/lib/api";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function ConfigCard() {
|
||||
const [isTestingProxy, setIsTestingProxy] = useState(false);
|
||||
const [proxyTestResult, setProxyTestResult] = useState<ProxyTestResult | null>(null);
|
||||
const logLevelOptions = ["debug", "info", "warning", "error"];
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
|
||||
const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
|
||||
const setImagePollTimeoutSecs = useSettingsStore((state) => state.setImagePollTimeoutSecs);
|
||||
const setImageAccountConcurrency = useSettingsStore((state) => state.setImageAccountConcurrency);
|
||||
const setImageSettleEnabled = useSettingsStore((state) => state.setImageSettleEnabled);
|
||||
const setImageRemoveConversationAfterResult = useSettingsStore((state) => state.setImageRemoveConversationAfterResult);
|
||||
const setImageRemoveConversationAlways = useSettingsStore((state) => state.setImageRemoveConversationAlways);
|
||||
const setImageSettleSecs = useSettingsStore((state) => state.setImageSettleSecs);
|
||||
const setImageTimeoutRetrySecs = useSettingsStore((state) => state.setImageTimeoutRetrySecs);
|
||||
const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
|
||||
const setAutoRemoveRateLimitedAccounts = useSettingsStore((state) => state.setAutoRemoveRateLimitedAccounts);
|
||||
const setAutoReloginAfterRefresh = useSettingsStore((state) => state.setAutoReloginAfterRefresh);
|
||||
const setLogLevel = useSettingsStore((state) => state.setLogLevel);
|
||||
const setProxy = useSettingsStore((state) => state.setProxy);
|
||||
const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
|
||||
const setGlobalSystemPrompt = useSettingsStore((state) => state.setGlobalSystemPrompt);
|
||||
const setDefaultUpstreamModelName = useSettingsStore((state) => state.setDefaultUpstreamModelName);
|
||||
const setDefaultThinkingEffort = useSettingsStore((state) => state.setDefaultThinkingEffort);
|
||||
const setSensitiveWordsText = useSettingsStore((state) => state.setSensitiveWordsText);
|
||||
const setAIReviewField = useSettingsStore((state) => state.setAIReviewField);
|
||||
const setImageStorageField = useSettingsStore((state) => state.setImageStorageField);
|
||||
const testImageStorage = useSettingsStore((state) => state.testImageStorage);
|
||||
const syncImagesToWebDAV = useSettingsStore((state) => state.syncImagesToWebDAV);
|
||||
const isTestingImageStorage = useSettingsStore((state) => state.isTestingImageStorage);
|
||||
const isSyncingImageStorage = useSettingsStore((state) => state.isSyncingImageStorage);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
|
||||
const handleTestProxy = async () => {
|
||||
const candidate = String(config?.proxy || "").trim();
|
||||
if (!candidate) {
|
||||
toast.error("请先填写代理地址");
|
||||
return;
|
||||
}
|
||||
setIsTestingProxy(true);
|
||||
setProxyTestResult(null);
|
||||
try {
|
||||
const data = await testProxy(candidate);
|
||||
setProxyTestResult(data.result);
|
||||
if (data.result.ok) {
|
||||
toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
||||
} else {
|
||||
toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "测试代理失败");
|
||||
} finally {
|
||||
setIsTestingProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingConfig) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="flex items-center justify-center p-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-600">
|
||||
管理员登录密钥继续从部署配置读取,不再在此页面展示;如需分发给其他人,请在下方创建普通用户密钥。
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">账号刷新间隔</label>
|
||||
<Input
|
||||
value={String(config?.refresh_account_interval_minute || "")}
|
||||
onChange={(event) => setRefreshAccountIntervalMinute(event.target.value)}
|
||||
placeholder="分钟"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">单位分钟,控制账号自动刷新频率。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">全局代理</label>
|
||||
<Input
|
||||
value={String(config?.proxy || "")}
|
||||
onChange={(event) => {
|
||||
setProxy(event.target.value);
|
||||
setProxyTestResult(null);
|
||||
}}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-stone-500">
|
||||
留空表示不使用代理。支持协议://账号:密码@主机:端口,也可直接粘贴代理商的 主机:端口:账号:密码;示例 http://user:pass@127.0.0.1:7890、127.0.0.1:7890:user:pass。账号密码含 @/: 等特殊字符时需 URL 编码。
|
||||
</p>
|
||||
{proxyTestResult ? (
|
||||
<div
|
||||
className={`rounded-xl border px-3 py-2 text-xs leading-6 ${
|
||||
proxyTestResult.ok
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}`}
|
||||
>
|
||||
{proxyTestResult.ok
|
||||
? `代理可用:HTTP ${proxyTestResult.status},用时 ${proxyTestResult.latency_ms} ms`
|
||||
: `代理不可用:${proxyTestResult.error ?? "未知错误"}(用时 ${proxyTestResult.latency_ms} ms)`}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void handleTestProxy()}
|
||||
disabled={isTestingProxy}
|
||||
>
|
||||
{isTestingProxy ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
||||
测试代理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">图片访问地址</label>
|
||||
<Input
|
||||
value={String(config?.base_url || "")}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">用于生成图片结果的访问前缀地址。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">默认请求上游模型名称</label>
|
||||
<Input
|
||||
value={String(config?.default_upstream_model_name || "")}
|
||||
onChange={(event) => setDefaultUpstreamModelName(event.target.value)}
|
||||
placeholder="gpt-5-5"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">gpt-image-2 发起图片请求时使用的上游模型名称,默认 gpt-5-5。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">默认思考强度</label>
|
||||
<Select
|
||||
value={String(config?.default_thinking_effort || "auto")}
|
||||
onValueChange={(value) => setDefaultThinkingEffort(value as "auto" | "standard" | "extended" | "max")}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto(不传递)</SelectItem>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="extended">Extended</SelectItem>
|
||||
<SelectItem value="max">Max</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-stone-500">模型名称以 -standard、-extended 或 -max 结尾时,模型后缀优先。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">图片自动清理</label>
|
||||
<Input
|
||||
value={String(config?.image_retention_days || "")}
|
||||
onChange={(event) => setImageRetentionDays(event.target.value)}
|
||||
placeholder="30"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">自动删除多少天前的本地图片。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">图片轮询超时</label>
|
||||
<Input
|
||||
value={String(config?.image_poll_timeout_secs || "")}
|
||||
onChange={(event) => setImagePollTimeoutSecs(event.target.value)}
|
||||
placeholder="120"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">单位秒,等待上游图片结果的最长时间。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">单账号图片并发</label>
|
||||
<Input
|
||||
value={String(config?.image_account_concurrency || "")}
|
||||
onChange={(event) => setImageAccountConcurrency(event.target.value)}
|
||||
placeholder="1"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">限制每个账号同时处理的图片请求数量,默认 3。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.auto_remove_invalid_accounts)}
|
||||
onCheckedChange={(checked) => setAutoRemoveInvalidAccounts(Boolean(checked))}
|
||||
/>
|
||||
自动移除异常账号
|
||||
</label>
|
||||
<p className="text-xs text-stone-500">刷新时检测并移除</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.image_settle_enabled !== false)}
|
||||
onCheckedChange={(checked) => setImageSettleEnabled(Boolean(checked))}
|
||||
/>
|
||||
<span className="text-sm text-stone-700">图片二次确认机制</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">打开后能稍微提升获取图片的成功率。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.image_remove_conversation_after_result)}
|
||||
onCheckedChange={(checked) => setImageRemoveConversationAfterResult(Boolean(checked))}
|
||||
/>
|
||||
<span className="text-sm text-stone-700">出图后移除本地对话</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">成功拿到图片后,异步隐藏 ChatGPT 侧对应的本地对话记录。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.image_remove_conversation_always)}
|
||||
onCheckedChange={(checked) => setImageRemoveConversationAlways(Boolean(checked))}
|
||||
/>
|
||||
<span className="text-sm text-stone-700">没出图也移除本地对话</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">失败、超时或只返回文本时也一并隐藏对话记录(打开后包含出图成功的情况)。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">图片超时继续等待时间</label>
|
||||
<Input
|
||||
value={String(config?.image_timeout_retry_secs || "30")}
|
||||
onChange={(event) => setImageTimeoutRetrySecs(event.target.value)}
|
||||
placeholder="30"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">单位秒,超时后点击"继续等待"额外等待的时间。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">图片二次确认等待时间</label>
|
||||
<Input
|
||||
value={String(config?.image_settle_secs || "2.0")}
|
||||
onChange={(event) => setImageSettleSecs(event.target.value)}
|
||||
placeholder="2.0"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!config?.image_settle_enabled}
|
||||
/>
|
||||
<p className="text-xs text-stone-500">单位秒,找到图片后等待多久再次确认。需配合图片二次确认机制使用。</p>
|
||||
</div>
|
||||
<div className="flex gap-4 md:col-span-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.auto_relogin_after_refresh)}
|
||||
onCheckedChange={(checked) => setAutoReloginAfterRefresh(Boolean(checked))}
|
||||
/>
|
||||
刷新后自动尝试移除异常状态
|
||||
</label>
|
||||
<p className="text-xs text-stone-500">开启后刷新时自动尝试密码登录恢复账号。</p>
|
||||
</div>
|
||||
<div className="flex-1" aria-hidden="true" />
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.auto_remove_rate_limited_accounts)}
|
||||
onCheckedChange={(checked) => setAutoRemoveRateLimitedAccounts(Boolean(checked))}
|
||||
/>
|
||||
自动移除限流账号
|
||||
</label>
|
||||
<div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<div>
|
||||
<label className="text-sm text-stone-700">控制台日志级别</label>
|
||||
<p className="mt-1 text-xs text-stone-500">不选择时使用默认 info / warning / error。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{logLevelOptions.map((level) => (
|
||||
<label key={level} className="flex items-center gap-2 text-sm capitalize text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.log_levels?.includes(level))}
|
||||
onCheckedChange={(checked) => setLogLevel(level, Boolean(checked))}
|
||||
/>
|
||||
{level}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">全局附加指令</label>
|
||||
<Textarea
|
||||
value={String(config?.global_system_prompt || "")}
|
||||
onChange={(event) => setGlobalSystemPrompt(event.target.value)}
|
||||
placeholder="例如:先判断用户提示词是否合规;遇到违法、色情、暴力、仇恨等请求时拒绝回答。"
|
||||
className="min-h-28 rounded-xl border-stone-200 bg-white font-mono text-xs shadow-none"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">每次请求都会作为 system 消息注入,可用于审核用户提示词、避免违规内容、统一约束模型行为或固定角色设定。</p>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">敏感词</label>
|
||||
<Textarea
|
||||
value={(config?.sensitive_words || []).join("\n")}
|
||||
onChange={(event) => setSensitiveWordsText(event.target.value)}
|
||||
placeholder="一行一个,命中即拒绝"
|
||||
className="min-h-28 rounded-xl border-stone-200 bg-white font-mono text-xs shadow-none"
|
||||
/>
|
||||
<p className="text-xs text-stone-500">只要用户请求包含任意敏感词,就直接返回拒绝。</p>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-xl border border-stone-200 bg-white px-4 py-3 md:col-span-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex items-center gap-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.image_storage?.enabled)}
|
||||
onCheckedChange={(checked) => setImageStorageField("enabled", Boolean(checked))}
|
||||
/>
|
||||
启用 WebDAV 图片存储
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void testImageStorage()}
|
||||
disabled={isTestingImageStorage || !config?.image_storage?.enabled}
|
||||
>
|
||||
{isTestingImageStorage ? <LoaderCircle className="size-4 animate-spin" /> : <Cloud className="size-4" />}
|
||||
测试 WebDAV
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void syncImagesToWebDAV()}
|
||||
disabled={isSyncingImageStorage || !config?.image_storage?.enabled || config?.image_storage?.mode === "local"}
|
||||
>
|
||||
{isSyncingImageStorage ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
全量同步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs leading-6 text-stone-500">
|
||||
生成时只处理本次新图片;全量同步用于把已有本地图片补传到 WebDAV。
|
||||
</p>
|
||||
<div className="rounded-lg border border-stone-100 bg-stone-50 px-3 py-2 text-xs text-stone-600">
|
||||
当前待保存模式:
|
||||
<span className="ml-1 font-medium text-stone-900">
|
||||
{config?.image_storage?.enabled
|
||||
? config.image_storage.mode === "both"
|
||||
? "本机 + WebDAV"
|
||||
: config.image_storage.mode === "webdav"
|
||||
? "仅 WebDAV"
|
||||
: "仅本机"
|
||||
: "仅本机"}
|
||||
</span>
|
||||
<span className="ml-2 text-stone-400">修改后需要点保存,或通过测试/同步按钮自动保存。</span>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">保存模式</label>
|
||||
<Select
|
||||
value={String(config?.image_storage?.mode || "local")}
|
||||
onValueChange={(value) => setImageStorageField("mode", value as ImageStorageMode)}
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">仅本机</SelectItem>
|
||||
<SelectItem value="webdav">仅 WebDAV</SelectItem>
|
||||
<SelectItem value="both">本机 + WebDAV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">WebDAV URL</label>
|
||||
<Input
|
||||
value={String(config?.image_storage?.webdav_url || "")}
|
||||
onChange={(event) => setImageStorageField("webdav_url", event.target.value)}
|
||||
placeholder="https://example.com/dav"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">用户名</label>
|
||||
<Input
|
||||
value={String(config?.image_storage?.webdav_username || "")}
|
||||
onChange={(event) => setImageStorageField("webdav_username", event.target.value)}
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={String(config?.image_storage?.webdav_password || "")}
|
||||
onChange={(event) => setImageStorageField("webdav_password", event.target.value)}
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">远端目录</label>
|
||||
<Input
|
||||
value={String(config?.image_storage?.webdav_root_path || "")}
|
||||
onChange={(event) => setImageStorageField("webdav_root_path", event.target.value)}
|
||||
placeholder="chatgpt2api/images"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<label className="text-sm text-stone-700">公开访问前缀</label>
|
||||
<Input
|
||||
value={String(config?.image_storage?.public_base_url || "")}
|
||||
onChange={(event) => setImageStorageField("public_base_url", event.target.value)}
|
||||
placeholder="https://cdn.example.com/chatgpt2api/images"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!config?.image_storage?.enabled}
|
||||
/>
|
||||
<p className="text-xs text-stone-500">留空时返回本应用 /images/... 代理地址;填入后直接返回公开图片地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-xl border border-stone-200 bg-white px-4 py-3 md:col-span-2">
|
||||
<label className="flex items-center gap-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(config?.ai_review?.enabled)}
|
||||
onCheckedChange={(checked) => setAIReviewField("enabled", Boolean(checked))}
|
||||
/>
|
||||
启用 AI 审核
|
||||
</label>
|
||||
<p className="text-xs leading-6 text-stone-500">
|
||||
开启后会在请求进入生图账号前先调用审核模型,审核不通过会直接拒绝,减少违规提示词触达账号造成风控或封号的风险。
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Base URL</label>
|
||||
<Input value={String(config?.ai_review?.base_url || "")} onChange={(event) => setAIReviewField("base_url", event.target.value)} placeholder="https://api.openai.com" className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">API Key</label>
|
||||
<Input value={String(config?.ai_review?.api_key || "")} onChange={(event) => setAIReviewField("api_key", event.target.value)} placeholder="sk-..." className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Model</label>
|
||||
<Input value={String(config?.ai_review?.model || "")} onChange={(event) => setAIReviewField("model", event.target.value)} placeholder="gpt-5.4-mini" className="h-10 rounded-xl border-stone-200 bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">审核提示词</label>
|
||||
<Textarea value={String(config?.ai_review?.prompt || "")} onChange={(event) => setAIReviewField("prompt", event.target.value)} placeholder="判断用户请求是否允许。只回答 ALLOW 或 REJECT。" className="min-h-24 rounded-xl border-stone-200 bg-white text-xs shadow-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, EyeOff, Link2, LoaderCircle, Save, Unplug } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function CPAPoolDialog() {
|
||||
const dialogOpen = useSettingsStore((state) => state.dialogOpen);
|
||||
const editingPool = useSettingsStore((state) => state.editingPool);
|
||||
const formName = useSettingsStore((state) => state.formName);
|
||||
const formBaseUrl = useSettingsStore((state) => state.formBaseUrl);
|
||||
const formSecretKey = useSettingsStore((state) => state.formSecretKey);
|
||||
const showSecret = useSettingsStore((state) => state.showSecret);
|
||||
const isSavingPool = useSettingsStore((state) => state.isSavingPool);
|
||||
const setDialogOpen = useSettingsStore((state) => state.setDialogOpen);
|
||||
const setFormName = useSettingsStore((state) => state.setFormName);
|
||||
const setFormBaseUrl = useSettingsStore((state) => state.setFormBaseUrl);
|
||||
const setFormSecretKey = useSettingsStore((state) => state.setFormSecretKey);
|
||||
const setShowSecret = useSettingsStore((state) => state.setShowSecret);
|
||||
const savePool = useSettingsStore((state) => state.savePool);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>{editingPool ? "编辑连接" : "添加连接"}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{editingPool ? "修改 CPA 连接信息" : "添加一个新的 CLIProxyAPI 连接"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
||||
<Input
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="例如:主号池、备用池"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Link2 className="size-3.5" />
|
||||
CPA 地址
|
||||
</label>
|
||||
<Input
|
||||
value={formBaseUrl}
|
||||
onChange={(event) => setFormBaseUrl(event.target.value)}
|
||||
placeholder="http://your-cpa-host:8317"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Unplug className="size-3.5" />
|
||||
Management Secret Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={formSecretKey}
|
||||
onChange={(event) => setFormSecretKey(event.target.value)}
|
||||
placeholder={editingPool ? "留空则不修改密钥" : "CPA 管理密钥"}
|
||||
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isSavingPool}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void savePool()}
|
||||
disabled={isSavingPool}
|
||||
>
|
||||
{isSavingPool ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
{editingPool ? "保存修改" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Import, LoaderCircle, Pencil, Plus, ServerCog, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function CPAPoolsCard() {
|
||||
const pools = useSettingsStore((state) => state.pools);
|
||||
const isLoadingPools = useSettingsStore((state) => state.isLoadingPools);
|
||||
const deletingId = useSettingsStore((state) => state.deletingId);
|
||||
const loadingFilesId = useSettingsStore((state) => state.loadingFilesId);
|
||||
const openAddDialog = useSettingsStore((state) => state.openAddDialog);
|
||||
const openEditDialog = useSettingsStore((state) => state.openEditDialog);
|
||||
const deletePool = useSettingsStore((state) => state.deletePool);
|
||||
const browseFiles = useSettingsStore((state) => state.browseFiles);
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<ServerCog className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">CPA 连接管理</h2>
|
||||
<p className="text-sm text-stone-500">先配置连接,再按需查询远程账号并选择导入到本地号池。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pools.length > 0 ? <Badge className="rounded-md px-2.5 py-1">{pools.length} 个连接</Badge> : null}
|
||||
<Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={openAddDialog}>
|
||||
<Plus className="size-4" />
|
||||
添加连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingPools ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : pools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl bg-stone-50 px-6 py-10 text-center">
|
||||
<ServerCog className="size-8 text-stone-300" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-stone-600">暂无 CPA 连接</p>
|
||||
<p className="text-sm text-stone-400">点击「添加连接」保存你的 CLIProxyAPI 信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pools.map((pool) => {
|
||||
const isBusy = deletingId === pool.id || loadingFilesId === pool.id;
|
||||
const importJob = pool.import_job ?? null;
|
||||
const progress = importJob?.total
|
||||
? Math.round((importJob.completed / importJob.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={pool.id} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-stone-800">{pool.name || pool.base_url}</div>
|
||||
<div className="truncate text-xs text-stone-400">{pool.base_url}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
||||
onClick={() => openEditDialog(pool)}
|
||||
disabled={isBusy}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-stone-400 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
onClick={() => void deletePool(pool)}
|
||||
disabled={isBusy}
|
||||
title="删除"
|
||||
>
|
||||
{deletingId === pool.id ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
|
||||
onClick={() => void browseFiles(pool)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{loadingFilesId === pool.id ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Import className="size-3.5" />
|
||||
)}
|
||||
同步
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importJob ? (
|
||||
<div className="space-y-2 rounded-xl bg-stone-50 px-3 py-3">
|
||||
<div className="text-xs font-medium tracking-[0.16em] text-stone-400 uppercase">导入任务</div>
|
||||
<div className="rounded-lg border border-stone-200 bg-white px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-stone-700">
|
||||
状态 {importJob.status},已处理 {importJob.completed}/{importJob.total}
|
||||
</div>
|
||||
<div className="truncate text-xs text-stone-400">
|
||||
任务 {importJob.job_id.slice(0, 8)} · {importJob.created_at}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
importJob.status === "completed"
|
||||
? "success"
|
||||
: importJob.status === "failed"
|
||||
? "danger"
|
||||
: "info"
|
||||
}
|
||||
className="rounded-md"
|
||||
>
|
||||
{progress}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-stone-200">
|
||||
<div className="h-full rounded-full bg-stone-900 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-stone-500">
|
||||
<span>新增 {importJob.added}</span>
|
||||
<span>跳过 {importJob.skipped}</span>
|
||||
<span>刷新 {importJob.refreshed}</span>
|
||||
<span>失败 {importJob.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-500">
|
||||
<p className="font-medium text-stone-600">使用说明</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
<li>页面进入后先读取系统里已配置的 CPA 连接。</li>
|
||||
<li>点击某个连接的「同步」后,会先读取远程账号列表并展示给前端选择。</li>
|
||||
<li>确认选择后,后端后台下载对应 access_token 并导入本地号池。</li>
|
||||
<li>前端只轮询导入进度,不直接参与 download。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { Import, LoaderCircle, Search } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import { PAGE_SIZE_OPTIONS, useSettingsStore } from "../store";
|
||||
|
||||
export function ImportBrowserDialog() {
|
||||
const browserOpen = useSettingsStore((state) => state.browserOpen);
|
||||
const browserPool = useSettingsStore((state) => state.browserPool);
|
||||
const remoteFiles = useSettingsStore((state) => state.remoteFiles);
|
||||
const selectedNames = useSettingsStore((state) => state.selectedNames);
|
||||
const fileQuery = useSettingsStore((state) => state.fileQuery);
|
||||
const filePage = useSettingsStore((state) => state.filePage);
|
||||
const pageSize = useSettingsStore((state) => state.pageSize);
|
||||
const isStartingImport = useSettingsStore((state) => state.isStartingImport);
|
||||
const setBrowserOpen = useSettingsStore((state) => state.setBrowserOpen);
|
||||
const toggleFile = useSettingsStore((state) => state.toggleFile);
|
||||
const replaceSelectedNames = useSettingsStore((state) => state.replaceSelectedNames);
|
||||
const setFileQuery = useSettingsStore((state) => state.setFileQuery);
|
||||
const setFilePage = useSettingsStore((state) => state.setFilePage);
|
||||
const setPageSize = useSettingsStore((state) => state.setPageSize);
|
||||
const startImport = useSettingsStore((state) => state.startImport);
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const query = fileQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return remoteFiles;
|
||||
}
|
||||
return remoteFiles.filter((item) => {
|
||||
return item.email.toLowerCase().includes(query) || item.name.toLowerCase().includes(query);
|
||||
});
|
||||
}, [fileQuery, remoteFiles]);
|
||||
|
||||
const currentPageSize = Number(pageSize);
|
||||
const filePageCount = Math.max(1, Math.ceil(filteredFiles.length / currentPageSize));
|
||||
const safeFilePage = Math.min(filePage, filePageCount);
|
||||
const pagedFiles = filteredFiles.slice((safeFilePage - 1) * currentPageSize, safeFilePage * currentPageSize);
|
||||
const allFilteredSelected = filteredFiles.length > 0 && filteredFiles.every((item) => selectedNames.includes(item.name));
|
||||
|
||||
const toggleSelectAllFiltered = (checked: boolean) => {
|
||||
if (checked) {
|
||||
replaceSelectedNames([...selectedNames, ...filteredFiles.map((item) => item.name)]);
|
||||
return;
|
||||
}
|
||||
const filteredSet = new Set(filteredFiles.map((item) => item.name));
|
||||
replaceSelectedNames(selectedNames.filter((name) => !filteredSet.has(name)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={browserOpen} onOpenChange={setBrowserOpen}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[90vh] max-w-5xl rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>选择要导入的账号</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{browserPool ? `来自 ${browserPool.name || browserPool.base_url}` : "读取到的远程账号列表"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative min-w-[260px]">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-stone-400" />
|
||||
<Input
|
||||
value={fileQuery}
|
||||
onChange={(event) => setFileQuery(event.target.value)}
|
||||
placeholder="搜索 email 或文件名"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={pageSize} onValueChange={(value) => setPageSize(value as (typeof PAGE_SIZE_OPTIONS)[number])}>
|
||||
<SelectTrigger className="h-10 w-[120px] rounded-xl border-stone-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => toggleSelectAllFiltered(!allFilteredSelected)}
|
||||
>
|
||||
{allFilteredSelected ? "取消全选" : "全选筛选结果"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3 text-sm text-stone-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox checked={allFilteredSelected} onCheckedChange={(checked) => toggleSelectAllFiltered(Boolean(checked))} />
|
||||
<span>筛选结果 {filteredFiles.length} 个</span>
|
||||
</div>
|
||||
<span>已选 {selectedNames.length} 个</span>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto">
|
||||
{pagedFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-stone-400">没有匹配的远程账号</div>
|
||||
) : (
|
||||
<div className="divide-y divide-stone-100">
|
||||
{pagedFiles.map((item) => (
|
||||
<label key={item.name} className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-stone-50">
|
||||
<Checkbox
|
||||
checked={selectedNames.includes(item.name)}
|
||||
onCheckedChange={(checked) => toggleFile(item.name, Boolean(checked))}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-stone-700">{item.email || item.name}</div>
|
||||
<div className="truncate text-xs text-stone-400">{item.name}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-stone-500">
|
||||
<span>
|
||||
第 {filteredFiles.length === 0 ? 0 : (safeFilePage - 1) * currentPageSize + 1} -{" "}
|
||||
{Math.min(safeFilePage * currentPageSize, filteredFiles.length)} 条,共 {filteredFiles.length} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
||||
onClick={() => setFilePage(Math.max(1, safeFilePage - 1))}
|
||||
disabled={safeFilePage <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
{safeFilePage}/{filePageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
||||
onClick={() => setFilePage(Math.min(filePageCount, safeFilePage + 1))}
|
||||
disabled={safeFilePage >= filePageCount}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setBrowserOpen(false)}
|
||||
disabled={isStartingImport}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void startImport()}
|
||||
disabled={isStartingImport || selectedNames.length === 0}
|
||||
>
|
||||
{isStartingImport ? <LoaderCircle className="size-4 animate-spin" /> : <Import className="size-4" />}
|
||||
导入选中账号
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Cookie, LoaderCircle, PlugZap, Save, ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
testProxy,
|
||||
testProxyClearance,
|
||||
type ClearanceTestResult,
|
||||
type ProxyRuntimeClearanceMode,
|
||||
type ProxyRuntimeEgressMode,
|
||||
type ProxyTestResult,
|
||||
} from "@/lib/api";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function ProxyRuntimeCard() {
|
||||
const [isTestingProxy, setIsTestingProxy] = useState(false);
|
||||
const [isTestingClearance, setIsTestingClearance] = useState(false);
|
||||
const [proxyResult, setProxyResult] = useState<ProxyTestResult | null>(null);
|
||||
const [clearanceResult, setClearanceResult] = useState<ClearanceTestResult | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState("https://chatgpt.com");
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
const setProxyRuntimeField = useSettingsStore((state) => state.setProxyRuntimeField);
|
||||
const setProxyRuntimeClearanceField = useSettingsStore((state) => state.setProxyRuntimeClearanceField);
|
||||
const setProxyRuntimeStatusCodesText = useSettingsStore((state) => state.setProxyRuntimeStatusCodesText);
|
||||
|
||||
if (isLoadingConfig || !config?.proxy_runtime) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="flex items-center justify-center p-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = config.proxy_runtime;
|
||||
const clearance = runtime.clearance;
|
||||
const runtimeEnabled = Boolean(runtime.enabled);
|
||||
const clearanceMode = clearance.mode;
|
||||
const hasStoredClearance = Boolean(clearance.has_cf_cookies || clearance.has_cf_clearance);
|
||||
|
||||
const handleTestRuntimeProxy = async () => {
|
||||
setIsTestingProxy(true);
|
||||
setProxyResult(null);
|
||||
try {
|
||||
const saved = await saveConfig();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
const data = await testProxy();
|
||||
setProxyResult(data.result);
|
||||
if (data.result.ok) {
|
||||
toast.success(`清障代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
||||
} else {
|
||||
toast.error(`清障代理不可用:${data.result.error ?? "未知错误"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "测试清障代理失败");
|
||||
} finally {
|
||||
setIsTestingProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestClearance = async () => {
|
||||
setIsTestingClearance(true);
|
||||
setClearanceResult(null);
|
||||
try {
|
||||
const saved = await saveConfig();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
const data = await testProxyClearance(targetUrl.trim() || "https://chatgpt.com");
|
||||
setClearanceResult(data.result);
|
||||
if (data.result.ok) {
|
||||
toast.success(`Clearance 获取成功(${data.result.latency_ms} ms)`);
|
||||
} else {
|
||||
toast.error(`Clearance 获取失败:${data.result.error ?? data.result.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "测试 Clearance 失败");
|
||||
} finally {
|
||||
setIsTestingClearance(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-5 p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-base font-semibold text-stone-900">
|
||||
<PlugZap className="size-5 text-stone-500" />
|
||||
FlareSolverr 清障
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-6 text-stone-500">
|
||||
默认关闭。用于上游请求遇到 Cloudflare 拦截后获取 clearance,可配合 WARP / Privoxy 代理链路重试。
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${runtimeEnabled ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{runtimeEnabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-xs leading-6 text-stone-600">
|
||||
代理优先级:账号代理 > FlareSolverr 代理链路 > 显式代理 > 全局代理。Cookie / cf_clearance 不会在接口响应中明文返回。
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-6 text-amber-800">
|
||||
<AlertTriangle className="mt-1 size-4 shrink-0" />
|
||||
<span>使用 FlareSolverr 模式前,请先通过 Docker 启动 flaresolverr、privoxy、warp-proxy 等相关容器;容器内 URL 通常填写 http://flaresolverr:8191。</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700 md:col-span-2">
|
||||
<Checkbox
|
||||
checked={runtimeEnabled}
|
||||
onCheckedChange={(checked) => setProxyRuntimeField("enabled", Boolean(checked))}
|
||||
/>
|
||||
启用 FlareSolverr 清障
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">出站模式</label>
|
||||
<Select
|
||||
value={runtime.egress_mode}
|
||||
onValueChange={(value) => setProxyRuntimeField("egress_mode", value as ProxyRuntimeEgressMode)}
|
||||
disabled={!runtimeEnabled}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direct">直连</SelectItem>
|
||||
<SelectItem value="single_proxy">单代理/WARP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-stone-500">WARP compose 默认使用 single_proxy。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">清障代理 URL</label>
|
||||
<Input
|
||||
value={runtime.proxy_url}
|
||||
onChange={(event) => setProxyRuntimeField("proxy_url", event.target.value)}
|
||||
placeholder="http://privoxy:8118"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || runtime.egress_mode !== "single_proxy"}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-stone-500">
|
||||
支持 http/https/socks5/socks5h,socks5 会转为 socks5h。带认证格式:协议://账号:密码@主机:端口,也可直接粘贴 主机:端口:账号:密码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">资源代理 URL</label>
|
||||
<Input
|
||||
value={runtime.resource_proxy_url}
|
||||
onChange={(event) => setProxyRuntimeField("resource_proxy_url", event.target.value)}
|
||||
placeholder="留空则复用清障代理"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || runtime.egress_mode !== "single_proxy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">重置会话状态码</label>
|
||||
<Input
|
||||
value={runtime.reset_session_status_codes.join(",")}
|
||||
onChange={(event) => setProxyRuntimeStatusCodesText(event.target.value)}
|
||||
placeholder="403"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled}
|
||||
/>
|
||||
<p className="text-xs text-stone-500">默认 403,只对 Cloudflare/挑战类错误触发。</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(runtime.skip_ssl_verify)}
|
||||
onCheckedChange={(checked) => setProxyRuntimeField("skip_ssl_verify", Boolean(checked))}
|
||||
disabled={!runtimeEnabled}
|
||||
/>
|
||||
跳过 SSL 校验
|
||||
</label>
|
||||
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void handleTestRuntimeProxy()}
|
||||
disabled={isTestingProxy || !runtimeEnabled}
|
||||
>
|
||||
{isTestingProxy ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
||||
测试当前清障代理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{proxyResult ? (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs leading-6 md:col-span-2 ${proxyResult.ok ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800"}`}>
|
||||
{proxyResult.ok
|
||||
? `代理可用:HTTP ${proxyResult.status},用时 ${proxyResult.latency_ms} ms,来源 ${proxyResult.proxy_source ?? "unknown"}`
|
||||
: `代理不可用:${proxyResult.error ?? "未知错误"}(用时 ${proxyResult.latency_ms} ms)`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-800">
|
||||
<Cookie className="size-4 text-stone-500" />
|
||||
Cloudflare Clearance
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${clearance.enabled ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{clearance.enabled ? clearanceMode : "disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">Clearance 模式</label>
|
||||
<Select
|
||||
value={clearanceMode}
|
||||
onValueChange={(value) => {
|
||||
const mode = value as ProxyRuntimeClearanceMode;
|
||||
setProxyRuntimeClearanceField("mode", mode);
|
||||
setProxyRuntimeClearanceField("enabled", mode !== "none");
|
||||
}}
|
||||
disabled={!runtimeEnabled}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不启用</SelectItem>
|
||||
<SelectItem value="manual">手动 Cookie</SelectItem>
|
||||
<SelectItem value="flaresolverr">FlareSolverr</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">FlareSolverr URL</label>
|
||||
<Input
|
||||
value={clearance.flaresolverr_url}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("flaresolverr_url", event.target.value)}
|
||||
placeholder="http://flaresolverr:8191"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || clearanceMode !== "flaresolverr"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">User-Agent</label>
|
||||
<Input
|
||||
value={clearance.user_agent}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("user_agent", event.target.value)}
|
||||
className="h-10 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
||||
disabled={!runtimeEnabled || clearanceMode === "none"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">超时秒数</label>
|
||||
<Input
|
||||
value={String(clearance.timeout_sec)}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("timeout_sec", event.target.value)}
|
||||
placeholder="60"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || clearanceMode === "none"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">刷新间隔秒数</label>
|
||||
<Input
|
||||
value={String(clearance.refresh_interval)}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("refresh_interval", event.target.value)}
|
||||
placeholder="3600"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || clearanceMode === "none"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">手动 Cookie</label>
|
||||
<Textarea
|
||||
value={clearance.cf_cookies}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("cf_cookies", event.target.value)}
|
||||
placeholder="可选:foo=bar; cf_clearance=..."
|
||||
className="min-h-24 rounded-xl border-stone-200 bg-white font-mono text-xs shadow-none"
|
||||
disabled={!runtimeEnabled || clearanceMode !== "manual"}
|
||||
/>
|
||||
<p className="text-xs text-stone-500">
|
||||
{hasStoredClearance ? "服务端已保存过 Cookie/clearance;留空保存不会清空已有值。" : "留空表示不使用手动 Cookie。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-sm text-stone-700">单独 cf_clearance</label>
|
||||
<Input
|
||||
value={clearance.cf_clearance}
|
||||
onChange={(event) => setProxyRuntimeClearanceField("cf_clearance", event.target.value)}
|
||||
placeholder="可选:只填写 cf_clearance 值"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
||||
disabled={!runtimeEnabled || clearanceMode !== "manual"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(clearance.warm_up_on_start)}
|
||||
onCheckedChange={(checked) => setProxyRuntimeClearanceField("warm_up_on_start", Boolean(checked))}
|
||||
disabled={!runtimeEnabled || clearanceMode === "none"}
|
||||
/>
|
||||
启动时预热 Clearance
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">测试目标 URL</label>
|
||||
<Input
|
||||
value={targetUrl}
|
||||
onChange={(event) => setTargetUrl(event.target.value)}
|
||||
placeholder="https://chatgpt.com"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
disabled={!runtimeEnabled || clearanceMode === "none"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end md:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void handleTestClearance()}
|
||||
disabled={isTestingClearance || !runtimeEnabled || clearanceMode === "none"}
|
||||
>
|
||||
{isTestingClearance ? <LoaderCircle className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
|
||||
测试 Clearance
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clearanceResult ? (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs leading-6 md:col-span-2 ${clearanceResult.ok ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800"}`}>
|
||||
{clearanceResult.ok
|
||||
? `Clearance 可用:${clearanceResult.has_cookies ? "已获取 Cookie" : "无 Cookie"},用时 ${clearanceResult.latency_ms} ms`
|
||||
: `Clearance 不可用:${clearanceResult.error ?? clearanceResult.status}(用时 ${clearanceResult.latency_ms} ms)`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2, LoaderCircle, PlugZap, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { testProxy, type ProxyTestResult } from "@/lib/api";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function ProxySettingsCard() {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const setProxy = useSettingsStore((state) => state.setProxy);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
|
||||
const proxy = config?.proxy ?? "";
|
||||
|
||||
const handleTest = async () => {
|
||||
const candidate = proxy.trim();
|
||||
if (!candidate) {
|
||||
toast.error("请先填写代理地址");
|
||||
return;
|
||||
}
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const data = await testProxy(candidate);
|
||||
setTestResult(data.result);
|
||||
if (data.result.ok) {
|
||||
toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
||||
} else {
|
||||
toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "测试代理失败");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<Link2 className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">全局代理</h2>
|
||||
<p className="text-sm text-stone-500">为系统中的出站请求配置统一代理,保存后会立即生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={proxy.trim() ? "success" : "secondary"} className="w-fit rounded-md px-2.5 py-1">
|
||||
{proxy.trim() ? "已配置" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isLoadingConfig ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">代理地址</label>
|
||||
<Input
|
||||
value={proxy}
|
||||
onChange={(event) => {
|
||||
setProxy(event.target.value);
|
||||
setTestResult(null);
|
||||
}}
|
||||
placeholder="http://user:pass@127.0.0.1:7890"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-sm text-stone-500">
|
||||
留空表示不使用代理。请按完整地址填写,例如 `http://127.0.0.1:7890`、`http://用户名:密码@127.0.0.1:7890` 或 `socks5://127.0.0.1:7890`。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{testResult ? (
|
||||
<div
|
||||
className={`rounded-xl border px-4 py-3 text-sm leading-6 ${
|
||||
testResult.ok
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok
|
||||
? `代理可用:HTTP ${testResult.status},用时 ${testResult.latency_ms} ms`
|
||||
: `代理不可用:${testResult.error ?? "未知错误"}(用时 ${testResult.latency_ms} ms)`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white px-5 text-stone-700"
|
||||
onClick={() => void handleTest()}
|
||||
disabled={isTesting || isLoadingConfig}
|
||||
>
|
||||
{isTesting ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
||||
测试代理
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LoaderCircle,
|
||||
PlugZap,
|
||||
Save,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
fetchProxy,
|
||||
testProxy,
|
||||
updateProxy,
|
||||
type ProxySettings,
|
||||
type ProxyTestResult,
|
||||
} from "@/lib/api";
|
||||
|
||||
export function ProxySettingsCard() {
|
||||
const didLoadRef = useRef(false);
|
||||
const [settings, setSettings] = useState<ProxySettings>({ enabled: false, url: "" });
|
||||
const [formUrl, setFormUrl] = useState("");
|
||||
const [formEnabled, setFormEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchProxy();
|
||||
setSettings(data.proxy);
|
||||
setFormUrl(data.proxy.url);
|
||||
setFormEnabled(data.proxy.enabled);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载代理配置失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (didLoadRef.current) {
|
||||
return;
|
||||
}
|
||||
didLoadRef.current = true;
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const urlChanged = formUrl.trim() !== settings.url;
|
||||
const enabledChanged = formEnabled !== settings.enabled;
|
||||
const dirty = urlChanged || enabledChanged;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (formEnabled && !formUrl.trim()) {
|
||||
toast.error("启用代理时必须填写代理地址");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload: { enabled?: boolean; url?: string } = {};
|
||||
if (enabledChanged) payload.enabled = formEnabled;
|
||||
if (urlChanged) payload.url = formUrl.trim();
|
||||
const data = await updateProxy(payload);
|
||||
setSettings(data.proxy);
|
||||
setFormUrl(data.proxy.url);
|
||||
setFormEnabled(data.proxy.enabled);
|
||||
toast.success("代理配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const candidate = formUrl.trim();
|
||||
if (!candidate) {
|
||||
toast.error("请先填写代理地址");
|
||||
return;
|
||||
}
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const data = await testProxy(candidate);
|
||||
setTestResult(data.result);
|
||||
if (data.result.ok) {
|
||||
toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
||||
} else {
|
||||
toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "测试代理失败");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<Wifi className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">上游代理配置</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
为 chatgpt.com 的请求配置出网代理,适合国内服务器部署;Sub2API / CPA 请求不受影响。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
|
||||
checked={formEnabled}
|
||||
onChange={(event) => setFormEnabled(event.target.checked)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium text-stone-800">启用代理</div>
|
||||
<div className="text-sm text-stone-500">
|
||||
关闭后 chatgpt.com 请求会直连。保存后立即生效,无需重启。
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<PlugZap className="size-3.5" />
|
||||
代理地址
|
||||
</label>
|
||||
<Input
|
||||
value={formUrl}
|
||||
onChange={(event) => setFormUrl(event.target.value)}
|
||||
placeholder="http://user:pass@host:port 或 socks5://host:port"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
||||
/>
|
||||
<div className="text-xs text-stone-400">
|
||||
支持 <code className="font-mono">http / https / socks4 / socks5 / socks5h</code>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult ? (
|
||||
<div
|
||||
className={`rounded-xl border px-4 py-3 text-sm leading-6 ${
|
||||
testResult.ok
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<>
|
||||
代理可用:HTTP {testResult.status},用时 {testResult.latency_ms} ms
|
||||
</>
|
||||
) : (
|
||||
<>代理不可用:{testResult.error ?? "未知错误"}(用时 {testResult.latency_ms} ms)</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving || !dirty}
|
||||
>
|
||||
{isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white px-5 text-stone-700"
|
||||
onClick={() => void handleTest()}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
||||
测试连通
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
export function SettingsHeader() {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-stone-500 uppercase">Settings</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">设置</h1>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Import,
|
||||
Layers,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
Mail,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
Search,
|
||||
ServerCog,
|
||||
Trash2,
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
createSub2APIServer,
|
||||
deleteSub2APIServer,
|
||||
fetchSub2APIServerAccounts,
|
||||
fetchSub2APIServerGroups,
|
||||
fetchSub2APIServers,
|
||||
startSub2APIImport,
|
||||
updateSub2APIServer,
|
||||
type Sub2APIRemoteAccount,
|
||||
type Sub2APIRemoteGroup,
|
||||
type Sub2APIServer,
|
||||
} from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
||||
|
||||
type AuthMode = "password" | "api_key";
|
||||
|
||||
function normalizeAccounts(items: Sub2APIRemoteAccount[]) {
|
||||
const seen = new Set<string>();
|
||||
const accounts: Sub2APIRemoteAccount[] = [];
|
||||
for (const item of items) {
|
||||
const id = String(item.id || "").trim();
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
accounts.push({
|
||||
id,
|
||||
name: String(item.name || "").trim(),
|
||||
email: String(item.email || "").trim(),
|
||||
plan_type: String(item.plan_type || "").trim(),
|
||||
status: String(item.status || "").trim(),
|
||||
expires_at: String(item.expires_at || "").trim(),
|
||||
has_refresh_token: Boolean(item.has_refresh_token),
|
||||
});
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export function Sub2APIConnections() {
|
||||
const didLoadRef = useRef(false);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [servers, setServers] = useState<Sub2APIServer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<Sub2APIServer | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formBaseUrl, setFormBaseUrl] = useState("");
|
||||
const [formEmail, setFormEmail] = useState("");
|
||||
const [formPassword, setFormPassword] = useState("");
|
||||
const [formApiKey, setFormApiKey] = useState("");
|
||||
const [formGroupId, setFormGroupId] = useState("");
|
||||
const [authMode, setAuthMode] = useState<AuthMode>("password");
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [remoteGroups, setRemoteGroups] = useState<Sub2APIRemoteGroup[] | null>(null);
|
||||
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [loadingAccountsId, setLoadingAccountsId] = useState<string | null>(null);
|
||||
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
const [browserServer, setBrowserServer] = useState<Sub2APIServer | null>(null);
|
||||
const [remoteAccounts, setRemoteAccounts] = useState<Sub2APIRemoteAccount[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [accountQuery, setAccountQuery] = useState("");
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>("100");
|
||||
const [isStartingImport, setIsStartingImport] = useState(false);
|
||||
|
||||
const loadServers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchSub2APIServers();
|
||||
setServers(data.servers);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载 Sub2API 连接失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (didLoadRef.current) {
|
||||
return;
|
||||
}
|
||||
didLoadRef.current = true;
|
||||
void loadServers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hasRunningJobs = servers.some(
|
||||
(server) => server.import_job?.status === "pending" || server.import_job?.status === "running",
|
||||
);
|
||||
if (!hasRunningJobs) {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
void fetchSub2APIServers()
|
||||
.then((data) => {
|
||||
setServers(data.servers);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : "查询导入进度失败");
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [servers]);
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingServer(null);
|
||||
setFormName("");
|
||||
setFormBaseUrl("");
|
||||
setFormEmail("");
|
||||
setFormPassword("");
|
||||
setFormApiKey("");
|
||||
setFormGroupId("");
|
||||
setAuthMode("password");
|
||||
setShowSecret(false);
|
||||
setRemoteGroups(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (server: Sub2APIServer) => {
|
||||
setEditingServer(server);
|
||||
setFormName(server.name);
|
||||
setFormBaseUrl(server.base_url);
|
||||
setFormEmail(server.email);
|
||||
setFormPassword("");
|
||||
setFormApiKey("");
|
||||
setFormGroupId(server.group_id || "");
|
||||
setAuthMode(server.has_api_key ? "api_key" : "password");
|
||||
setShowSecret(false);
|
||||
setRemoteGroups(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleFetchGroups = async () => {
|
||||
if (!editingServer) {
|
||||
toast.error("请先保存连接后再拉取分组");
|
||||
return;
|
||||
}
|
||||
setIsLoadingGroups(true);
|
||||
try {
|
||||
const data = await fetchSub2APIServerGroups(editingServer.id);
|
||||
setRemoteGroups(data.groups);
|
||||
if (data.groups.length === 0) {
|
||||
toast.message("远端没有配置分组");
|
||||
} else {
|
||||
toast.success(`读取到 ${data.groups.length} 个分组`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "拉取分组失败");
|
||||
} finally {
|
||||
setIsLoadingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formBaseUrl.trim()) {
|
||||
toast.error("请输入 Sub2API 地址");
|
||||
return;
|
||||
}
|
||||
if (authMode === "password") {
|
||||
if (!formEmail.trim()) {
|
||||
toast.error("请输入管理员邮箱");
|
||||
return;
|
||||
}
|
||||
if (!editingServer && !formPassword.trim()) {
|
||||
toast.error("请输入管理员密码");
|
||||
return;
|
||||
}
|
||||
} else if (!editingServer && !formApiKey.trim()) {
|
||||
toast.error("请输入 Admin API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingServer) {
|
||||
const updates: Parameters<typeof updateSub2APIServer>[1] = {
|
||||
name: formName.trim(),
|
||||
base_url: formBaseUrl.trim(),
|
||||
group_id: formGroupId.trim(),
|
||||
};
|
||||
if (authMode === "password") {
|
||||
updates.email = formEmail.trim();
|
||||
if (formPassword.trim()) {
|
||||
updates.password = formPassword.trim();
|
||||
}
|
||||
updates.api_key = "";
|
||||
} else {
|
||||
if (formApiKey.trim()) {
|
||||
updates.api_key = formApiKey.trim();
|
||||
}
|
||||
updates.email = "";
|
||||
updates.password = "";
|
||||
}
|
||||
const data = await updateSub2APIServer(editingServer.id, updates);
|
||||
setServers(data.servers);
|
||||
toast.success("连接已更新");
|
||||
} else {
|
||||
const data = await createSub2APIServer({
|
||||
name: formName.trim(),
|
||||
base_url: formBaseUrl.trim(),
|
||||
email: authMode === "password" ? formEmail.trim() : "",
|
||||
password: authMode === "password" ? formPassword.trim() : "",
|
||||
api_key: authMode === "api_key" ? formApiKey.trim() : "",
|
||||
group_id: formGroupId.trim(),
|
||||
});
|
||||
setServers(data.servers);
|
||||
toast.success("连接已添加");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (server: Sub2APIServer) => {
|
||||
setDeletingId(server.id);
|
||||
try {
|
||||
const data = await deleteSub2APIServer(server.id);
|
||||
setServers(data.servers);
|
||||
toast.success("连接已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseAccounts = async (server: Sub2APIServer) => {
|
||||
setLoadingAccountsId(server.id);
|
||||
try {
|
||||
const data = await fetchSub2APIServerAccounts(server.id);
|
||||
const accounts = normalizeAccounts(data.accounts);
|
||||
setBrowserServer(server);
|
||||
setRemoteAccounts(accounts);
|
||||
setSelectedIds([]);
|
||||
setAccountQuery("");
|
||||
setAccountPage(1);
|
||||
setBrowserOpen(true);
|
||||
toast.success(`读取成功,共 ${accounts.length} 个 OpenAI 账号`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "读取 Sub2API 账号失败");
|
||||
} finally {
|
||||
setLoadingAccountsId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const query = accountQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return remoteAccounts;
|
||||
}
|
||||
return remoteAccounts.filter((item) => {
|
||||
return (
|
||||
item.email.toLowerCase().includes(query) ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.plan_type.toLowerCase().includes(query) ||
|
||||
item.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [accountQuery, remoteAccounts]);
|
||||
|
||||
const currentPageSize = Number(pageSize);
|
||||
const accountPageCount = Math.max(1, Math.ceil(filteredAccounts.length / currentPageSize));
|
||||
const safeAccountPage = Math.min(accountPage, accountPageCount);
|
||||
const pagedAccounts = filteredAccounts.slice(
|
||||
(safeAccountPage - 1) * currentPageSize,
|
||||
safeAccountPage * currentPageSize,
|
||||
);
|
||||
const allFilteredSelected =
|
||||
filteredAccounts.length > 0 && filteredAccounts.every((item) => selectedIds.includes(item.id));
|
||||
|
||||
const toggleAccount = (id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, id]));
|
||||
}
|
||||
return prev.filter((item) => item !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelectAllFiltered = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(Array.from(new Set([...selectedIds, ...filteredAccounts.map((item) => item.id)])));
|
||||
return;
|
||||
}
|
||||
const filteredSet = new Set(filteredAccounts.map((item) => item.id));
|
||||
setSelectedIds((prev) => prev.filter((id) => !filteredSet.has(id)));
|
||||
};
|
||||
|
||||
const handleStartImport = async () => {
|
||||
if (!browserServer) {
|
||||
return;
|
||||
}
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error("请先选择要导入的账号");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingImport(true);
|
||||
try {
|
||||
const result = await startSub2APIImport(browserServer.id, selectedIds);
|
||||
setServers((prev) =>
|
||||
prev.map((server) =>
|
||||
server.id === browserServer.id ? { ...server, import_job: result.import_job } : server,
|
||||
),
|
||||
);
|
||||
setBrowserOpen(false);
|
||||
toast.success("导入任务已启动");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "启动导入失败");
|
||||
} finally {
|
||||
setIsStartingImport(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<ServerCog className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Sub2API 连接管理</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
配置 Sub2API 服务器后,可查询其中的 OpenAI OAuth 账号并批量导入本地号池。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{servers.length > 0 ? <Badge className="rounded-md px-2.5 py-1">{servers.length} 个连接</Badge> : null}
|
||||
<Button
|
||||
className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
|
||||
onClick={openAddDialog}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
添加连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl bg-stone-50 px-6 py-10 text-center">
|
||||
<ServerCog className="size-8 text-stone-300" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-stone-600">暂无 Sub2API 连接</p>
|
||||
<p className="text-sm text-stone-400">点击「添加连接」保存你的 Sub2API 信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers.map((server) => {
|
||||
const isBusy = deletingId === server.id || loadingAccountsId === server.id;
|
||||
const importJob = server.import_job ?? null;
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-stone-800">{server.name || server.base_url}</div>
|
||||
<div className="truncate text-xs text-stone-400">
|
||||
{server.base_url}
|
||||
{server.email ? ` · ${server.email}` : server.has_api_key ? " · API Key" : ""}
|
||||
{server.group_id ? ` · 分组 ${server.group_id}` : " · 全部分组"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-stone-400 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
onClick={() => void handleDelete(server)}
|
||||
disabled={isBusy}
|
||||
title="删除"
|
||||
>
|
||||
{deletingId === server.id ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 rounded-lg border-stone-200 bg-white px-3 text-xs text-stone-600"
|
||||
onClick={() => void handleBrowseAccounts(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{loadingAccountsId === server.id ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Import className="size-3.5" />
|
||||
)}
|
||||
同步
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importJob ? (
|
||||
<div className="space-y-2 rounded-xl bg-stone-50 px-3 py-3">
|
||||
<div className="text-xs font-medium tracking-[0.16em] text-stone-400 uppercase">导入任务</div>
|
||||
{(() => {
|
||||
const progress =
|
||||
importJob.total > 0
|
||||
? Math.round((importJob.completed / importJob.total) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div className="rounded-lg border border-stone-200 bg-white px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-stone-700">
|
||||
状态 {importJob.status},已处理 {importJob.completed}/{importJob.total}
|
||||
</div>
|
||||
<div className="truncate text-xs text-stone-400">
|
||||
任务 {importJob.job_id.slice(0, 8)} · {importJob.created_at}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
importJob.status === "completed"
|
||||
? "success"
|
||||
: importJob.status === "failed"
|
||||
? "danger"
|
||||
: "info"
|
||||
}
|
||||
className="rounded-md"
|
||||
>
|
||||
{progress}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-stone-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-stone-900 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-stone-500">
|
||||
<span>新增 {importJob.added}</span>
|
||||
<span>跳过 {importJob.skipped}</span>
|
||||
<span>刷新 {importJob.refreshed}</span>
|
||||
<span>失败 {importJob.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-stone-50 px-4 py-3 text-sm leading-6 text-stone-500">
|
||||
<p className="font-medium text-stone-600">使用说明</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
<li>输入 Sub2API 地址和管理员账户(或 Admin API Key),保存为一个连接。</li>
|
||||
<li>点击某个连接的「同步」会拉取其中 platform=openai 且 type=oauth 的账号列表。</li>
|
||||
<li>勾选需要的账号后后端会并发拉取 access_token,自动导入本地号池并刷新状态。</li>
|
||||
<li>仅会读取 sub2api 凭据中的 access_token;refresh_token 等字段不会写入本地。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>{editingServer ? "编辑连接" : "添加连接"}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{editingServer ? "修改 Sub2API 连接信息" : "添加一个新的 Sub2API 连接"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
||||
<Input
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="例如:自建 sub2api"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Link2 className="size-3.5" />
|
||||
Sub2API 地址
|
||||
</label>
|
||||
<Input
|
||||
value={formBaseUrl}
|
||||
onChange={(event) => setFormBaseUrl(event.target.value)}
|
||||
placeholder="http://your-sub2api-host:8080"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">认证方式</label>
|
||||
<Select value={authMode} onValueChange={(value) => setAuthMode(value as AuthMode)}>
|
||||
<SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="password">管理员邮箱 + 密码</SelectItem>
|
||||
<SelectItem value="api_key">Admin API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{authMode === "password" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Mail className="size-3.5" />
|
||||
管理员邮箱
|
||||
</label>
|
||||
<Input
|
||||
value={formEmail}
|
||||
onChange={(event) => setFormEmail(event.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Unplug className="size-3.5" />
|
||||
管理员密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={formPassword}
|
||||
onChange={(event) => setFormPassword(event.target.value)}
|
||||
placeholder={editingServer ? "留空则不修改密码" : "管理员密码"}
|
||||
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
||||
onClick={() => setShowSecret((prev) => !prev)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Unplug className="size-3.5" />
|
||||
Admin API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={formApiKey}
|
||||
onChange={(event) => setFormApiKey(event.target.value)}
|
||||
placeholder={editingServer ? "留空则不修改密钥" : "Sub2API Admin API Key"}
|
||||
className="h-11 rounded-xl border-stone-200 bg-white pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
||||
onClick={() => setShowSecret((prev) => !prev)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
||||
<Layers className="size-3.5" />
|
||||
分组(可选)
|
||||
</label>
|
||||
{remoteGroups && remoteGroups.length > 0 ? (
|
||||
<Select value={formGroupId || "__all__"} onValueChange={(value) => setFormGroupId(value === "__all__" ? "" : value)}>
|
||||
<SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
|
||||
<SelectValue placeholder="选择分组" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部分组(不限制)</SelectItem>
|
||||
<SelectItem value="ungrouped">未分组</SelectItem>
|
||||
{remoteGroups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name || `Group ${group.id}`}
|
||||
{group.platform ? `(${group.platform})` : ""}
|
||||
{group.account_count
|
||||
? ` · ${group.active_account_count}/${group.account_count}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={formGroupId}
|
||||
onChange={(event) => setFormGroupId(event.target.value)}
|
||||
placeholder="留空则同步所有分组;或填写分组 ID / ungrouped"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
)}
|
||||
{editingServer ? (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-stone-500">
|
||||
<span>同步时会用分组 ID 过滤,留空 = 同步所有 OpenAI OAuth 账号。</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 rounded-lg border-stone-200 bg-white px-2 text-xs text-stone-600"
|
||||
onClick={() => void handleFetchGroups()}
|
||||
disabled={isLoadingGroups}
|
||||
>
|
||||
{isLoadingGroups ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="size-3.5" />
|
||||
)}
|
||||
{remoteGroups ? "重新拉取" : "拉取分组"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500">
|
||||
添加完连接后可在编辑对话框里点「拉取分组」选择具体分组。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
{editingServer ? "保存修改" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={browserOpen} onOpenChange={setBrowserOpen}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[90vh] max-w-5xl rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>选择要导入的账号</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
{browserServer ? `来自 ${browserServer.name || browserServer.base_url}` : "Sub2API 上的 OpenAI OAuth 账号"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative min-w-[260px]">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-stone-400" />
|
||||
<Input
|
||||
value={accountQuery}
|
||||
onChange={(event) => {
|
||||
setAccountQuery(event.target.value);
|
||||
setAccountPage(1);
|
||||
}}
|
||||
placeholder="搜索邮箱、套餐或名称"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={pageSize}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(value as (typeof PAGE_SIZE_OPTIONS)[number]);
|
||||
setAccountPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[120px] rounded-xl border-stone-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => handleToggleSelectAllFiltered(!allFilteredSelected)}
|
||||
>
|
||||
{allFilteredSelected ? "取消全选" : "全选筛选结果"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3 text-sm text-stone-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allFilteredSelected}
|
||||
onCheckedChange={(checked) => handleToggleSelectAllFiltered(Boolean(checked))}
|
||||
/>
|
||||
<span>筛选结果 {filteredAccounts.length} 个</span>
|
||||
</div>
|
||||
<span>已选 {selectedIds.length} 个</span>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto">
|
||||
{pagedAccounts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-stone-400">没有匹配的账号</div>
|
||||
) : (
|
||||
<div className="divide-y divide-stone-100">
|
||||
{pagedAccounts.map((item) => (
|
||||
<label
|
||||
key={item.id}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-stone-50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => toggleAccount(item.id, Boolean(checked))}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-stone-700">
|
||||
{item.email || item.name || item.id}
|
||||
</span>
|
||||
{item.plan_type ? (
|
||||
<Badge className="rounded-md bg-stone-100 text-stone-600">{item.plan_type}</Badge>
|
||||
) : null}
|
||||
{item.status ? (
|
||||
<Badge
|
||||
variant={item.status === "active" ? "success" : "info"}
|
||||
className="rounded-md"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate text-xs text-stone-400">
|
||||
id {item.id}
|
||||
{item.expires_at ? ` · 过期 ${item.expires_at}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-stone-500">
|
||||
<span>
|
||||
第 {filteredAccounts.length === 0 ? 0 : (safeAccountPage - 1) * currentPageSize + 1} -{" "}
|
||||
{Math.min(safeAccountPage * currentPageSize, filteredAccounts.length)} 条,共 {filteredAccounts.length} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
||||
onClick={() => setAccountPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={safeAccountPage <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
{safeAccountPage}/{accountPageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-3"
|
||||
onClick={() => setAccountPage((prev) => Math.min(accountPageCount, prev + 1))}
|
||||
disabled={safeAccountPage >= accountPageCount}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setBrowserOpen(false)}
|
||||
disabled={isStartingImport}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleStartImport()}
|
||||
disabled={isStartingImport || selectedIds.length === 0}
|
||||
>
|
||||
{isStartingImport ? <LoaderCircle className="size-4 animate-spin" /> : <Import className="size-4" />}
|
||||
导入选中账号
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LoaderCircle, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { useSettingsStore } from "../store";
|
||||
|
||||
export function ThirdPartyAppsCard() {
|
||||
const config = useSettingsStore((state) => state.config);
|
||||
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
||||
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
||||
const setInfiniteCanvasField = useSettingsStore((state) => state.setInfiniteCanvasField);
|
||||
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
||||
|
||||
if (isLoadingConfig || !config?.third_party_apps) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="flex items-center justify-center p-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const canvas = config.third_party_apps.infinite_canvas;
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-5 p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-base font-semibold text-stone-900">
|
||||
<ExternalLink className="size-5 text-stone-500" />
|
||||
无限画布入口
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-6 text-stone-500">开启后会在顶部导航显示入口,跳转时自动附带本项目地址和当前密钥。</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${canvas.enabled ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{canvas.enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
||||
<label className="flex items-center gap-3 text-sm text-stone-700">
|
||||
<Checkbox
|
||||
checked={Boolean(canvas.enabled)}
|
||||
onCheckedChange={(checked) => setInfiniteCanvasField("enabled", Boolean(checked))}
|
||||
/>
|
||||
启用无限画布
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-stone-700">无限画布地址</label>
|
||||
<Input
|
||||
value={canvas.url}
|
||||
onChange={(event) => setInfiniteCanvasField("url", event.target.value)}
|
||||
placeholder="https://canvas.best"
|
||||
className="h-10 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-stone-500">
|
||||
顶部入口跳转时会追加 apiKey 和 baseUrl 参数;关闭后顶部导航不显示无限画布。
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-amber-700">
|
||||
该入口仅供个人测试使用;长期使用建议自行本机部署无限画布。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800" onClick={() => void saveConfig()} disabled={isSavingConfig}>
|
||||
{isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Ban, CheckCircle2, Copy, KeyRound, LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createUserKey, deleteUserKey, fetchUserKeys, updateUserKey, type UserKey } from "@/lib/api";
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function UserKeysCard() {
|
||||
const didLoadRef = useRef(false);
|
||||
const [items, setItems] = useState<UserKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
|
||||
const [revealedKey, setRevealedKey] = useState("");
|
||||
const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
|
||||
const [editingItem, setEditingItem] = useState<UserKey | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchUserKeys();
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载用户密钥失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (didLoadRef.current) {
|
||||
return;
|
||||
}
|
||||
didLoadRef.current = true;
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const data = await createUserKey(name.trim());
|
||||
setItems(data.items);
|
||||
setRevealedKey(data.key);
|
||||
setName("");
|
||||
setIsDialogOpen(false);
|
||||
toast.success("用户密钥已创建");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建用户密钥失败");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setItemPending = (id: string, isPending: boolean) => {
|
||||
setPendingIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = async (item: UserKey) => {
|
||||
setItemPending(item.id, true);
|
||||
try {
|
||||
const data = await updateUserKey(item.id, { enabled: !item.enabled });
|
||||
setItems(data.items);
|
||||
toast.success(item.enabled ? "用户密钥已禁用" : "用户密钥已启用");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新用户密钥失败");
|
||||
} finally {
|
||||
setItemPending(item.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingItem) {
|
||||
return;
|
||||
}
|
||||
const item = deletingItem;
|
||||
setItemPending(item.id, true);
|
||||
try {
|
||||
const data = await deleteUserKey(item.id);
|
||||
setItems(data.items);
|
||||
setDeletingItem(null);
|
||||
toast.success("用户密钥已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除用户密钥失败");
|
||||
} finally {
|
||||
setItemPending(item.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (item: UserKey) => {
|
||||
setEditingItem(item);
|
||||
setEditName(item.name);
|
||||
setEditKey("");
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editingItem) {
|
||||
return;
|
||||
}
|
||||
const item = editingItem;
|
||||
const trimmedName = editName.trim();
|
||||
const trimmedKey = editKey.trim();
|
||||
if (trimmedName === item.name && !trimmedKey) {
|
||||
setEditingItem(null);
|
||||
return;
|
||||
}
|
||||
setItemPending(item.id, true);
|
||||
try {
|
||||
const data = await updateUserKey(item.id, {
|
||||
...(trimmedName !== item.name ? { name: trimmedName } : {}),
|
||||
...(trimmedKey ? { key: trimmedKey } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
setEditingItem(null);
|
||||
setEditKey("");
|
||||
toast.success(trimmedKey ? "用户密钥已更新" : "用户名称已更新");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新用户密钥失败");
|
||||
} finally {
|
||||
setItemPending(item.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success("已复制到剪贴板");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
||||
<KeyRound className="size-5 text-stone-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">用户密钥管理</h2>
|
||||
<p className="text-sm text-stone-500">为普通用户创建专用密钥;普通用户只能进入画图页,不能查看设置和号池。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
创建用户密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{revealedKey ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-900">
|
||||
<div className="font-medium">新密钥仅展示一次,请立即保存:</div>
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-lg border border-emerald-200 bg-white/80 p-3 md:flex-row md:items-center md:justify-between">
|
||||
<code className="break-all font-mono text-[13px]">{revealedKey}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-emerald-200 bg-white px-4 text-emerald-700"
|
||||
onClick={() => void handleCopy(revealedKey)}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl bg-stone-50 px-6 py-10 text-center text-sm text-stone-500">
|
||||
暂无普通用户密钥。点击右上角按钮后即可创建并分发给其他人。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const isPending = pendingIds.has(item.id);
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-medium text-stone-800">{item.name}</div>
|
||||
<Badge variant={item.enabled ? "success" : "secondary"} className="rounded-md">
|
||||
{item.enabled ? "已启用" : "已禁用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
|
||||
<span>创建时间 {formatDateTime(item.created_at)}</span>
|
||||
<span>最近使用 {formatDateTime(item.last_used_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => openEditDialog(item)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
||||
onClick={() => void handleToggle(item)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : item.enabled ? (
|
||||
<Ban className="size-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
{item.enabled ? "禁用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-xl border-rose-200 bg-white px-4 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={() => setDeletingItem(item)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>创建用户密钥</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
可选填写一个备注名称,方便区分不同使用者;创建后会生成一条只能查看一次的原始密钥。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">名称(可选)</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:设计同学 A、运营临时账号"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? <LoaderCircle className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deletingItem)} onOpenChange={(open) => (!open ? setDeletingItem(null) : null)}>
|
||||
<DialogContent className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>删除用户密钥</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
确认删除用户密钥「{deletingItem?.name}」吗?删除后该密钥将无法继续调用接口。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => setDeletingItem(null)}
|
||||
disabled={deletingItem ? pendingIds.has(deletingItem.id) : false}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 rounded-xl bg-rose-600 px-5 text-white hover:bg-rose-700"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deletingItem ? pendingIds.has(deletingItem.id) : false}
|
||||
>
|
||||
{deletingItem && pendingIds.has(deletingItem.id) ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editingItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
setEditKey("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>编辑用户密钥</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
可以修改备注名称;如需更换专用密钥,直接填写新的原始密钥即可。留空则保持当前密钥不变。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">名称</label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
placeholder="例如:设计同学 A、运营临时账号"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-stone-700">新的专用密钥(可选)</label>
|
||||
<Input
|
||||
value={editKey}
|
||||
onChange={(event) => setEditKey(event.target.value)}
|
||||
placeholder="例如:sk-your-custom-user-key"
|
||||
className="h-11 rounded-xl border-stone-200 bg-white font-mono"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-stone-500">
|
||||
保存后旧密钥会立即失效,新密钥生效。系统仍只保存哈希,不会回显当前密钥。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setEditKey("");
|
||||
}}
|
||||
disabled={editingItem ? pendingIds.has(editingItem.id) : false}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
||||
onClick={() => void handleEdit()}
|
||||
disabled={editingItem ? pendingIds.has(editingItem.id) : false}
|
||||
>
|
||||
{editingItem && pendingIds.has(editingItem.id) ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuthGuard } from "@/lib/use-auth-guard";
|
||||
|
||||
import { BackupSettingsCard } from "./components/backup-settings-card";
|
||||
import { ApiDocsCard } from "./components/api-docs-card";
|
||||
import { ConfigCard } from "./components/config-card";
|
||||
import { CPAPoolDialog } from "./components/cpa-pool-dialog";
|
||||
import { CPAPoolsCard } from "./components/cpa-pools-card";
|
||||
import { ImportBrowserDialog } from "./components/import-browser-dialog";
|
||||
import { ProxyRuntimeCard } from "./components/proxy-runtime-card";
|
||||
import { SettingsHeader } from "./components/settings-header";
|
||||
import { Sub2APIConnections } from "./components/sub2api-connections";
|
||||
import { ThirdPartyAppsCard } from "./components/third-party-apps-card";
|
||||
import { UserKeysCard } from "./components/user-keys-card";
|
||||
import { useSettingsStore } from "./store";
|
||||
|
||||
const settingsTabs = [
|
||||
{ value: "basic", title: "基础配置" },
|
||||
{ value: "backup", title: "备份" },
|
||||
{ value: "keys", title: "用户密钥" },
|
||||
{ value: "api-docs", title: "接口接入" },
|
||||
{ value: "canvas", title: "画布入口" },
|
||||
{ value: "proxy", title: "FlareSolverr" },
|
||||
{ value: "cpa", title: "CPA" },
|
||||
{ value: "sub2api", title: "Sub2API" },
|
||||
];
|
||||
|
||||
function SettingsDataController() {
|
||||
const didLoadRef = useRef(false);
|
||||
const initialize = useSettingsStore((state) => state.initialize);
|
||||
const loadPools = useSettingsStore((state) => state.loadPools);
|
||||
const loadBackups = useSettingsStore((state) => state.loadBackups);
|
||||
const pools = useSettingsStore((state) => state.pools);
|
||||
const backupState = useSettingsStore((state) => state.backupState);
|
||||
|
||||
useEffect(() => {
|
||||
if (didLoadRef.current) {
|
||||
return;
|
||||
}
|
||||
didLoadRef.current = true;
|
||||
void initialize();
|
||||
}, [initialize]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasRunningJobs = pools.some((pool) => {
|
||||
const status = pool.import_job?.status;
|
||||
return status === "pending" || status === "running";
|
||||
});
|
||||
if (!hasRunningJobs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void loadPools(true);
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadPools, pools]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backupState?.running) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
void loadBackups(true);
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [backupState?.running, loadBackups]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SettingsPageContent() {
|
||||
return (
|
||||
<>
|
||||
<SettingsDataController />
|
||||
<SettingsHeader />
|
||||
<Tabs defaultValue="basic" className="space-y-4">
|
||||
<div className="sticky top-3 z-20 overflow-x-auto rounded-xl border border-white/80 bg-white/90 px-3 py-2 shadow-sm backdrop-blur">
|
||||
<TabsList variant="line" className="min-w-max justify-start">
|
||||
{settingsTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="px-4">
|
||||
{tab.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="basic">
|
||||
<ConfigCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="proxy">
|
||||
<ProxyRuntimeCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="backup">
|
||||
<BackupSettingsCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="keys">
|
||||
<UserKeysCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="canvas">
|
||||
<ThirdPartyAppsCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="api-docs">
|
||||
<ApiDocsCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="cpa">
|
||||
<CPAPoolsCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="sub2api">
|
||||
<Sub2APIConnections />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CPAPoolDialog />
|
||||
<ImportBrowserDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isCheckingAuth, session } = useAuthGuard(["admin"]);
|
||||
|
||||
if (isCheckingAuth || !session || session.role !== "admin") {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsPageContent />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { format, parse } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
type DateRangeFilterProps = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChange: (startDate: string, endDate: string) => void;
|
||||
};
|
||||
|
||||
export function DateRangeFilter({ startDate, endDate, onChange }: DateRangeFilterProps) {
|
||||
const selected: DateRange | undefined = startDate
|
||||
? {
|
||||
from: parse(startDate, "yyyy-MM-dd", new Date()),
|
||||
to: endDate ? parse(endDate, "yyyy-MM-dd", new Date()) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const label = startDate ? `${startDate} 至 ${endDate || startDate}` : "选择日期范围";
|
||||
|
||||
return (
|
||||
<Field className="w-[240px]">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-10 justify-start rounded-xl border-stone-200 bg-white px-3 font-normal text-stone-700">
|
||||
<CalendarIcon className="size-4 text-stone-400" />
|
||||
{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={selected?.from}
|
||||
selected={selected}
|
||||
onSelect={(value) => onChange(value?.from ? format(value.from, "yyyy-MM-dd") : "", value?.to ? format(value.to, "yyyy-MM-dd") : "")}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { VersionReleaseDialog } from "@/components/version-release-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function HeaderActions({ className, showGithubText = true }: { className?: string; showGithubText?: boolean }) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 sm:gap-3", className)}>
|
||||
<ThemeToggle />
|
||||
<a
|
||||
href="https://github.com/basketikun/chatgpt2api"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 text-sm text-stone-500 transition hover:text-stone-900 dark:text-stone-300 dark:hover:text-white"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<img src="/github.svg" alt="" className="size-4" />
|
||||
{showGithubText ? <span className="hidden sm:inline">GitHub</span> : null}
|
||||
</a>
|
||||
<VersionReleaseDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LightboxImage = {
|
||||
id: string;
|
||||
src: string;
|
||||
sizeLabel?: string;
|
||||
dimensions?: string;
|
||||
};
|
||||
|
||||
type ImageLightboxProps = {
|
||||
images: LightboxImage[];
|
||||
currentIndex: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
};
|
||||
|
||||
type ImageTransform = {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type TouchPoints = {
|
||||
[index: number]: React.Touch;
|
||||
};
|
||||
|
||||
type TouchGesture =
|
||||
| {
|
||||
type: "swipe";
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
| {
|
||||
type: "pan";
|
||||
startX: number;
|
||||
startY: number;
|
||||
startTransform: ImageTransform;
|
||||
}
|
||||
| {
|
||||
type: "pinch";
|
||||
startDistance: number;
|
||||
startCenterX: number;
|
||||
startCenterY: number;
|
||||
startTransform: ImageTransform;
|
||||
};
|
||||
|
||||
const minScale = 1;
|
||||
const maxScale = 4;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getTouchDistance(touches: TouchPoints) {
|
||||
const first = touches[0];
|
||||
const second = touches[1];
|
||||
return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY);
|
||||
}
|
||||
|
||||
function getTouchCenter(touches: TouchPoints) {
|
||||
const first = touches[0];
|
||||
const second = touches[1];
|
||||
return {
|
||||
x: (first.clientX + second.clientX) / 2,
|
||||
y: (first.clientY + second.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransform(transform: ImageTransform) {
|
||||
if (transform.scale <= minScale) {
|
||||
return { scale: minScale, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const maxX = window.innerWidth * (transform.scale - 1) * 0.5;
|
||||
const maxY = window.innerHeight * (transform.scale - 1) * 0.5;
|
||||
return {
|
||||
scale: transform.scale,
|
||||
x: clamp(transform.x, -maxX, maxX),
|
||||
y: clamp(transform.y, -maxY, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageLightbox({
|
||||
images,
|
||||
currentIndex,
|
||||
open,
|
||||
onOpenChange,
|
||||
onIndexChange,
|
||||
}: ImageLightboxProps) {
|
||||
const gestureRef = useRef<TouchGesture | null>(null);
|
||||
const lastTapRef = useRef(0);
|
||||
const pendingTransformRef = useRef<ImageTransform | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [transform, setTransform] = useState<ImageTransform>({ scale: 1, x: 0, y: 0 });
|
||||
const [isGesturing, setIsGesturing] = useState(false);
|
||||
const current = images[currentIndex];
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < images.length - 1;
|
||||
|
||||
const cancelScheduledTransform = useCallback(() => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
pendingTransformRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleTransform = useCallback((next: ImageTransform) => {
|
||||
pendingTransformRef.current = next;
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const pending = pendingTransformRef.current;
|
||||
pendingTransformRef.current = null;
|
||||
if (pending) {
|
||||
setTransform(pending);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushScheduledTransform = useCallback(() => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const pending = pendingTransformRef.current;
|
||||
pendingTransformRef.current = null;
|
||||
if (pending) {
|
||||
setTransform(pending);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetTransform = useCallback(() => {
|
||||
cancelScheduledTransform();
|
||||
setTransform({ scale: 1, x: 0, y: 0 });
|
||||
setIsGesturing(false);
|
||||
gestureRef.current = null;
|
||||
}, [cancelScheduledTransform]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (hasPrev) onIndexChange(currentIndex - 1);
|
||||
}, [hasPrev, currentIndex, onIndexChange]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (hasNext) onIndexChange(currentIndex + 1);
|
||||
}, [hasNext, currentIndex, onIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
resetTransform();
|
||||
}, [current?.id, open, resetTransform]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
goPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, goPrev, goNext]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!current) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = current.src;
|
||||
link.download = `image-${current.id}.png`;
|
||||
link.click();
|
||||
}, [current]);
|
||||
|
||||
const toggleZoom = useCallback(() => {
|
||||
setTransform((currentTransform) =>
|
||||
currentTransform.scale > minScale ? { scale: 1, x: 0, y: 0 } : { scale: 2.5, x: 0, y: 0 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
event.preventDefault();
|
||||
const startDistance = getTouchDistance(event.touches);
|
||||
if (startDistance < 1) {
|
||||
gestureRef.current = null;
|
||||
return;
|
||||
}
|
||||
const center = getTouchCenter(event.touches);
|
||||
cancelScheduledTransform();
|
||||
setIsGesturing(true);
|
||||
gestureRef.current = {
|
||||
type: "pinch",
|
||||
startDistance,
|
||||
startCenterX: center.x,
|
||||
startCenterY: center.y,
|
||||
startTransform: transform,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.touches.length !== 1) {
|
||||
gestureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (transform.scale > minScale) {
|
||||
cancelScheduledTransform();
|
||||
setIsGesturing(true);
|
||||
gestureRef.current = {
|
||||
type: "pan",
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startTransform: transform,
|
||||
};
|
||||
} else {
|
||||
gestureRef.current = {
|
||||
type: "swipe",
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
};
|
||||
}
|
||||
},
|
||||
[transform, cancelScheduledTransform],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture) return;
|
||||
|
||||
if (gesture.type === "pinch" && event.touches.length === 2) {
|
||||
event.preventDefault();
|
||||
const targetScale = clamp(
|
||||
(getTouchDistance(event.touches) / gesture.startDistance) * gesture.startTransform.scale,
|
||||
minScale,
|
||||
maxScale,
|
||||
);
|
||||
const effectiveRatio = targetScale / gesture.startTransform.scale;
|
||||
const center = getTouchCenter(event.touches);
|
||||
const viewportCenterX = window.innerWidth / 2;
|
||||
const viewportCenterY = window.innerHeight / 2;
|
||||
const nextX =
|
||||
center.x -
|
||||
viewportCenterX -
|
||||
(gesture.startCenterX - viewportCenterX - gesture.startTransform.x) * effectiveRatio;
|
||||
const nextY =
|
||||
center.y -
|
||||
viewportCenterY -
|
||||
(gesture.startCenterY - viewportCenterY - gesture.startTransform.y) * effectiveRatio;
|
||||
scheduleTransform(
|
||||
normalizeTransform({ scale: targetScale, x: nextX, y: nextY }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.type === "pan" && event.touches.length === 1) {
|
||||
event.preventDefault();
|
||||
const touch = event.touches[0];
|
||||
scheduleTransform(
|
||||
normalizeTransform({
|
||||
scale: gesture.startTransform.scale,
|
||||
x: gesture.startTransform.x + touch.clientX - gesture.startX,
|
||||
y: gesture.startTransform.y + touch.clientY - gesture.startY,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.touches.length !== 1) {
|
||||
gestureRef.current = null;
|
||||
}
|
||||
},
|
||||
[scheduleTransform],
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
flushScheduledTransform();
|
||||
setIsGesturing(false);
|
||||
|
||||
const gesture = gestureRef.current;
|
||||
gestureRef.current = null;
|
||||
if (!gesture) return;
|
||||
|
||||
if (gesture.type !== "swipe" || event.changedTouches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
const deltaX = touch.clientX - gesture.startX;
|
||||
const deltaY = touch.clientY - gesture.startY;
|
||||
const now = Date.now();
|
||||
|
||||
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && now - lastTapRef.current < 280) {
|
||||
event.preventDefault();
|
||||
lastTapRef.current = 0;
|
||||
toggleZoom();
|
||||
return;
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) < Math.abs(deltaY) * 1.4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 0) {
|
||||
goPrev();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
},
|
||||
[goPrev, goNext, toggleZoom, flushScheduledTransform],
|
||||
);
|
||||
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
cancelScheduledTransform();
|
||||
setIsGesturing(false);
|
||||
gestureRef.current = null;
|
||||
}, [cancelScheduledTransform]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-50 flex items-center justify-center outline-none"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">
|
||||
图片预览
|
||||
</DialogPrimitive.Title>
|
||||
|
||||
<div className="absolute top-[calc(env(safe-area-inset-top)+1rem)] right-4 z-10 flex items-center gap-2">
|
||||
{current.sizeLabel || current.dimensions ? (
|
||||
<span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
|
||||
{[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
{images.length > 1 && (
|
||||
<span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70"
|
||||
aria-label="下载图片"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</button>
|
||||
<DialogPrimitive.Close className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
{hasPrev && transform.scale <= minScale && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="absolute left-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-full w-full touch-none items-center justify-center overflow-hidden"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
>
|
||||
<img
|
||||
src={current.src}
|
||||
alt=""
|
||||
className={cn(
|
||||
"max-h-[90vh] max-w-[90vw] rounded-lg object-contain will-change-transform",
|
||||
isGesturing ? "" : "transition-transform duration-150 ease-out",
|
||||
transform.scale > minScale ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom();
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasNext && transform.scale <= minScale && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="absolute right-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImageThumbnailProps = {
|
||||
src: string;
|
||||
thumbnailSrc?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
};
|
||||
|
||||
export function getImageThumbnailUrl(src: string) {
|
||||
const marker = "/images/";
|
||||
const index = src.indexOf(marker);
|
||||
if (index < 0) return src;
|
||||
return `${src.slice(0, index)}/image-thumbnails/${src.slice(index + marker.length)}`;
|
||||
}
|
||||
|
||||
export function ImageThumbnail({ src, thumbnailSrc, alt = "", className, imageClassName }: ImageThumbnailProps) {
|
||||
const initialSrc = useMemo(() => thumbnailSrc || getImageThumbnailUrl(src), [src, thumbnailSrc]);
|
||||
const [currentSrc, setCurrentSrc] = useState(initialSrc);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSrc(initialSrc);
|
||||
}, [initialSrc]);
|
||||
|
||||
return (
|
||||
<span className={cn("block overflow-hidden bg-stone-100", className)}>
|
||||
<img
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
className={cn("h-full w-full object-cover", imageClassName)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
if (currentSrc !== src) {
|
||||
setCurrentSrc(src);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const themeScript = `
|
||||
(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("chatgpt2api-theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const theme = stored === "light" || stored === "dark" ? stored : prefersDark ? "dark" : "light";
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.style.colorScheme = "light";
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
export function ThemeScript() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
|
||||
export function ThemeToggle() {
|
||||
return (
|
||||
<AnimatedThemeToggler
|
||||
aria-label="切换主题"
|
||||
title="切换主题"
|
||||
variant="circle"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-500 transition hover:text-stone-900 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { HeaderActions } from "@/components/header-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { fetchThirdPartyApps, type ThirdPartyAppsSettings } from "@/lib/api";
|
||||
import { getValidatedAuthSession } from "@/lib/auth-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: "/image", label: "生图" },
|
||||
{ href: "/accounts", label: "号池管理" },
|
||||
{ href: "/image-manager", label: "图片管理" },
|
||||
{ href: "/logs", label: "日志管理" },
|
||||
{ href: "/debug", label: "调试" },
|
||||
{ href: "/settings", label: "设置" },
|
||||
];
|
||||
|
||||
const userNavItems = [{ href: "/image", label: "画图" }];
|
||||
|
||||
function buildThirdPartyHref(appUrl: string, baseUrl: string, apiKey: string) {
|
||||
const url = appUrl.trim();
|
||||
try {
|
||||
const target = new URL(url);
|
||||
target.searchParams.set("apiKey", apiKey);
|
||||
target.searchParams.set("baseUrl", baseUrl);
|
||||
return target.toString();
|
||||
} catch {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(apiKey)}&baseUrl=${encodeURIComponent(baseUrl)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
|
||||
const [thirdPartyApps, setThirdPartyApps] = useState<ThirdPartyAppsSettings | null>(null);
|
||||
const [isCanvasDialogOpen, setIsCanvasDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
if (pathname === "/login") {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedSession = await getValidatedAuthSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setSession(storedSession);
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setThirdPartyApps(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchThirdPartyApps();
|
||||
if (active) {
|
||||
setThirdPartyApps(data.third_party_apps);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setThirdPartyApps(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
const reload = () => void load();
|
||||
|
||||
void load();
|
||||
window.addEventListener("third-party-apps-updated", reload);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("third-party-apps-updated", reload);
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await clearStoredAuthSession();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
if (pathname === "/login" || session === undefined || !session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navItems = session.role === "admin" ? adminNavItems : userNavItems;
|
||||
const roleLabel = session.role === "admin" ? "管理员" : "普通用户";
|
||||
const displayName = session.name.trim() || roleLabel;
|
||||
const baseUrl = webConfig.apiUrl.replace(/\/$/, "") || window.location.origin;
|
||||
const canvas = thirdPartyApps?.infinite_canvas;
|
||||
const canvasHref = canvas?.enabled && canvas.url.trim() ? buildThirdPartyHref(canvas.url, baseUrl, session.key) : "";
|
||||
const canvasDisplayHref = canvasHref ? decodeURIComponent(canvasHref) : "";
|
||||
|
||||
const handleCanvasOpen = () => {
|
||||
if (!canvasHref) {
|
||||
return;
|
||||
}
|
||||
setIsCanvasDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmCanvasOpen = () => {
|
||||
if (canvasHref) {
|
||||
window.open(canvasHref, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
setIsCanvasDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-stone-100/50 dark:border-white/10">
|
||||
<div className="flex min-h-12 flex-col gap-1 px-3 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-6 sm:py-0">
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger className="inline-flex size-8 items-center justify-center text-stone-700 transition hover:text-stone-950 sm:hidden dark:text-stone-200 dark:hover:text-white">
|
||||
<Menu className="size-4" />
|
||||
<span className="sr-only">打开导航</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>chatgpt2api</SheetTitle>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">{roleLabel} · {displayName}</span>
|
||||
</SheetHeader>
|
||||
<nav className="mt-8 flex flex-col gap-1">
|
||||
{canvasHref ? (
|
||||
<SheetClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-xl px-3 py-2.5 text-left text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
onClick={handleCanvasOpen}
|
||||
>
|
||||
无限画布
|
||||
</button>
|
||||
</SheetClose>
|
||||
) : null}
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const className = cn(
|
||||
"flex items-center rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
||||
active ? "bg-stone-950 text-white dark:bg-white dark:text-stone-950" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-white/10 dark:hover:text-white",
|
||||
);
|
||||
return (
|
||||
<SheetClose asChild key={item.href}>
|
||||
<Link href={item.href} className={className}>{item.label}</Link>
|
||||
</SheetClose>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SheetFooter>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-stone-200 px-3 py-2.5 text-left text-sm font-medium text-stone-500 transition hover:text-stone-950 dark:border-white/10 dark:text-stone-300 dark:hover:text-white"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Link
|
||||
href="/image"
|
||||
className="shrink-0 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700 dark:text-stone-50 dark:hover:text-white"
|
||||
>
|
||||
chatgpt2api
|
||||
</Link>
|
||||
<HeaderActions className="ml-auto sm:hidden" showGithubText={false} />
|
||||
</div>
|
||||
<nav className="hide-scrollbar -mx-1 hidden min-w-0 flex-1 gap-1 overflow-x-auto px-1 sm:mx-0 sm:flex sm:justify-center sm:gap-8 sm:overflow-visible sm:px-0">
|
||||
{canvasHref ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCanvasOpen}
|
||||
className="relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium text-stone-500 transition hover:text-stone-900 sm:rounded-none sm:px-0 sm:text-[15px] dark:text-stone-400 dark:hover:text-stone-100"
|
||||
>
|
||||
无限画布
|
||||
</button>
|
||||
) : null}
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium transition sm:rounded-none sm:px-0 sm:text-[15px]",
|
||||
active
|
||||
? "bg-stone-950 text-white sm:bg-transparent sm:font-semibold sm:text-stone-950 dark:bg-white dark:text-stone-950 dark:sm:bg-transparent dark:sm:text-white"
|
||||
: "text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{active ? <span className="absolute inset-x-0 -bottom-[1px] hidden h-0.5 bg-stone-950 dark:bg-white sm:block" /> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
|
||||
<HeaderActions />
|
||||
<span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
|
||||
{roleLabel} · {displayName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="py-1 text-xs text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200 sm:text-sm"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Dialog open={isCanvasDialogOpen} onOpenChange={setIsCanvasDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle>跳转到三方应用</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-6">
|
||||
该入口仅供个人测试使用,建议自行本机部署后再长期使用。跳转地址会默认带上本项目地址和当前密钥,用于自动填充连接信息;如果不放心,可以取消后手动前往应用并自行输入。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-stone-500">完整跳转地址</div>
|
||||
<div className="max-h-28 overflow-auto break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 font-mono text-xs leading-5 text-stone-700">
|
||||
{canvasDisplayHref}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" className="rounded-xl border-stone-200 bg-white text-stone-700">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" className="rounded-xl bg-stone-950 text-white hover:bg-stone-800" onClick={confirmCanvasOpen}>
|
||||
继续跳转
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
|
||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
duration?: number
|
||||
variant?: TransitionVariant
|
||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
||||
fromCenter?: boolean
|
||||
}
|
||||
|
||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||
const pairs = Array.from(
|
||||
{ length: vertexCount },
|
||||
() => `${cx}px ${cy}px`
|
||||
).join(", ")
|
||||
return `polygon(${pairs})`
|
||||
}
|
||||
|
||||
function getThemeTransitionClipPaths(
|
||||
variant: TransitionVariant,
|
||||
cx: number,
|
||||
cy: number,
|
||||
maxRadius: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): [string, string] {
|
||||
switch (variant) {
|
||||
case "circle":
|
||||
return [
|
||||
`circle(0px at ${cx}px ${cy}px)`,
|
||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
||||
]
|
||||
case "square": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx)
|
||||
const halfH = Math.max(cy, viewportHeight - cy)
|
||||
const halfSide = Math.max(halfW, halfH) * 1.05
|
||||
const end = [
|
||||
`${cx - halfSide}px ${cy - halfSide}px`,
|
||||
`${cx + halfSide}px ${cy - halfSide}px`,
|
||||
`${cx + halfSide}px ${cy + halfSide}px`,
|
||||
`${cx - halfSide}px ${cy + halfSide}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "triangle": {
|
||||
const scale = maxRadius * 2.2
|
||||
const dx = (Math.sqrt(3) / 2) * scale
|
||||
const verts = [
|
||||
`${cx}px ${cy - scale}px`,
|
||||
`${cx + dx}px ${cy + 0.5 * scale}px`,
|
||||
`${cx - dx}px ${cy + 0.5 * scale}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
|
||||
}
|
||||
case "diamond": {
|
||||
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
||||
const R = maxRadius * Math.SQRT2
|
||||
const end = [
|
||||
`${cx}px ${cy - R}px`,
|
||||
`${cx + R}px ${cy}px`,
|
||||
`${cx}px ${cy + R}px`,
|
||||
`${cx - R}px ${cy}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "hexagon": {
|
||||
const R = maxRadius * Math.SQRT2
|
||||
const verts: string[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 3
|
||||
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
|
||||
}
|
||||
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
|
||||
}
|
||||
case "rectangle": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx)
|
||||
const halfH = Math.max(cy, viewportHeight - cy)
|
||||
const end = [
|
||||
`${cx - halfW}px ${cy - halfH}px`,
|
||||
`${cx + halfW}px ${cy - halfH}px`,
|
||||
`${cx + halfW}px ${cy + halfH}px`,
|
||||
`${cx - halfW}px ${cy + halfH}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "star": {
|
||||
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
||||
const R = maxRadius * Math.SQRT2 * 1.03
|
||||
const innerRatio = 0.42
|
||||
const starPolygon = (radius: number) => {
|
||||
const verts: string[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
|
||||
verts.push(
|
||||
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
|
||||
)
|
||||
const innerA = outerA + Math.PI / 5
|
||||
verts.push(
|
||||
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
|
||||
)
|
||||
}
|
||||
return `polygon(${verts.join(", ")})`
|
||||
}
|
||||
const startR = Math.max(2, R * 0.025)
|
||||
return [starPolygon(startR), starPolygon(R)]
|
||||
}
|
||||
default:
|
||||
return [
|
||||
`circle(0px at ${cx}px ${cy}px)`,
|
||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
className,
|
||||
duration = 400,
|
||||
variant,
|
||||
fromCenter = false,
|
||||
...props
|
||||
}: AnimatedThemeTogglerProps) => {
|
||||
const shape = variant ?? "circle"
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}
|
||||
|
||||
updateTheme()
|
||||
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
|
||||
let x: number
|
||||
let y: number
|
||||
if (fromCenter) {
|
||||
x = viewportWidth / 2
|
||||
y = viewportHeight / 2
|
||||
} else {
|
||||
const { top, left, width, height } = button.getBoundingClientRect()
|
||||
x = left + width / 2
|
||||
y = top + height / 2
|
||||
}
|
||||
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(x, viewportWidth - x),
|
||||
Math.max(y, viewportHeight - y)
|
||||
)
|
||||
|
||||
const applyTheme = () => {
|
||||
const newTheme = !isDark
|
||||
setIsDark(newTheme)
|
||||
document.documentElement.classList.toggle("dark")
|
||||
document.documentElement.style.colorScheme = newTheme ? "dark" : "light"
|
||||
localStorage.setItem("chatgpt2api-theme", newTheme ? "dark" : "light")
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme()
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
|
||||
const root = document.documentElement
|
||||
root.dataset.magicuiThemeVt = "active"
|
||||
root.style.setProperty(
|
||||
"--magicui-theme-toggle-vt-duration",
|
||||
`${duration}ms`
|
||||
)
|
||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
|
||||
const cleanup = () => {
|
||||
delete root.dataset.magicuiThemeVt
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from")
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme)
|
||||
})
|
||||
if (typeof transition?.finished?.finally === "function") {
|
||||
transition.finished.finally(cleanup)
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const ready = transition?.ready
|
||||
if (ready && typeof ready.then === "function") {
|
||||
ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath,
|
||||
},
|
||||
{
|
||||
duration,
|
||||
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
|
||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
||||
fill: "forwards",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [shape, fromCenter, duration, isDark])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={toggleTheme}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{isDark ? <Sun /> : <Moon />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/10 text-primary",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "border-border bg-background text-foreground",
|
||||
success:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
danger:
|
||||
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-300",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-300",
|
||||
violet:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-1 text-sm", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col gap-4 sm:flex-row",
|
||||
month: "relative",
|
||||
month_caption: "flex h-9 items-center justify-center font-medium",
|
||||
nav: "absolute inset-x-2 top-2 flex items-center justify-between",
|
||||
button_previous: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
|
||||
button_next: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
|
||||
weekdays: "mt-2 grid grid-cols-7 text-xs text-stone-400",
|
||||
weekday: "flex h-8 items-center justify-center font-normal",
|
||||
week: "grid grid-cols-7",
|
||||
day: "size-9 p-0 text-center",
|
||||
day_button: "size-9 rounded-lg text-sm transition hover:bg-stone-100",
|
||||
today: "font-semibold text-stone-950",
|
||||
selected: "[&_button]:bg-stone-950 [&_button]:text-white [&_button]:hover:bg-stone-800",
|
||||
outside: "text-stone-300",
|
||||
disabled: "text-stone-300 opacity-50",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === "left" ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col rounded-3xl border border-white/70 shadow-[0_20px_60px_-28px_rgba(25,33,61,0.18)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30 size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center transition-none">
|
||||
<Check className="size-3.5 text-white" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
|
||||
const BAND_HALF = 17
|
||||
const SWEEP_START = -BAND_HALF
|
||||
const SWEEP_END = 100 + BAND_HALF
|
||||
|
||||
const sweepEase = (t: number) =>
|
||||
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
||||
const bandStart = pos - BAND_HALF
|
||||
const bandEnd = pos + BAND_HALF
|
||||
|
||||
if (bandStart >= 100) {
|
||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`
|
||||
}
|
||||
const n = colors.length
|
||||
const parts: string[] = []
|
||||
|
||||
if (bandStart > 0)
|
||||
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
|
||||
|
||||
colors.forEach((c, i) => {
|
||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
|
||||
parts.push(`${c} ${pct.toFixed(2)}%`)
|
||||
})
|
||||
|
||||
if (bandEnd < 100)
|
||||
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
}
|
||||
|
||||
function measureWidths(el: HTMLElement, texts: string[]) {
|
||||
const ghost = el.cloneNode() as HTMLElement
|
||||
Object.assign(ghost.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
width: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
})
|
||||
el.parentElement!.appendChild(ghost)
|
||||
const widths = texts.map((t) => {
|
||||
ghost.textContent = t
|
||||
return ghost.getBoundingClientRect().width
|
||||
})
|
||||
ghost.remove()
|
||||
return widths
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link DiaTextReveal}.
|
||||
*/
|
||||
export interface DiaTextRevealProps extends Omit<
|
||||
HTMLMotionProps<"span">,
|
||||
"ref" | "children" | "style" | "animate" | "transition" | "color"
|
||||
> {
|
||||
/**
|
||||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
||||
*/
|
||||
text: string | string[]
|
||||
/**
|
||||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
||||
*/
|
||||
colors?: string[]
|
||||
/**
|
||||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
||||
* @defaultValue `"var(--foreground)"`
|
||||
*/
|
||||
textColor?: string
|
||||
/**
|
||||
* Duration of one sweep pass, in seconds.
|
||||
* @defaultValue `1.5`
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Delay before the sweep starts, in seconds.
|
||||
* @defaultValue `0`
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
repeat?: boolean
|
||||
/**
|
||||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
||||
* @defaultValue `0.5`
|
||||
*/
|
||||
repeatDelay?: number
|
||||
/**
|
||||
* If `true`, the animation starts only after the element enters the viewport.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
once?: boolean
|
||||
/**
|
||||
* Additional class names for the animated `span` (e.g. typography utilities).
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
}
|
||||
|
||||
export function DiaTextReveal({
|
||||
text,
|
||||
colors = DEFAULT_COLORS,
|
||||
textColor = "var(--foreground)",
|
||||
duration = 1.5,
|
||||
delay = 0,
|
||||
repeat = false,
|
||||
repeatDelay = 0.5,
|
||||
startOnView = true,
|
||||
once = true,
|
||||
className,
|
||||
fixedWidth = false,
|
||||
...props
|
||||
}: DiaTextRevealProps) {
|
||||
const texts = useMemo(() => (Array.isArray(text) ? text : [text]), [text])
|
||||
const textKey = texts.join("\0")
|
||||
const isMulti = texts.length > 1
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const spanRef = useRef<HTMLSpanElement>(null)
|
||||
const optsRef = useRef({
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
})
|
||||
|
||||
const indexRef = useRef(0)
|
||||
const hasPlayedRef = useRef(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const playRef = useRef<() => void>(null!)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
|
||||
optsRef.current = {
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = spanRef.current
|
||||
if (!el || !isMulti) return
|
||||
setMeasuredWidths(measureWidths(el, texts))
|
||||
}, [isMulti, textKey, texts])
|
||||
|
||||
useEffect(() => {
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
|
||||
const controls = animate(sweepPos, SWEEP_END, {
|
||||
duration,
|
||||
delay,
|
||||
ease: sweepEase,
|
||||
onComplete() {
|
||||
if (!repeat) return
|
||||
timerRef.current = setTimeout(() => {
|
||||
const next = (indexRef.current + 1) % texts.length
|
||||
indexRef.current = next
|
||||
setActiveIndex(next)
|
||||
playRef.current()
|
||||
}, repeatDelay * 1000)
|
||||
},
|
||||
})
|
||||
|
||||
stopRef.current = () => controls.stop()
|
||||
}
|
||||
}, [sweepPos])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
sweepPos.set(SWEEP_END)
|
||||
return
|
||||
}
|
||||
if (startOnView && !isInView) return
|
||||
if (once && hasPlayedRef.current) return
|
||||
hasPlayedRef.current = true
|
||||
playRef.current()
|
||||
|
||||
return () => {
|
||||
stopRef.current?.()
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={spanRef}
|
||||
className={cn("align-bottom leading-[100%] text-inherit", className)}
|
||||
style={{
|
||||
transform: "translateY(-2px)",
|
||||
color: "transparent",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundImage,
|
||||
...(isMulti && {
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "text-center",
|
||||
...(fixedW != null && { width: fixedW }),
|
||||
}),
|
||||
}}
|
||||
animate={animatedW != null ? { width: animatedW } : undefined}
|
||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||||
{...props}
|
||||
>
|
||||
{texts[activeIndex]}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger(
|
||||
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
|
||||
) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal(
|
||||
props: React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out fixed inset-0 z-50 bg-black/30 backdrop-blur-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed top-[50%] left-[50%] z-50 grid w-[min(92vw,560px)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[28px] border border-white/80 p-6 shadow-[0_36px_120px_-45px_rgba(16,24,40,0.4)] duration-200",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-full p-1 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-xl leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Field({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("grid gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return <label className={cn("text-sm font-medium text-stone-700", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Field, FieldLabel };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-11 w-full min-w-0 rounded-2xl border bg-white/90 px-4 py-2 text-sm shadow-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-stone-300 focus-visible:ring-[3px] focus-visible:ring-stone-200/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const morphTime = 1.5
|
||||
const cooldownTime = 0.5
|
||||
|
||||
const useMorphingText = (texts: string[]) => {
|
||||
const textIndexRef = useRef(0)
|
||||
const morphRef = useRef(0)
|
||||
const cooldownRef = useRef(0)
|
||||
const timeRef = useRef(new Date())
|
||||
|
||||
const text1Ref = useRef<HTMLSpanElement>(null)
|
||||
const text2Ref = useRef<HTMLSpanElement>(null)
|
||||
|
||||
const setStyles = useCallback(
|
||||
(fraction: number) => {
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
if (!current1 || !current2) return
|
||||
|
||||
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`
|
||||
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`
|
||||
|
||||
const invertedFraction = 1 - fraction
|
||||
current1.style.filter = `blur(${Math.min(
|
||||
8 / invertedFraction - 8,
|
||||
100
|
||||
)}px)`
|
||||
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
|
||||
|
||||
current1.textContent = texts[textIndexRef.current % texts.length]
|
||||
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
|
||||
},
|
||||
[texts]
|
||||
)
|
||||
|
||||
const doMorph = useCallback(() => {
|
||||
morphRef.current -= cooldownRef.current
|
||||
cooldownRef.current = 0
|
||||
|
||||
let fraction = morphRef.current / morphTime
|
||||
|
||||
if (fraction > 1) {
|
||||
cooldownRef.current = cooldownTime
|
||||
fraction = 1
|
||||
}
|
||||
|
||||
setStyles(fraction)
|
||||
|
||||
if (fraction === 1) {
|
||||
textIndexRef.current++
|
||||
}
|
||||
}, [setStyles])
|
||||
|
||||
const doCooldown = useCallback(() => {
|
||||
morphRef.current = 0
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
if (current1 && current2) {
|
||||
current2.style.filter = "none"
|
||||
current2.style.opacity = "100%"
|
||||
current1.style.filter = "none"
|
||||
current1.style.opacity = "0%"
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number
|
||||
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
const newTime = new Date()
|
||||
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
|
||||
timeRef.current = newTime
|
||||
|
||||
cooldownRef.current -= dt
|
||||
|
||||
if (cooldownRef.current <= 0) doMorph()
|
||||
else doCooldown()
|
||||
}
|
||||
|
||||
animate()
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [doMorph, doCooldown])
|
||||
|
||||
return { text1Ref, text2Ref }
|
||||
}
|
||||
|
||||
interface MorphingTextProps {
|
||||
className?: string
|
||||
texts: string[]
|
||||
}
|
||||
|
||||
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
|
||||
const { text1Ref, text2Ref } = useMorphingText(texts)
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text1Ref}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text2Ref}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SvgFilters: React.FC = () => (
|
||||
<svg
|
||||
id="filters"
|
||||
className="fixed h-0 w-0"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<filter id="threshold">
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 255 -140"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MorphingText: React.FC<MorphingTextProps> = ({
|
||||
texts,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto h-16 w-full max-w-3xl text-center font-sans text-[40pt] leading-none font-bold filter-[url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Texts texts={texts} />
|
||||
<SvgFilters />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 rounded-2xl border border-white/80 bg-white p-3 text-stone-950 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)] outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup(
|
||||
props: React.ComponentProps<typeof SelectPrimitive.Group>,
|
||||
) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue(
|
||||
props: React.ComponentProps<typeof SelectPrimitive.Value>,
|
||||
) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input bg-background data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-11 w-full items-center justify-between gap-2 rounded-2xl border px-4 py-2 text-sm whitespace-nowrap shadow-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out relative z-50 max-h-96 min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-2xl border border-white/80 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-xl py-2 pr-8 pl-3 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return <thead className={cn("border-b border-stone-100 text-[11px] tracking-[0.18em] text-stone-400 uppercase", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr className={cn("border-b border-stone-100 transition-colors hover:bg-stone-50/70", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return <th className={cn("h-11 px-4 text-left align-middle font-medium", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return <td className={cn("px-4 py-3 align-middle", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ElementType,
|
||||
} from "react"
|
||||
import {
|
||||
useAnimate,
|
||||
type AnimationOptions,
|
||||
type ValueAnimationTransition,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HAS_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
if (HAS_SEGMENTER) {
|
||||
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
|
||||
return Array.from(segmenter.segment(text), ({ segment }) => segment)
|
||||
}
|
||||
return Array.from(text)
|
||||
}
|
||||
|
||||
const extractTextFromChildren = (children: React.ReactNode): string => {
|
||||
if (children == null) return ""
|
||||
if (typeof children === "string") return children
|
||||
if (typeof children === "number") return String(children)
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(extractTextFromChildren).join("")
|
||||
}
|
||||
|
||||
if (React.isValidElement(children)) {
|
||||
const props = children.props as Record<string, unknown>
|
||||
const childText = props.children as React.ReactNode
|
||||
if (childText != null) {
|
||||
return extractTextFromChildren(childText)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const ROTATION_MAP = {
|
||||
top: "rotateX(90deg)",
|
||||
right: "rotateY(90deg)",
|
||||
bottom: "rotateX(-90deg)",
|
||||
left: "rotateY(-90deg)",
|
||||
} as const
|
||||
|
||||
const DEFAULT_TRANSITION: ValueAnimationTransition = {
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}
|
||||
|
||||
interface Text3DFlipProps {
|
||||
children: React.ReactNode
|
||||
as?: ElementType
|
||||
className?: string
|
||||
textClassName?: string
|
||||
flipTextClassName?: string
|
||||
staggerDuration?: number
|
||||
staggerFrom?: "first" | "last" | "center" | number | "random"
|
||||
transition?: ValueAnimationTransition | AnimationOptions
|
||||
rotateDirection?: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const Text3DFlip = ({
|
||||
children,
|
||||
as: ElementTag = "p",
|
||||
className,
|
||||
textClassName,
|
||||
flipTextClassName,
|
||||
staggerDuration = 0.05,
|
||||
staggerFrom = "first",
|
||||
transition = DEFAULT_TRANSITION,
|
||||
rotateDirection = "right",
|
||||
...props
|
||||
}: Text3DFlipProps) => {
|
||||
const isAnimatingRef = useRef(false)
|
||||
const isMountedRef = useRef(false)
|
||||
const [scope, animate] = useAnimate()
|
||||
|
||||
const rotationTransform = ROTATION_MAP[rotateDirection]
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const text = useMemo(() => {
|
||||
try {
|
||||
return extractTextFromChildren(children)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}, [children])
|
||||
|
||||
const characters = useMemo(() => {
|
||||
const words = text.split(" ")
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1,
|
||||
}))
|
||||
}, [text])
|
||||
|
||||
const charOffsets = useMemo(() => {
|
||||
const offsets = [0]
|
||||
for (const word of characters) {
|
||||
offsets.push(offsets.at(-1)! + word.characters.length)
|
||||
}
|
||||
return offsets
|
||||
}, [characters])
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index: number, totalChars: number) => {
|
||||
if (staggerFrom === "first") return index * staggerDuration
|
||||
if (staggerFrom === "last")
|
||||
return (totalChars - 1 - index) * staggerDuration
|
||||
if (staggerFrom === "center") {
|
||||
const center = Math.floor(totalChars / 2)
|
||||
return Math.abs(center - index) * staggerDuration
|
||||
}
|
||||
if (staggerFrom === "random") {
|
||||
const randomIndex = Math.floor(Math.random() * totalChars)
|
||||
return Math.abs(randomIndex - index) * staggerDuration
|
||||
}
|
||||
return Math.abs(staggerFrom - index) * staggerDuration
|
||||
},
|
||||
[staggerFrom, staggerDuration]
|
||||
)
|
||||
|
||||
const handleHoverStart = useCallback(async () => {
|
||||
if (isAnimatingRef.current) return
|
||||
isAnimatingRef.current = true
|
||||
|
||||
try {
|
||||
const totalChars = characters.reduce(
|
||||
(sum, word) => sum + word.characters.length,
|
||||
0
|
||||
)
|
||||
|
||||
const delays = Array.from({ length: totalChars }, (_, i) =>
|
||||
getStaggerDelay(i, totalChars)
|
||||
)
|
||||
|
||||
await animate(
|
||||
".text-3d-flip-char",
|
||||
{ transform: rotationTransform },
|
||||
{
|
||||
...transition,
|
||||
delay: (i: number) => delays[i],
|
||||
}
|
||||
)
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
await animate(
|
||||
".text-3d-flip-char",
|
||||
{ transform: "rotateX(0deg) rotateY(0deg)" },
|
||||
{ duration: 0 }
|
||||
)
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}
|
||||
}, [characters, transition, getStaggerDelay, rotationTransform, animate])
|
||||
|
||||
return (
|
||||
<ElementTag
|
||||
className={cn("relative flex flex-wrap", className)}
|
||||
onMouseEnter={handleHoverStart}
|
||||
ref={scope}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
|
||||
{characters.map((wordObj, wordIndex) => (
|
||||
<span key={wordIndex} className="inline-flex">
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<CharBox
|
||||
key={charOffsets[wordIndex] + charIndex}
|
||||
char={char}
|
||||
textClassName={textClassName}
|
||||
flipTextClassName={flipTextClassName}
|
||||
rotateDirection={rotateDirection}
|
||||
/>
|
||||
))}
|
||||
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
|
||||
</span>
|
||||
))}
|
||||
</ElementTag>
|
||||
)
|
||||
}
|
||||
|
||||
interface CharBoxProps {
|
||||
char: string
|
||||
textClassName?: string
|
||||
flipTextClassName?: string
|
||||
rotateDirection: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const SECOND_FACE_TRANSFORMS = {
|
||||
top: "rotateX(-90deg) translateZ(0.5lh)",
|
||||
right:
|
||||
"rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)",
|
||||
bottom: "rotateX(90deg) translateZ(0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(50%)",
|
||||
} as const
|
||||
|
||||
const FRONT_FACE_TRANSFORMS = {
|
||||
top: "translateZ(0.5lh)",
|
||||
bottom: "translateZ(0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
right: "rotateY(-90deg) translateX(50%) rotateY(90deg)",
|
||||
} as const
|
||||
|
||||
const CONTAINER_TRANSFORMS = {
|
||||
top: "translateZ(-0.5lh)",
|
||||
bottom: "translateZ(-0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
right: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
} as const
|
||||
|
||||
const CharBox = memo(
|
||||
({
|
||||
char,
|
||||
textClassName,
|
||||
flipTextClassName,
|
||||
rotateDirection,
|
||||
}: CharBoxProps) => (
|
||||
<span
|
||||
className="text-3d-flip-char inline transform-3d"
|
||||
style={{ transform: CONTAINER_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
<span
|
||||
className={cn("relative h-[1lh] backface-hidden", textClassName)}
|
||||
style={{ transform: FRONT_FACE_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0 left-0 h-[1lh] backface-hidden",
|
||||
flipTextClassName
|
||||
)}
|
||||
style={{ transform: SECOND_FACE_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
)
|
||||
|
||||
CharBox.displayName = "CharBox"
|
||||
Text3DFlip.displayName = "Text3DFlip"
|
||||
|
||||
export default Text3DFlip
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 block min-h-32 w-full rounded-[24px] border bg-white/90 px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { useVersionCheck } from "@/hooks/use-version-check";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function typeVariant(type: string): "success" | "danger" | "info" | "violet" | "outline" {
|
||||
if (type === "新增") return "success";
|
||||
if (type === "修复") return "danger";
|
||||
if (type === "调整") return "info";
|
||||
if (type === "文档") return "violet";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export function VersionReleaseDialog({ className }: { className?: string }) {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
openReleaseModal,
|
||||
latestVersion,
|
||||
releases,
|
||||
checking,
|
||||
hasNewVersion,
|
||||
checkLatestRelease,
|
||||
} = useVersionCheck();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative px-1 py-1 text-[11px] font-medium text-stone-500 transition hover:text-stone-900 dark:text-stone-300 dark:hover:text-white",
|
||||
className,
|
||||
)}
|
||||
onClick={openReleaseModal}
|
||||
title="查看版本更新"
|
||||
>
|
||||
v{webConfig.appVersion}
|
||||
{hasNewVersion ? (
|
||||
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-emerald-500" />
|
||||
) : null}
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[min(94vw,680px)] rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>版本更新</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<VersionCard label="当前版本" value={webConfig.appVersion} />
|
||||
<VersionCard
|
||||
label="最新版本"
|
||||
value={latestVersion}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-stone-400 underline-offset-2 hover:text-stone-700 hover:underline dark:hover:text-stone-200"
|
||||
onClick={() => void checkLatestRelease(true)}
|
||||
>
|
||||
{checking ? "检查中..." : "检查更新"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[56vh] space-y-5 overflow-y-auto pr-1">
|
||||
{releases.map((release) => (
|
||||
<div key={release.version} className="border-l border-stone-200 pl-4 dark:border-white/10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">
|
||||
{release.version === "Unreleased" ? "未发布" : release.version}
|
||||
</span>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
|
||||
{release.version === latestVersion ? <Badge variant="success">最新</Badge> : null}
|
||||
{release.version === webConfig.appVersion ? <Badge variant="outline">当前</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{release.items.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
||||
<Badge variant={typeVariant(item.type)} className="mt-0.5 shrink-0">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1">{item.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="https://github.com/basketikun/chatgpt2api" target="_blank" rel="noreferrer">
|
||||
前往 GitHub 更新
|
||||
</a>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCard({
|
||||
label,
|
||||
value,
|
||||
action,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-white/55 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">{label}</div>
|
||||
{action}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const webConfig = {
|
||||
apiUrl: process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8000' : '',
|
||||
appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
|
||||
}
|
||||
|
||||
export default webConfig
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import webConfig from "@/constants/common-env";
|
||||
import { parseChangelog, type ReleaseInfo } from "@/lib/release";
|
||||
|
||||
const latestVersionUrl =
|
||||
"https://raw.githubusercontent.com/basketikun/chatgpt2api/main/VERSION";
|
||||
const latestChangelogUrl =
|
||||
"https://raw.githubusercontent.com/basketikun/chatgpt2api/main/CHANGELOG.md";
|
||||
|
||||
function readLocalReleases(): ReleaseInfo[] {
|
||||
return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
|
||||
}
|
||||
|
||||
function toVersionParts(version: string) {
|
||||
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
function isNewerVersion(latestVersion: string, currentVersion: string) {
|
||||
const latest = toVersionParts(latestVersion);
|
||||
const current = toVersionParts(currentVersion);
|
||||
if (!latest || !current) return false;
|
||||
return latest.some(
|
||||
(value, index) =>
|
||||
value > current[index] &&
|
||||
latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]),
|
||||
);
|
||||
}
|
||||
|
||||
export function useVersionCheck() {
|
||||
const currentVersion = webConfig.appVersion;
|
||||
const localReleases = useMemo(readLocalReleases, []);
|
||||
const [latestVersion, setLatestVersion] = useState(currentVersion);
|
||||
const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
|
||||
|
||||
const checkLatestRelease = useCallback(
|
||||
async (showMessage = false) => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const [versionResponse, changelogResponse] = await Promise.all([
|
||||
fetch(latestVersionUrl),
|
||||
fetch(latestChangelogUrl),
|
||||
]);
|
||||
if (!versionResponse.ok || !changelogResponse.ok) throw new Error();
|
||||
const [version, changelog] = await Promise.all([
|
||||
versionResponse.text(),
|
||||
changelogResponse.text(),
|
||||
]);
|
||||
setLatestVersion(version.trim() || currentVersion);
|
||||
if (changelog.trim()) setReleases(parseChangelog(changelog));
|
||||
if (showMessage) toast.success("已获取最新版本信息");
|
||||
} catch {
|
||||
setLatestVersion(currentVersion);
|
||||
setReleases(localReleases);
|
||||
if (showMessage) toast.error("获取最新版本信息失败");
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
},
|
||||
[currentVersion, localReleases],
|
||||
);
|
||||
|
||||
const openReleaseModal = () => {
|
||||
setOpen(true);
|
||||
void checkLatestRelease();
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
openReleaseModal,
|
||||
latestVersion,
|
||||
releases,
|
||||
checking,
|
||||
hasNewVersion,
|
||||
checkLatestRelease,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
import { httpRequest, request } from "@/lib/request";
|
||||
|
||||
export type AccountType = string;
|
||||
export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
|
||||
export type ImageModel = string;
|
||||
export type AuthRole = "admin" | "user";
|
||||
export type ImageStorageMode = "local" | "webdav" | "both";
|
||||
|
||||
export type ImageStorageSettings = {
|
||||
enabled: boolean;
|
||||
mode: ImageStorageMode;
|
||||
webdav_url: string;
|
||||
webdav_username: string;
|
||||
webdav_password: string;
|
||||
webdav_root_path: string;
|
||||
public_base_url: string;
|
||||
};
|
||||
|
||||
export type Account = {
|
||||
access_token: string;
|
||||
type: AccountType;
|
||||
source_type?: string | null;
|
||||
status: AccountStatus;
|
||||
quota: number;
|
||||
email?: string | null;
|
||||
user_id?: string | null;
|
||||
limits_progress?: Array<{
|
||||
feature_name?: string;
|
||||
remaining?: number;
|
||||
reset_after?: string;
|
||||
}>;
|
||||
default_model_slug?: string | null;
|
||||
restore_at?: string | null;
|
||||
upload_blocked_until?: string | null;
|
||||
upload_limit_reason?: string | null;
|
||||
success: number;
|
||||
fail: number;
|
||||
/** 当前图片在途数(正在生成、尚未结束的图片数)。号池空闲时持续 > 0 表示并发槽位泄漏。 */
|
||||
image_inflight?: number;
|
||||
last_used_at?: string | null;
|
||||
proxy?: string | null;
|
||||
};
|
||||
|
||||
export type AccountImportPayload = {
|
||||
access_token: string;
|
||||
accessToken?: string;
|
||||
type?: string;
|
||||
export_type?: string;
|
||||
source_type?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type Model = {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
permission: unknown[];
|
||||
root: string;
|
||||
parent: string | null;
|
||||
};
|
||||
|
||||
type AccountListResponse = {
|
||||
items: Account[];
|
||||
};
|
||||
|
||||
type ModelListResponse = {
|
||||
object: string;
|
||||
data: Model[];
|
||||
};
|
||||
|
||||
type AccountMutationResponse = {
|
||||
items: Account[];
|
||||
added?: number;
|
||||
skipped?: number;
|
||||
removed?: number;
|
||||
refreshed?: number;
|
||||
relogined?: number;
|
||||
errors?: Array<{ access_token: string; error: string }>;
|
||||
};
|
||||
|
||||
export type AccountRefreshResponse = {
|
||||
items: Account[];
|
||||
refreshed: number;
|
||||
relogined?: number;
|
||||
errors: Array<{ access_token: string; error: string }>;
|
||||
};
|
||||
|
||||
export type RefreshProgressResponse = {
|
||||
total: number;
|
||||
processed: number;
|
||||
done: boolean;
|
||||
error: string | null;
|
||||
status_counts?: Record<string, number>;
|
||||
total_quota?: number;
|
||||
result?: AccountRefreshResponse | null;
|
||||
results?: Array<{ token: string; status: string; error?: string | null }>;
|
||||
};
|
||||
|
||||
type AccountUpdateResponse = {
|
||||
item: Account;
|
||||
items: Account[];
|
||||
};
|
||||
|
||||
export type ProxyRuntimeEgressMode = "direct" | "single_proxy";
|
||||
export type ProxyRuntimeClearanceMode = "none" | "manual" | "flaresolverr";
|
||||
|
||||
export type ProxyRuntimeClearanceSettings = {
|
||||
enabled: boolean;
|
||||
mode: ProxyRuntimeClearanceMode;
|
||||
cf_cookies: string;
|
||||
cf_clearance: string;
|
||||
user_agent: string;
|
||||
browser: string;
|
||||
flaresolverr_url: string;
|
||||
timeout_sec: number | string;
|
||||
refresh_interval: number | string;
|
||||
warm_up_on_start: boolean;
|
||||
has_cf_cookies?: boolean;
|
||||
has_cf_clearance?: boolean;
|
||||
};
|
||||
|
||||
export type ProxyRuntimeSettings = {
|
||||
enabled: boolean;
|
||||
egress_mode: ProxyRuntimeEgressMode;
|
||||
proxy_url: string;
|
||||
resource_proxy_url: string;
|
||||
skip_ssl_verify: boolean;
|
||||
reset_session_status_codes: number[];
|
||||
clearance: ProxyRuntimeClearanceSettings;
|
||||
};
|
||||
|
||||
export type ProxyRuntimeStatus = {
|
||||
enabled: boolean;
|
||||
egress_mode: ProxyRuntimeEgressMode | string;
|
||||
proxy_source: string;
|
||||
has_proxy: boolean;
|
||||
clearance_enabled: boolean;
|
||||
clearance_mode: ProxyRuntimeClearanceMode | string;
|
||||
has_clearance_bundle: boolean;
|
||||
cached_clearance_hosts: string[];
|
||||
};
|
||||
|
||||
export type ProxyRuntimeResponse = {
|
||||
runtime: ProxyRuntimeSettings;
|
||||
status: ProxyRuntimeStatus;
|
||||
};
|
||||
|
||||
export type ThirdPartyAppsSettings = {
|
||||
infinite_canvas: {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SettingsConfig = {
|
||||
proxy: string;
|
||||
base_url?: string;
|
||||
global_system_prompt?: string;
|
||||
default_upstream_model_name?: string;
|
||||
default_thinking_effort?: "auto" | "standard" | "extended" | "max";
|
||||
sensitive_words?: string[];
|
||||
ai_review?: {
|
||||
enabled?: boolean;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
refresh_account_interval_minute?: number | string;
|
||||
image_retention_days?: number | string;
|
||||
image_poll_timeout_secs?: number | string;
|
||||
image_account_concurrency?: number | string;
|
||||
image_parallel_generation?: boolean;
|
||||
image_settle_enabled?: boolean;
|
||||
image_check_before_hit_enabled?: boolean;
|
||||
image_remove_conversation_after_result?: boolean;
|
||||
image_remove_conversation_always?: boolean;
|
||||
image_settle_secs?: number | string;
|
||||
image_timeout_retry_secs?: number | string;
|
||||
auto_remove_invalid_accounts?: boolean;
|
||||
auto_remove_rate_limited_accounts?: boolean;
|
||||
auto_relogin_after_refresh?: boolean;
|
||||
log_levels?: string[];
|
||||
image_storage?: ImageStorageSettings;
|
||||
proxy_runtime?: ProxyRuntimeSettings;
|
||||
third_party_apps?: ThirdPartyAppsSettings;
|
||||
backup?: BackupSettings;
|
||||
backup_state?: BackupState;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type BackupInclude = {
|
||||
config: boolean;
|
||||
cpa: boolean;
|
||||
sub2api: boolean;
|
||||
logs: boolean;
|
||||
image_tasks: boolean;
|
||||
accounts_snapshot: boolean;
|
||||
auth_keys_snapshot: boolean;
|
||||
images: boolean;
|
||||
};
|
||||
|
||||
export type BackupSettings = {
|
||||
enabled: boolean;
|
||||
provider: "cloudflare_r2" | string;
|
||||
account_id: string;
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
interval_minutes: number | string;
|
||||
rotation_keep: number | string;
|
||||
encrypt: boolean;
|
||||
passphrase: string;
|
||||
include: BackupInclude;
|
||||
};
|
||||
|
||||
export type BackupState = {
|
||||
running: boolean;
|
||||
last_started_at?: string | null;
|
||||
last_finished_at?: string | null;
|
||||
last_status?: string;
|
||||
last_error?: string | null;
|
||||
last_object_key?: string | null;
|
||||
};
|
||||
|
||||
export type BackupItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
size: number;
|
||||
updated_at?: string | null;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
export type BackupDetail = {
|
||||
key: string;
|
||||
name: string;
|
||||
encrypted: boolean;
|
||||
created_at?: string | null;
|
||||
trigger?: string | null;
|
||||
app_version?: string | null;
|
||||
storage_backend?: Record<string, unknown> | null;
|
||||
files: Array<{
|
||||
name: string;
|
||||
exists: boolean;
|
||||
content_type?: string;
|
||||
size: number;
|
||||
sha256?: string;
|
||||
}>;
|
||||
snapshots: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ManagedImage = {
|
||||
rel: string;
|
||||
path?: string;
|
||||
name: string;
|
||||
date: string;
|
||||
size: number;
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
created_at: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type SystemLog = {
|
||||
id: string;
|
||||
time: string;
|
||||
type: "call" | "account" | string;
|
||||
summary?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ImageResponse = {
|
||||
created: number;
|
||||
data: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
|
||||
};
|
||||
|
||||
export type ImageTask = {
|
||||
id: string;
|
||||
status: "queued" | "running" | "success" | "error";
|
||||
mode: "generate" | "edit";
|
||||
model?: ImageModel;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
conversation_id?: string;
|
||||
data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
|
||||
error?: string;
|
||||
progress?: string;
|
||||
elapsed_secs?: number;
|
||||
stage_durations_ms?: Record<string, number>;
|
||||
duration_ms?: number;
|
||||
};
|
||||
|
||||
type ImageTaskListResponse = {
|
||||
items: ImageTask[];
|
||||
missing_ids: string[];
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
ok: boolean;
|
||||
version: string;
|
||||
role: AuthRole;
|
||||
subject_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "user";
|
||||
enabled: boolean;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
|
||||
export async function login(authKey: string) {
|
||||
const normalizedAuthKey = String(authKey || "").trim();
|
||||
return httpRequest<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
headers: {
|
||||
Authorization: `Bearer ${normalizedAuthKey}`,
|
||||
},
|
||||
redirectOnUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAccounts() {
|
||||
return httpRequest<AccountListResponse>("/api/accounts");
|
||||
}
|
||||
|
||||
export async function fetchModels() {
|
||||
return httpRequest<ModelListResponse>("/v1/models");
|
||||
}
|
||||
|
||||
export async function createAccounts(tokens: string[], accounts: AccountImportPayload[] = []) {
|
||||
return httpRequest<AccountMutationResponse>("/api/accounts", {
|
||||
method: "POST",
|
||||
body: { tokens, accounts },
|
||||
});
|
||||
}
|
||||
|
||||
export type OAuthLoginStartResponse = {
|
||||
session_id: string;
|
||||
authorize_url: string;
|
||||
expires_in: string;
|
||||
redirect_uri_prefix: string;
|
||||
};
|
||||
|
||||
export async function startOAuthLogin(emailHint?: string) {
|
||||
return httpRequest<OAuthLoginStartResponse>("/api/accounts/oauth/start", {
|
||||
method: "POST",
|
||||
body: { email_hint: emailHint ?? "" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishOAuthLogin(sessionId: string, callback: string) {
|
||||
return httpRequest<AccountMutationResponse>("/api/accounts/oauth/finish", {
|
||||
method: "POST",
|
||||
body: { session_id: sessionId, callback },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAccounts(tokens: string[]) {
|
||||
return httpRequest<AccountMutationResponse>("/api/accounts", {
|
||||
method: "DELETE",
|
||||
body: { tokens },
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshAccounts(accessTokens: string[]) {
|
||||
return httpRequest<{ progress_id: string }>("/api/accounts/refresh", {
|
||||
method: "POST",
|
||||
body: { access_tokens: accessTokens },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRefreshProgress(progressId: string) {
|
||||
return httpRequest<RefreshProgressResponse>(`/api/accounts/refresh/progress/${progressId}`);
|
||||
}
|
||||
|
||||
export async function reLoginAccounts(accessTokens: string[]) {
|
||||
return httpRequest<{ progress_id: string }>("/api/accounts/re-login", {
|
||||
method: "POST",
|
||||
body: { access_tokens: accessTokens },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReLoginProgress(progressId: string) {
|
||||
return httpRequest<RefreshProgressResponse>(`/api/accounts/re-login/progress/${progressId}`);
|
||||
}
|
||||
|
||||
export async function updateAccount(
|
||||
accessToken: string,
|
||||
updates: {
|
||||
type?: AccountType;
|
||||
status?: AccountStatus;
|
||||
quota?: number;
|
||||
proxy?: string;
|
||||
},
|
||||
) {
|
||||
return httpRequest<AccountUpdateResponse>("/api/accounts/update", {
|
||||
method: "POST",
|
||||
body: {
|
||||
access_token: accessToken,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateImage(prompt: string, model?: ImageModel, size?: string, quality = "auto") {
|
||||
return httpRequest<ImageResponse>(
|
||||
"/v1/images/generations",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
prompt,
|
||||
...(model ? { model } : {}),
|
||||
...(size ? { size } : {}),
|
||||
quality,
|
||||
n: 1,
|
||||
response_format: "b64_json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function editImage(files: File | File[], prompt: string, model?: ImageModel, size?: string, quality = "auto") {
|
||||
const formData = new FormData();
|
||||
const uploadFiles = Array.isArray(files) ? files : [files];
|
||||
|
||||
uploadFiles.forEach((file) => {
|
||||
formData.append("image", file);
|
||||
});
|
||||
formData.append("prompt", prompt);
|
||||
if (model) {
|
||||
formData.append("model", model);
|
||||
}
|
||||
if (size) {
|
||||
formData.append("size", size);
|
||||
}
|
||||
formData.append("quality", quality);
|
||||
formData.append("n", "1");
|
||||
|
||||
return httpRequest<ImageResponse>(
|
||||
"/v1/images/edits",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createImageGenerationTask(clientTaskId: string, prompt: string, model?: ImageModel, size?: string, quality = "auto") {
|
||||
return httpRequest<ImageTask>("/api/image-tasks/generations", {
|
||||
method: "POST",
|
||||
body: {
|
||||
client_task_id: clientTaskId,
|
||||
prompt,
|
||||
...(model ? { model } : {}),
|
||||
...(size ? { size } : {}),
|
||||
quality,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createImageEditTask(
|
||||
clientTaskId: string,
|
||||
files: File | File[],
|
||||
prompt: string,
|
||||
model?: ImageModel,
|
||||
size?: string,
|
||||
quality = "auto",
|
||||
) {
|
||||
const formData = new FormData();
|
||||
const uploadFiles = Array.isArray(files) ? files : [files];
|
||||
|
||||
uploadFiles.forEach((file) => {
|
||||
formData.append("image", file);
|
||||
});
|
||||
formData.append("client_task_id", clientTaskId);
|
||||
formData.append("prompt", prompt);
|
||||
if (model) {
|
||||
formData.append("model", model);
|
||||
}
|
||||
if (size) {
|
||||
formData.append("size", size);
|
||||
}
|
||||
formData.append("quality", quality);
|
||||
|
||||
return httpRequest<ImageTask>("/api/image-tasks/edits", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchImageTasks(ids: string[]) {
|
||||
const params = new URLSearchParams();
|
||||
if (ids.length > 0) {
|
||||
params.set("ids", ids.join(","));
|
||||
}
|
||||
params.set("_t", String(Date.now()));
|
||||
return httpRequest<ImageTaskListResponse>(`/api/image-tasks?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function resumeImagePoll(taskId: string, extraTimeoutSecs = 30) {
|
||||
return httpRequest<ImageTask>(`/api/image-tasks/${encodeURIComponent(taskId)}/resume-poll`, {
|
||||
method: "POST",
|
||||
body: { extra_timeout_secs: extraTimeoutSecs },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSettingsConfig() {
|
||||
return httpRequest<{ config: SettingsConfig }>("/api/settings");
|
||||
}
|
||||
|
||||
export async function updateSettingsConfig(settings: SettingsConfig) {
|
||||
return httpRequest<{ config: SettingsConfig }>("/api/settings", {
|
||||
method: "POST",
|
||||
body: settings,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchThirdPartyApps() {
|
||||
return httpRequest<{ third_party_apps: ThirdPartyAppsSettings }>("/api/third-party-apps");
|
||||
}
|
||||
|
||||
export async function testBackupConnection() {
|
||||
return httpRequest<{ result: { ok: boolean; status: number } }>("/api/backup/test", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function testImageStorageConnection() {
|
||||
return httpRequest<{ result: { ok: boolean; status: number; error?: string } }>("/api/image-storage/test", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncImageStorage() {
|
||||
return httpRequest<{ result: { uploaded: number; skipped: number; failed: number } }>("/api/image-storage/sync", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBackups() {
|
||||
return httpRequest<{ items: BackupItem[]; state: BackupState; settings: BackupSettings }>("/api/backups");
|
||||
}
|
||||
|
||||
export async function runBackupNow() {
|
||||
return httpRequest<{ result: { key: string; size: number; encrypted: boolean } }>("/api/backups/run", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBackup(key: string) {
|
||||
return httpRequest<{ ok: boolean }>("/api/backups/delete", {
|
||||
method: "POST",
|
||||
body: { key },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBackupDetail(key: string) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("key", key);
|
||||
return httpRequest<{ item: BackupDetail }>(`/api/backups/detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function getBackupDownloadUrl(key: string) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("key", key);
|
||||
return `/api/backups/download?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function fetchManagedImages(filters: { start_date?: string; end_date?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.start_date) params.set("start_date", filters.start_date);
|
||||
if (filters.end_date) params.set("end_date", filters.end_date);
|
||||
return httpRequest<{ items: ManagedImage[]; groups: Array<{ date: string; items: ManagedImage[] }> }>(
|
||||
`/api/images${params.toString() ? `?${params.toString()}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteManagedImages(body: { paths?: string[]; start_date?: string; end_date?: string; all_matching?: boolean }) {
|
||||
return httpRequest<{ removed: number }>("/api/images/delete", { method: "POST", body });
|
||||
}
|
||||
|
||||
export async function downloadImages(paths: string[]) {
|
||||
const response = await request.post("/api/images/download", { paths }, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "images.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function downloadSingleImage(path: string) {
|
||||
const response = await request.get(`/api/images/download/${path}`, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = path.split("/").pop() || "image.png";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function fetchImageTags() {
|
||||
return httpRequest<{ tags: string[] }>("/api/images/tags");
|
||||
}
|
||||
|
||||
export async function setImageTags(path: string, tags: string[]) {
|
||||
return httpRequest<{ ok: boolean; tags: string[] }>("/api/images/tags", {
|
||||
method: "POST",
|
||||
body: { path, tags },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteImageTag(tag: string) {
|
||||
return httpRequest<{ ok: boolean; removed_from: number }>(`/api/images/tags/${encodeURIComponent(tag)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export type ImageStorageStats = {
|
||||
disk_total_mb: number; disk_used_mb: number; disk_free_mb: number;
|
||||
image_count: number; image_size_mb: number; image_size_bytes: number;
|
||||
};
|
||||
|
||||
export async function fetchImageStorage() {
|
||||
return httpRequest<ImageStorageStats>("/api/images/storage");
|
||||
}
|
||||
|
||||
export async function compressAllImages() {
|
||||
return httpRequest<{ compressed: number; saved_bytes: number; saved_mb: number }>("/api/images/storage/compress", { method: "POST" });
|
||||
}
|
||||
|
||||
export async function deleteToTarget(targetFreeMb: number) {
|
||||
return httpRequest<{ removed: number; freed_mb: number; done: boolean }>(
|
||||
`/api/images/storage/cleanup-to-target?target_free_mb=${targetFreeMb}&dry_run=false`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSystemLogs(filters: { type?: string; start_date?: string; end_date?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.type) params.set("type", filters.type);
|
||||
if (filters.start_date) params.set("start_date", filters.start_date);
|
||||
if (filters.end_date) params.set("end_date", filters.end_date);
|
||||
return httpRequest<{ items: SystemLog[] }>(`/api/logs${params.toString() ? `?${params.toString()}` : ""}`);
|
||||
}
|
||||
|
||||
export async function deleteSystemLogs(ids: string[]) {
|
||||
return httpRequest<{ removed: number }>("/api/logs/delete", {
|
||||
method: "POST",
|
||||
body: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUserKeys() {
|
||||
return httpRequest<{ items: UserKey[] }>("/api/auth/users");
|
||||
}
|
||||
|
||||
export async function createUserKey(name: string) {
|
||||
return httpRequest<{ item: UserKey; key: string; items: UserKey[] }>("/api/auth/users", {
|
||||
method: "POST",
|
||||
body: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserKey(keyId: string, updates: { enabled?: boolean; name?: string; key?: string }) {
|
||||
return httpRequest<{ item: UserKey; items: UserKey[] }>(`/api/auth/users/${keyId}`, {
|
||||
method: "POST",
|
||||
body: updates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserKey(keyId: string) {
|
||||
return httpRequest<{ items: UserKey[] }>(`/api/auth/users/${keyId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// ── CPA (CLIProxyAPI) ──────────────────────────────────────────────
|
||||
|
||||
export type CPAPool = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
import_job?: CPAImportJob | null;
|
||||
};
|
||||
|
||||
export type CPARemoteFile = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type CPAImportJob = {
|
||||
job_id: string;
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
added: number;
|
||||
skipped: number;
|
||||
refreshed: number;
|
||||
failed: number;
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
};
|
||||
|
||||
export async function fetchCPAPools() {
|
||||
return httpRequest<{ pools: CPAPool[] }>("/api/cpa/pools");
|
||||
}
|
||||
|
||||
export async function createCPAPool(pool: { name: string; base_url: string; secret_key: string }) {
|
||||
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>("/api/cpa/pools", {
|
||||
method: "POST",
|
||||
body: pool,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCPAPool(
|
||||
poolId: string,
|
||||
updates: { name?: string; base_url?: string; secret_key?: string },
|
||||
) {
|
||||
return httpRequest<{ pool: CPAPool; pools: CPAPool[] }>(`/api/cpa/pools/${poolId}`, {
|
||||
method: "POST",
|
||||
body: updates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCPAPool(poolId: string) {
|
||||
return httpRequest<{ pools: CPAPool[] }>(`/api/cpa/pools/${poolId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCPAPoolFiles(poolId: string) {
|
||||
return httpRequest<{ pool_id: string; files: CPARemoteFile[] }>(`/api/cpa/pools/${poolId}/files`);
|
||||
}
|
||||
|
||||
export async function startCPAImport(poolId: string, names: string[]) {
|
||||
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/cpa/pools/${poolId}/import`, {
|
||||
method: "POST",
|
||||
body: { names },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCPAPoolImportJob(poolId: string) {
|
||||
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/cpa/pools/${poolId}/import`);
|
||||
}
|
||||
|
||||
// ── Sub2API ────────────────────────────────────────────────────────
|
||||
|
||||
export type Sub2APIServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
email: string;
|
||||
has_api_key: boolean;
|
||||
group_id: string;
|
||||
import_job?: CPAImportJob | null;
|
||||
};
|
||||
|
||||
export type Sub2APIRemoteAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
plan_type: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
has_refresh_token: boolean;
|
||||
};
|
||||
|
||||
export type Sub2APIRemoteGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
account_count: number;
|
||||
active_account_count: number;
|
||||
};
|
||||
|
||||
export async function fetchSub2APIServers() {
|
||||
return httpRequest<{ servers: Sub2APIServer[] }>("/api/sub2api/servers");
|
||||
}
|
||||
|
||||
export async function createSub2APIServer(server: {
|
||||
name: string;
|
||||
base_url: string;
|
||||
email: string;
|
||||
password: string;
|
||||
api_key: string;
|
||||
group_id: string;
|
||||
}) {
|
||||
return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>("/api/sub2api/servers", {
|
||||
method: "POST",
|
||||
body: server,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSub2APIServer(
|
||||
serverId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
api_key?: string;
|
||||
group_id?: string;
|
||||
},
|
||||
) {
|
||||
return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
|
||||
method: "POST",
|
||||
body: updates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSub2APIServerGroups(serverId: string) {
|
||||
return httpRequest<{ server_id: string; groups: Sub2APIRemoteGroup[] }>(
|
||||
`/api/sub2api/servers/${serverId}/groups`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSub2APIServer(serverId: string) {
|
||||
return httpRequest<{ servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSub2APIServerAccounts(serverId: string) {
|
||||
return httpRequest<{ server_id: string; accounts: Sub2APIRemoteAccount[] }>(
|
||||
`/api/sub2api/servers/${serverId}/accounts`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startSub2APIImport(serverId: string, accountIds: string[]) {
|
||||
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`, {
|
||||
method: "POST",
|
||||
body: { account_ids: accountIds },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSub2APIImportJob(serverId: string) {
|
||||
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`);
|
||||
}
|
||||
|
||||
// ── Upstream proxy ────────────────────────────────────────────────
|
||||
|
||||
export type ProxySettings = {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ProxyTestResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
latency_ms: number;
|
||||
error: string | null;
|
||||
proxy_source?: string;
|
||||
has_proxy?: boolean;
|
||||
};
|
||||
|
||||
export type ClearanceTestResult = {
|
||||
ok: boolean;
|
||||
status: string;
|
||||
latency_ms: number;
|
||||
has_cookies: boolean;
|
||||
user_agent: string;
|
||||
error: string | null;
|
||||
runtime: ProxyRuntimeStatus;
|
||||
};
|
||||
|
||||
export async function fetchProxy() {
|
||||
return httpRequest<{ proxy: ProxySettings }>("/api/proxy");
|
||||
}
|
||||
|
||||
export async function updateProxy(updates: { enabled?: boolean; url?: string }) {
|
||||
return httpRequest<{ proxy: ProxySettings }>("/api/proxy", {
|
||||
method: "POST",
|
||||
body: updates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testProxy(url?: string) {
|
||||
return httpRequest<{ result: ProxyTestResult }>("/api/proxy/test", {
|
||||
method: "POST",
|
||||
body: { url: url ?? "" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchProxyRuntime() {
|
||||
return httpRequest<ProxyRuntimeResponse>("/api/proxy/runtime");
|
||||
}
|
||||
|
||||
export async function updateProxyRuntime(runtime: ProxyRuntimeSettings) {
|
||||
return httpRequest<ProxyRuntimeResponse>("/api/proxy/runtime", {
|
||||
method: "POST",
|
||||
body: runtime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testProxyClearance(targetUrl?: string) {
|
||||
return httpRequest<{ result: ClearanceTestResult }>("/api/proxy/clearance/test", {
|
||||
method: "POST",
|
||||
body: { target_url: targetUrl ?? "https://chatgpt.com" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { login } from "@/lib/api";
|
||||
import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
||||
|
||||
export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
|
||||
const storedSession = await getStoredAuthSession();
|
||||
if (!storedSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await login(storedSession.key);
|
||||
const nextSession: StoredAuthSession = {
|
||||
key: storedSession.key,
|
||||
role: data.role,
|
||||
subjectId: data.subject_id,
|
||||
name: data.name,
|
||||
};
|
||||
await setStoredAuthSession(nextSession);
|
||||
return nextSession;
|
||||
} catch {
|
||||
await clearStoredAuthSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ReleaseInfo = {
|
||||
version: string;
|
||||
date: string;
|
||||
items: { type: string; content: string }[];
|
||||
};
|
||||
|
||||
export function parseChangelog(content: string): ReleaseInfo[] {
|
||||
return content
|
||||
.split(/^## /m)
|
||||
.slice(1)
|
||||
.map((block) => {
|
||||
const [title = "", ...lines] = block.trim().split("\n");
|
||||
const [, version = title.trim(), date = ""] =
|
||||
title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
|
||||
return {
|
||||
version: version.trim(),
|
||||
date: date.trim(),
|
||||
items: lines
|
||||
.map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => ({ type: match[1], content: match[2] })),
|
||||
};
|
||||
})
|
||||
.filter((release) => release.items.length);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios, {AxiosError, type AxiosRequestConfig} from "axios";
|
||||
|
||||
import webConfig from "@/constants/common-env";
|
||||
import {clearStoredAuthSession, getStoredAuthKey} from "@/store/auth";
|
||||
|
||||
type RequestConfig = AxiosRequestConfig & {
|
||||
redirectOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
type ErrorPayload = {
|
||||
detail?: string | { error?: string | { message?: string } };
|
||||
error?: string | { message?: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function errorMessageFromValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const item = value as { error?: unknown; message?: unknown };
|
||||
if (typeof item.message === "string") {
|
||||
return item.message;
|
||||
}
|
||||
return errorMessageFromValue(item.error);
|
||||
}
|
||||
|
||||
export const request = axios.create({
|
||||
baseURL: webConfig.apiUrl.replace(/\/$/, ""),
|
||||
});
|
||||
|
||||
request.interceptors.request.use(async (config) => {
|
||||
const nextConfig = {...config};
|
||||
const authKey = await getStoredAuthKey();
|
||||
const headers = {...(nextConfig.headers || {})} as Record<string, string>;
|
||||
if (authKey && !headers.Authorization) {
|
||||
headers.Authorization = `Bearer ${authKey}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
nextConfig.headers = headers;
|
||||
return nextConfig;
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ErrorPayload>) => {
|
||||
const status = error.response?.status;
|
||||
const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
|
||||
if (status === 401 && shouldRedirect && typeof window !== "undefined") {
|
||||
// Avoid redirect loop — only redirect if not already on /login
|
||||
if (!window.location.pathname.startsWith("/login")) {
|
||||
await clearStoredAuthSession();
|
||||
window.location.replace("/login");
|
||||
// Return a never-resolving promise to prevent further error handling
|
||||
// while the browser navigates away
|
||||
return new Promise(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = error.response?.data;
|
||||
const message =
|
||||
errorMessageFromValue(payload?.detail) ||
|
||||
errorMessageFromValue(payload?.error) ||
|
||||
payload?.message ||
|
||||
error.message ||
|
||||
`请求失败 (${status || 500})`;
|
||||
return Promise.reject(new Error(message));
|
||||
},
|
||||
);
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
redirectOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
export async function httpRequest<T>(path: string, options: RequestOptions = {}) {
|
||||
const {method = "GET", body, headers, redirectOnUnauthorized = true} = options;
|
||||
const config: RequestConfig = {
|
||||
url: path,
|
||||
method,
|
||||
data: body,
|
||||
headers,
|
||||
redirectOnUnauthorized,
|
||||
};
|
||||
const response = await request.request<T>(config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getValidatedAuthSession } from "@/lib/auth-session";
|
||||
import {
|
||||
getDefaultRouteForRole,
|
||||
type AuthRole,
|
||||
type StoredAuthSession,
|
||||
} from "@/store/auth";
|
||||
|
||||
type UseAuthGuardResult = {
|
||||
isCheckingAuth: boolean;
|
||||
session: StoredAuthSession | null;
|
||||
};
|
||||
|
||||
export function useAuthGuard(allowedRoles?: AuthRole[]): UseAuthGuardResult {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredAuthSession | null>(null);
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const allowedRolesKey = (allowedRoles || []).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
const roleList = allowedRolesKey ? (allowedRolesKey.split(",") as AuthRole[]) : [];
|
||||
const storedSession = await getValidatedAuthSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storedSession) {
|
||||
setSession(null);
|
||||
setIsCheckingAuth(false);
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (roleList.length > 0 && !roleList.includes(storedSession.role)) {
|
||||
setSession(storedSession);
|
||||
setIsCheckingAuth(false);
|
||||
router.replace(getDefaultRouteForRole(storedSession.role));
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(storedSession);
|
||||
setIsCheckingAuth(false);
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [allowedRolesKey, router]);
|
||||
|
||||
return { isCheckingAuth, session };
|
||||
}
|
||||
|
||||
export function useRedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
const storedSession = await getValidatedAuthSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedSession) {
|
||||
router.replace(getDefaultRouteForRole(storedSession.role));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingAuth(false);
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return { isCheckingAuth };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import localforage from "localforage";
|
||||
|
||||
export type AuthRole = "admin" | "user";
|
||||
|
||||
export type StoredAuthSession = {
|
||||
key: string;
|
||||
role: AuthRole;
|
||||
subjectId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const AUTH_KEY_STORAGE_KEY = "chatgpt2api_auth_key";
|
||||
export const AUTH_SESSION_STORAGE_KEY = "chatgpt2api_auth_session";
|
||||
|
||||
const authStorage = localforage.createInstance({
|
||||
name: "chatgpt2api",
|
||||
storeName: "auth",
|
||||
});
|
||||
|
||||
function normalizeSession(value: unknown, fallbackKey = ""): StoredAuthSession | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<StoredAuthSession>;
|
||||
const key = String(candidate.key || fallbackKey || "").trim();
|
||||
const role = candidate.role === "admin" || candidate.role === "user" ? candidate.role : null;
|
||||
if (!key || !role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
role,
|
||||
subjectId: String(candidate.subjectId || "").trim(),
|
||||
name: String(candidate.name || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultRouteForRole(role: AuthRole) {
|
||||
return role === "admin" ? "/accounts" : "/image";
|
||||
}
|
||||
|
||||
export async function getStoredAuthKey() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const value = await authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY);
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
export async function getStoredAuthSession() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [storedKey, storedSession] = await Promise.all([
|
||||
authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY),
|
||||
authStorage.getItem<StoredAuthSession>(AUTH_SESSION_STORAGE_KEY),
|
||||
]);
|
||||
|
||||
const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
|
||||
if (normalizedSession) {
|
||||
if (normalizedSession.key !== String(storedKey || "").trim()) {
|
||||
await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key);
|
||||
}
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
if (String(storedKey || "").trim()) {
|
||||
await clearStoredAuthSession();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setStoredAuthSession(session: StoredAuthSession) {
|
||||
const normalizedSession = normalizeSession(session);
|
||||
if (!normalizedSession) {
|
||||
await clearStoredAuthSession();
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key),
|
||||
authStorage.setItem(AUTH_SESSION_STORAGE_KEY, normalizedSession),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function setStoredAuthKey(authKey: string) {
|
||||
const normalizedAuthKey = String(authKey || "").trim();
|
||||
if (!normalizedAuthKey) {
|
||||
await clearStoredAuthSession();
|
||||
return;
|
||||
}
|
||||
await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedAuthKey);
|
||||
}
|
||||
|
||||
export async function clearStoredAuthSession() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
authStorage.removeItem(AUTH_KEY_STORAGE_KEY),
|
||||
authStorage.removeItem(AUTH_SESSION_STORAGE_KEY),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function clearStoredAuthKey() {
|
||||
await clearStoredAuthSession();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import localforage from "localforage";
|
||||
|
||||
export type EditableFileDraft = {
|
||||
prompt?: string;
|
||||
images?: string[];
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const editableFileStorage = localforage.createInstance({
|
||||
name: "chatgpt2api",
|
||||
storeName: "editable_file_history",
|
||||
});
|
||||
|
||||
const draftsKey = (kind: string) => `drafts:${kind}`;
|
||||
const deletedKey = (kind: string) => `deleted:${kind}`;
|
||||
|
||||
export async function listEditableFileDrafts(kind: string): Promise<Record<string, EditableFileDraft>> {
|
||||
return (await editableFileStorage.getItem<Record<string, EditableFileDraft>>(draftsKey(kind))) || {};
|
||||
}
|
||||
|
||||
export async function saveEditableFileDrafts(kind: string, drafts: Record<string, EditableFileDraft>): Promise<void> {
|
||||
await editableFileStorage.setItem(draftsKey(kind), drafts);
|
||||
}
|
||||
|
||||
export async function listDeletedEditableFileIds(kind: string): Promise<Set<string>> {
|
||||
return new Set((await editableFileStorage.getItem<string[]>(deletedKey(kind))) || []);
|
||||
}
|
||||
|
||||
export async function saveDeletedEditableFileIds(kind: string, ids: Set<string>): Promise<void> {
|
||||
await editableFileStorage.setItem(deletedKey(kind), [...ids]);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import localforage from "localforage";
|
||||
|
||||
import type { ImageModel } from "@/lib/api";
|
||||
|
||||
export type ImageConversationMode = "generate" | "edit";
|
||||
|
||||
export type StoredReferenceImage = {
|
||||
name: string;
|
||||
type: string;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export type StoredImage = {
|
||||
id: string;
|
||||
taskId?: string;
|
||||
status?: "loading" | "success" | "error";
|
||||
taskStatus?: "queued" | "running";
|
||||
progress?: string;
|
||||
b64_json?: string;
|
||||
url?: string;
|
||||
revised_prompt?: string;
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
elapsedSecs?: number;
|
||||
elapsedUpdatedAt?: number;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type ImageTurnStatus = "queued" | "generating" | "success" | "error";
|
||||
|
||||
export type ImageTurn = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
model: ImageModel;
|
||||
mode: ImageConversationMode;
|
||||
referenceImages: StoredReferenceImage[];
|
||||
count: number;
|
||||
size: string;
|
||||
ratio: string;
|
||||
tier: string;
|
||||
quality: string;
|
||||
images: StoredImage[];
|
||||
createdAt: string;
|
||||
status: ImageTurnStatus;
|
||||
error?: string;
|
||||
promptDeleted?: boolean;
|
||||
resultsDeleted?: boolean;
|
||||
};
|
||||
|
||||
export type ImageConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
turns: ImageTurn[];
|
||||
};
|
||||
|
||||
export type ImageConversationStats = {
|
||||
queued: number;
|
||||
running: number;
|
||||
};
|
||||
|
||||
const imageConversationStorage = localforage.createInstance({
|
||||
name: "chatgpt2api",
|
||||
storeName: "image_conversations",
|
||||
});
|
||||
|
||||
const IMAGE_CONVERSATIONS_KEY = "items";
|
||||
let imageConversationWriteQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function normalizeStoredImage(image: StoredImage): StoredImage {
|
||||
const normalized = {
|
||||
...image,
|
||||
taskId: typeof image.taskId === "string" && image.taskId ? image.taskId : undefined,
|
||||
taskStatus: image.taskStatus === "queued" || image.taskStatus === "running" ? image.taskStatus : undefined,
|
||||
url: typeof image.url === "string" && image.url ? image.url : undefined,
|
||||
revised_prompt: typeof image.revised_prompt === "string" ? image.revised_prompt : undefined,
|
||||
startTime: typeof image.startTime === "number" ? image.startTime : undefined,
|
||||
elapsedSecs: typeof image.elapsedSecs === "number" ? image.elapsedSecs : undefined,
|
||||
elapsedUpdatedAt: typeof image.elapsedUpdatedAt === "number" ? image.elapsedUpdatedAt : undefined,
|
||||
durationMs: typeof image.durationMs === "number" ? image.durationMs : undefined,
|
||||
};
|
||||
if (image.status === "loading" || image.status === "error" || image.status === "success") {
|
||||
return normalized;
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
status: image.b64_json || image.url ? "success" : "loading",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReferenceImage(image: StoredReferenceImage): StoredReferenceImage {
|
||||
return {
|
||||
name: image.name || "reference.png",
|
||||
type: image.type || "image/png",
|
||||
dataUrl: image.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function dataUrlMimeType(dataUrl: string) {
|
||||
const match = dataUrl.match(/^data:(.*?);base64,/);
|
||||
return match?.[1] || "image/png";
|
||||
}
|
||||
|
||||
function getLegacyReferenceImages(source: Record<string, unknown>): StoredReferenceImage[] {
|
||||
if (Array.isArray(source.referenceImages)) {
|
||||
return source.referenceImages
|
||||
.filter((image): image is StoredReferenceImage => {
|
||||
if (!image || typeof image !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = image as StoredReferenceImage;
|
||||
return typeof candidate.dataUrl === "string" && candidate.dataUrl.length > 0;
|
||||
})
|
||||
.map(normalizeReferenceImage);
|
||||
}
|
||||
|
||||
if (source.sourceImage && typeof source.sourceImage === "object") {
|
||||
const image = source.sourceImage as { dataUrl?: unknown; fileName?: unknown };
|
||||
if (typeof image.dataUrl === "string" && image.dataUrl) {
|
||||
return [
|
||||
{
|
||||
name: typeof image.fileName === "string" && image.fileName ? image.fileName : "reference.png",
|
||||
type: dataUrlMimeType(image.dataUrl),
|
||||
dataUrl: image.dataUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
|
||||
const normalizedImages = Array.isArray(turn.images) ? turn.images.map(normalizeStoredImage) : [];
|
||||
const derivedStatus: ImageTurnStatus =
|
||||
normalizedImages.some((image) => image.status === "loading")
|
||||
? "generating"
|
||||
: normalizedImages.some((image) => image.status === "error")
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return {
|
||||
id: String(turn.id || `${Date.now()}`),
|
||||
prompt: String(turn.prompt || ""),
|
||||
model: (turn.model as ImageModel) || "gpt-image-2",
|
||||
mode: turn.mode === "edit" ? "edit" : "generate",
|
||||
referenceImages: getLegacyReferenceImages(turn),
|
||||
count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
|
||||
size: typeof turn.size === "string" ? turn.size : "",
|
||||
ratio: typeof turn.ratio === "string" && turn.ratio ? turn.ratio : "1:1",
|
||||
tier: typeof turn.tier === "string" && turn.tier ? turn.tier : "1k",
|
||||
quality: typeof turn.quality === "string" && turn.quality ? turn.quality : "auto",
|
||||
images: normalizedImages,
|
||||
createdAt: String(turn.createdAt || new Date().toISOString()),
|
||||
status:
|
||||
turn.status === "queued" ||
|
||||
turn.status === "generating" ||
|
||||
turn.status === "success" ||
|
||||
turn.status === "error"
|
||||
? turn.status
|
||||
: derivedStatus,
|
||||
error: typeof turn.error === "string" ? turn.error : undefined,
|
||||
promptDeleted: turn.promptDeleted === true,
|
||||
resultsDeleted: turn.resultsDeleted === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConversation(conversation: ImageConversation & Record<string, unknown>): ImageConversation {
|
||||
const turns = Array.isArray(conversation.turns)
|
||||
? conversation.turns.map((turn) => normalizeTurn(turn as ImageTurn & Record<string, unknown>))
|
||||
: [
|
||||
normalizeTurn({
|
||||
id: String(conversation.id || `${Date.now()}`),
|
||||
prompt: String(conversation.prompt || ""),
|
||||
model: (conversation.model as ImageModel) || "gpt-image-2",
|
||||
mode: conversation.mode === "edit" ? "edit" : "generate",
|
||||
referenceImages: getLegacyReferenceImages(conversation),
|
||||
count: Number(conversation.count || 1),
|
||||
size: typeof conversation.size === "string" ? conversation.size : "",
|
||||
ratio: typeof conversation.ratio === "string" && conversation.ratio ? conversation.ratio : "1:1",
|
||||
tier: typeof conversation.tier === "string" && conversation.tier ? conversation.tier : "1k",
|
||||
quality: typeof conversation.quality === "string" && conversation.quality ? conversation.quality : "auto",
|
||||
images: Array.isArray(conversation.images) ? (conversation.images as StoredImage[]) : [],
|
||||
createdAt: String(conversation.createdAt || new Date().toISOString()),
|
||||
status:
|
||||
conversation.status === "generating" || conversation.status === "success" || conversation.status === "error"
|
||||
? conversation.status
|
||||
: "success",
|
||||
error: typeof conversation.error === "string" ? conversation.error : undefined,
|
||||
}),
|
||||
];
|
||||
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
||||
|
||||
return {
|
||||
id: String(conversation.id || `${Date.now()}`),
|
||||
title: String(conversation.title || ""),
|
||||
createdAt: String(conversation.createdAt || lastTurn?.createdAt || new Date().toISOString()),
|
||||
updatedAt: String(conversation.updatedAt || lastTurn?.createdAt || new Date().toISOString()),
|
||||
turns,
|
||||
};
|
||||
}
|
||||
|
||||
function sortImageConversations(conversations: ImageConversation[]): ImageConversation[] {
|
||||
return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
function getTimestamp(value: string) {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function pickLatestConversation(current: ImageConversation, next: ImageConversation) {
|
||||
return getTimestamp(next.updatedAt) >= getTimestamp(current.updatedAt) ? next : current;
|
||||
}
|
||||
|
||||
function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = imageConversationWriteQueue.then(operation);
|
||||
imageConversationWriteQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readStoredImageConversations(): Promise<ImageConversation[]> {
|
||||
const items =
|
||||
(await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(
|
||||
IMAGE_CONVERSATIONS_KEY,
|
||||
)) || [];
|
||||
return items.map(normalizeConversation);
|
||||
}
|
||||
|
||||
export async function listImageConversations(): Promise<ImageConversation[]> {
|
||||
return sortImageConversations(await readStoredImageConversations());
|
||||
}
|
||||
|
||||
export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> {
|
||||
await queueImageConversationWrite(async () => {
|
||||
const items = await readStoredImageConversations();
|
||||
const conversationMap = new Map(items.map((item) => [item.id, item]));
|
||||
for (const conversation of conversations.map(normalizeConversation)) {
|
||||
const current = conversationMap.get(conversation.id);
|
||||
conversationMap.set(conversation.id, current ? pickLatestConversation(current, conversation) : conversation);
|
||||
}
|
||||
await imageConversationStorage.setItem(
|
||||
IMAGE_CONVERSATIONS_KEY,
|
||||
sortImageConversations([...conversationMap.values()]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
|
||||
await queueImageConversationWrite(async () => {
|
||||
const items = await readStoredImageConversations();
|
||||
const nextConversation = normalizeConversation(conversation);
|
||||
const current = items.find((item) => item.id === nextConversation.id);
|
||||
const persistedConversation = current ? pickLatestConversation(current, nextConversation) : nextConversation;
|
||||
const nextItems = sortImageConversations([
|
||||
persistedConversation,
|
||||
...items.filter((item) => item.id !== persistedConversation.id),
|
||||
]);
|
||||
await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameImageConversation(id: string, title: string): Promise<void> {
|
||||
await queueImageConversationWrite(async () => {
|
||||
const items = await readStoredImageConversations();
|
||||
const target = items.find((item) => item.id === id);
|
||||
if (!target) return;
|
||||
const updated = { ...target, title, updatedAt: new Date().toISOString() };
|
||||
const nextItems = sortImageConversations([
|
||||
updated,
|
||||
...items.filter((item) => item.id !== id),
|
||||
]);
|
||||
await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteImageConversation(id: string): Promise<void> {
|
||||
await queueImageConversationWrite(async () => {
|
||||
const items = await readStoredImageConversations();
|
||||
await imageConversationStorage.setItem(
|
||||
IMAGE_CONVERSATIONS_KEY,
|
||||
items.filter((item) => item.id !== id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearImageConversations(): Promise<void> {
|
||||
await queueImageConversationWrite(async () => {
|
||||
await imageConversationStorage.removeItem(IMAGE_CONVERSATIONS_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageConversationStats(conversation: ImageConversation | null): ImageConversationStats {
|
||||
if (!conversation) {
|
||||
return { queued: 0, running: 0 };
|
||||
}
|
||||
|
||||
return conversation.turns.reduce(
|
||||
(acc, turn) => {
|
||||
if (turn.resultsDeleted) {
|
||||
return acc;
|
||||
}
|
||||
if (turn.status === "queued") {
|
||||
acc.queued += 1;
|
||||
} else if (turn.status === "generating") {
|
||||
acc.running += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ queued: 0, running: 0 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user