Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
+41
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
enable-pre-post-scripts=true
+1501
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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}"
}
}
+17
View File
@@ -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;
+40
View File
@@ -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
+58
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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
+224
View File
@@ -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>
);
}
+39
View File
@@ -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);
+59
View File
@@ -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

+231
View File
@@ -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;
}
+732
View File
@@ -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
+50
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+356
View File
@@ -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 />;
}
+30
View File
@@ -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">
代理优先级:账号代理 &gt; FlareSolverr 代理链路 &gt; 显式代理 &gt; 全局代理。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>
</>
);
}
+135
View File
@@ -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
+49
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+434
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+18
View File
@@ -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 }} />;
}
+14
View File
@@ -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"
/>
);
}
+263
View File
@@ -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>
)
}
+41
View File
@@ -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 };
+59
View File
@@ -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 };
+46
View File
@@ -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 };
+61
View File
@@ -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 };
+27
View File
@@ -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 };
+267
View File
@@ -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>
)
}
+132
View File
@@ -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,
};
+13
View File
@@ -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 };
+19
View File
@@ -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 };
+24
View File
@@ -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 }
+149
View File
@@ -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>
)
+38
View File
@@ -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 };
+175
View File
@@ -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,
};
+143
View File
@@ -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,
}
+29
View File
@@ -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 };
+91
View File
@@ -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 }
+269
View File
@@ -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
+18
View File
@@ -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>
);
}
+6
View File
@@ -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
+85
View File
@@ -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,
};
}
+929
View File
@@ -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" },
});
}
+26
View File
@@ -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;
}
}
+25
View File
@@ -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);
}
+93
View File
@@ -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;
}
+89
View File
@@ -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 };
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+112
View File
@@ -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();
}
+33
View File
@@ -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]);
}
+318
View File
@@ -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 },
);
}
+42
View File
@@ -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"
]
}