Initial commit: Pixel AI comic/video creation platform
- FastAPI backend with SQLModel, Alembic migrations, AgentScope agents - Next.js 15 frontend with React 19, Tailwind, Zustand, React Flow - Multi-provider AI system (DashScope, Kling, MiniMax, Volcengine, OpenAI, etc.) - All HTTP clients migrated from sync requests to async httpx - Admin-managed API keys via environment variables - SSRF vulnerability fixed in ensure_url()
This commit is contained in:
37
frontend/Dockerfile
Normal file
37
frontend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build argument for API URL
|
||||
ARG API_URL
|
||||
ENV API_URL=$API_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy built artifacts from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start command
|
||||
CMD ["node", "server.js"]
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
25
frontend/eslint.config.mjs
Normal file
25
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [".next/**", "node_modules/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/lib/api/**/*.{ts,tsx,js,jsx}"],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
88
frontend/next.config.mjs
Normal file
88
frontend/next.config.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
// 服务端 rewrites 转发目标;浏览器端 Base 由 NEXT_PUBLIC_API_URL 控制(见 src/lib/client.ts)
|
||||
const API_URL = process.env.API_URL || 'http://localhost:8000';
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
'@lobehub/icons',
|
||||
'lucide-react',
|
||||
'framer-motion',
|
||||
'@radix-ui/react-dialog',
|
||||
'@radix-ui/react-dropdown-menu',
|
||||
'@radix-ui/react-select',
|
||||
'@radix-ui/react-tabs',
|
||||
],
|
||||
// Increase proxy timeout for long-running AI operations
|
||||
proxyTimeout: 300000, // 5 minutes
|
||||
},
|
||||
// Server response timeout
|
||||
serverExternalPackages: [],
|
||||
images: {
|
||||
// 本地开发环境下关闭 Next 图片优化,避免在受限网络环境中请求 OSS 导致 _next/image 500
|
||||
unoptimized: process.env.NODE_ENV === 'development',
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.aliyuncs.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.aliyuncs.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.dicebear.com',
|
||||
}
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${API_URL}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/files/:path*',
|
||||
destination: `${API_URL}/files/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: `${API_URL}/uploads/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/config/:path*',
|
||||
destination: `${API_URL}/config/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/projects/:path*',
|
||||
destination: `${API_URL}/projects/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/generations/:path*',
|
||||
destination: `${API_URL}/generations/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/storage/:path*',
|
||||
destination: `${API_URL}/storage/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/chat/:path*',
|
||||
destination: `${API_URL}/chat/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/health',
|
||||
destination: `${API_URL}/health`,
|
||||
}
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
102
frontend/package.json
Normal file
102
frontend/package.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "eslint . --fix",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"gen:api": "curl -s http://localhost:8000/openapi.json -o openapi.json && npx openapi-typescript-codegen --input ./openapi.json --output ./src/lib/api --client axios && rm openapi.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.69",
|
||||
"@google/genai": "^1.34.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons": "^4.0.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.67",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"framer-motion": "^12.23.26",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"immer": "^11.1.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"next": "^15.5.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.2",
|
||||
"react-window": "^2.2.5",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.3.4",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/ui": "^4.0.17",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^15.1.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"openapi-typescript-codegen": "^0.30.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"overrides": {
|
||||
"@emoji-mart/react": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/playwright.config.ts
Normal file
26
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --hostname 127.0.0.1 --port 3000',
|
||||
url: 'http://127.0.0.1:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
12697
frontend/pnpm-lock.yaml
generated
Normal file
12697
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/pnpm-workspace.yaml
Normal file
1
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1 @@
|
||||
storeDir: /Users/cillin/Library/pnpm/store/v10
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
243
frontend/src/app/admin/api-keys/page.tsx
Normal file
243
frontend/src/app/admin/api-keys/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { listApiKeys, revokeApiKey, getApiKeyUsage } from '@/lib/api/admin/api-keys';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageFilter,
|
||||
PageTable,
|
||||
PageTableEmpty,
|
||||
PageTablePagination,
|
||||
RefreshButton,
|
||||
SearchButton,
|
||||
} from '@/components/admin/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Trash2, Eye } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function AdminApiKeysPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchUserId, setSearchUserId] = useState('');
|
||||
const [searchProvider, setSearchProvider] = useState('');
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [usageDialogOpen, setUsageDialogOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin', 'api-keys', page, searchUserId, searchProvider],
|
||||
queryFn: () => listApiKeys({
|
||||
page,
|
||||
pageSize: 20,
|
||||
userId: searchUserId || undefined,
|
||||
provider: searchProvider || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (keyId: string) => revokeApiKey(keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'api-keys'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleRevoke = (keyId: string) => {
|
||||
if (confirm('确定要撤销此 API Key 吗?')) {
|
||||
revokeMutation.mutate(keyId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewUsage = async (keyId: string) => {
|
||||
setSelectedKey(keyId);
|
||||
setUsageDialogOpen(true);
|
||||
};
|
||||
|
||||
const { data: usageData } = useQuery({
|
||||
queryKey: ['admin', 'api-keys', 'usage', selectedKey],
|
||||
queryFn: () => selectedKey ? getApiKeyUsage(selectedKey) : null,
|
||||
enabled: !!selectedKey && usageDialogOpen,
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'api-keys'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="密钥管理"
|
||||
description="管理所有用户的供应商密钥"
|
||||
actions={<RefreshButton onClick={() => refetch()} loading={isLoading} />}
|
||||
/>
|
||||
|
||||
<PageFilter variant="ghost">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-user">用户 ID</Label>
|
||||
<Input
|
||||
id="search-user"
|
||||
placeholder="搜索用户 ID"
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-provider">提供商</Label>
|
||||
<Input
|
||||
id="search-provider"
|
||||
placeholder="搜索提供商"
|
||||
value={searchProvider}
|
||||
onChange={(e) => setSearchProvider(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
<SearchButton onClick={handleSearch} />
|
||||
</PageFilter>
|
||||
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Key (脱敏)</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>使用次数</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<PageTableEmpty colSpan={9} message="加载中..." />
|
||||
) : data?.items.length === 0 ? (
|
||||
<PageTableEmpty colSpan={9} message="暂无数据" />
|
||||
) : (
|
||||
data?.items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-mono text-xs">{key.id.slice(0, 8)}...</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{key.username}</div>
|
||||
<div className="text-xs text-muted-foreground">{key.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{key.provider}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{key.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{key.maskedKey}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={key.isActive ? 'default' : 'secondary'}>
|
||||
{key.isActive ? '活跃' : '已禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{key.usageCount}</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt ? (
|
||||
new Date(key.lastUsedAt * 1000).toLocaleString('zh-CN')
|
||||
) : (
|
||||
<span className="text-muted-foreground">从未</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewUsage(key.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{key.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
|
||||
{data && (
|
||||
<PageTablePagination
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={usageDialogOpen} onOpenChange={setUsageDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>密钥使用记录</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
总使用次数:{usageData?.usageCount || 0}
|
||||
</div>
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务 ID</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usageData?.records.length === 0 ? (
|
||||
<PageTableEmpty colSpan={4} message="暂无使用记录" />
|
||||
) : (
|
||||
usageData?.records.map((record, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{record.taskId?.slice(0, 8) || '-'}
|
||||
</TableCell>
|
||||
<TableCell>{record.taskType || '-'}</TableCell>
|
||||
<TableCell>{record.model || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{record.createdAt ? new Date(record.createdAt).toLocaleString('zh-CN') : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
323
frontend/src/app/admin/audit-logs/page.tsx
Normal file
323
frontend/src/app/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listAuditLogs, exportAuditLogs, getAuditLog } from '@/lib/api/admin/audit-logs';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageFilter,
|
||||
PageTable,
|
||||
PageTableEmpty,
|
||||
PageTablePagination,
|
||||
ExportButton,
|
||||
SearchButton,
|
||||
} from '@/components/admin/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Eye, FileText } from 'lucide-react';
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState({
|
||||
userId: '',
|
||||
action: '',
|
||||
resourceType: '',
|
||||
resourceId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
});
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'audit-logs', page, filters],
|
||||
queryFn: () => listAuditLogs({
|
||||
page,
|
||||
pageSize: 20,
|
||||
...filters,
|
||||
}),
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const result = await exportAuditLogs(filters);
|
||||
const blob = new Blob([result.content], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = (logId: string) => {
|
||||
setSelectedLog(logId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const { data: logDetail } = useQuery({
|
||||
queryKey: ['admin', 'audit-logs', 'detail', selectedLog],
|
||||
queryFn: () => selectedLog ? getAuditLog(selectedLog) : null,
|
||||
enabled: !!selectedLog && detailDialogOpen,
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: key === 'resourceType' && value === 'all' ? '' : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'audit-logs'] });
|
||||
};
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'user', label: '用户' },
|
||||
{ value: 'project', label: '项目' },
|
||||
{ value: 'task', label: '任务' },
|
||||
{ value: 'api_key', label: '密钥' },
|
||||
{ value: 'model', label: '模型' },
|
||||
{ value: 'provider', label: 'Provider' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="审计日志"
|
||||
description="查看系统操作审计日志"
|
||||
actions={<ExportButton onClick={handleExport} />}
|
||||
/>
|
||||
|
||||
<PageFilter className="grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-user">用户 ID</Label>
|
||||
<Input
|
||||
id="filter-user"
|
||||
placeholder="用户 ID"
|
||||
value={filters.userId}
|
||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-action">操作类型</Label>
|
||||
<Input
|
||||
id="filter-action"
|
||||
placeholder="如 create, delete"
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-resource">资源类型</Label>
|
||||
<Select
|
||||
value={filters.resourceType}
|
||||
onValueChange={(value) => handleFilterChange('resourceType', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{resourceTypeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-resource-id">资源 ID</Label>
|
||||
<Input
|
||||
id="filter-resource-id"
|
||||
placeholder="资源 ID"
|
||||
value={filters.resourceId}
|
||||
onChange={(e) => handleFilterChange('resourceId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-start">开始日期</Label>
|
||||
<Input
|
||||
id="filter-start"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filter-end">结束日期</Label>
|
||||
<Input
|
||||
id="filter-end"
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 flex justify-end">
|
||||
<SearchButton onClick={handleSearch} />
|
||||
</div>
|
||||
</PageFilter>
|
||||
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>资源类型</TableHead>
|
||||
<TableHead>资源 ID</TableHead>
|
||||
<TableHead>IP 地址</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<PageTableEmpty colSpan={8} message="加载中..." />
|
||||
) : data?.items.length === 0 ? (
|
||||
<PageTableEmpty colSpan={8} message="暂无数据" />
|
||||
) : (
|
||||
data?.items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.username || log.userId || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{log.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.resourceType ? (
|
||||
<Badge variant="secondary">{log.resourceType}</Badge>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.resourceId?.slice(0, 8) || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.ipAddress || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(log.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
|
||||
{data && (
|
||||
<PageTablePagination
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
审计日志详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{logDetail && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">日志 ID</Label>
|
||||
<div className="font-mono">{logDetail.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">用户</Label>
|
||||
<div>{logDetail.username || logDetail.userId || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">操作</Label>
|
||||
<div className="font-mono">{logDetail.action}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">资源类型</Label>
|
||||
<div>{logDetail.resourceType || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">资源 ID</Label>
|
||||
<div className="font-mono">{logDetail.resourceId || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">IP 地址</Label>
|
||||
<div className="font-mono">{logDetail.ipAddress || '-'}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground">User Agent</Label>
|
||||
<div className="text-xs">{logDetail.userAgent || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">时间</Label>
|
||||
<div>{new Date(logDetail.createdAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{logDetail.details && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">详细信息</Label>
|
||||
<pre className="bg-muted p-4 rounded-md text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(logDetail.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
202
frontend/src/app/admin/dashboard/page.tsx
Normal file
202
frontend/src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/apiClient';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
} from '@/components/admin/common';
|
||||
import { StatsCards } from '@/components/admin/dashboard/StatsCards';
|
||||
import { ActivityChart } from '@/components/admin/dashboard/ActivityChart';
|
||||
import { RecentActivity } from '@/components/admin/dashboard/RecentActivity';
|
||||
|
||||
// Frontend interfaces (apiClient automatically converts snake_case to camelCase)
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalProjects: number;
|
||||
totalTasks: number;
|
||||
activeTasks: number;
|
||||
pendingTasks: number;
|
||||
failedTasks: number;
|
||||
completedTasks: number;
|
||||
}
|
||||
|
||||
interface ActivityDataPoint {
|
||||
date: string;
|
||||
users: number;
|
||||
projects: number;
|
||||
tasks: number;
|
||||
}
|
||||
|
||||
// Backend API response format (after apiClient camelCase conversion)
|
||||
interface BackendActivityItem {
|
||||
id: string;
|
||||
type: string; // "user", "project", "task"
|
||||
action: string; // "created", "status_changed"
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
details?: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Frontend display format
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'user_created' | 'project_created' | 'task_completed' | 'generation_started' | 'system' | 'error';
|
||||
title: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
stats: DashboardStats;
|
||||
activityData: ActivityDataPoint[];
|
||||
recentActivities: ActivityItem[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code?: string;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
const response = await api.get<ApiResponse<DashboardStats>>('/api/v1/admin/dashboard/stats');
|
||||
const data = response.data?.data ?? response.data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid stats response');
|
||||
}
|
||||
return data as DashboardStats;
|
||||
}
|
||||
|
||||
async function fetchDashboardActivity(): Promise<Pick<DashboardData, 'activityData' | 'recentActivities'>> {
|
||||
const response = await api.get<ApiResponse<Pick<DashboardData, 'activityData' | 'recentActivities'>>>('/api/v1/admin/dashboard/activity');
|
||||
const data = response.data?.data ?? response.data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid activity response');
|
||||
}
|
||||
|
||||
// Transform backend activity items to frontend format
|
||||
const backendItems = ((data as any).items || []) as BackendActivityItem[];
|
||||
const transformedActivities: ActivityItem[] = backendItems.map((item: BackendActivityItem) => {
|
||||
// Map backend type+action to frontend type
|
||||
let frontendType: ActivityItem['type'] = 'system';
|
||||
let title = '';
|
||||
let description = '';
|
||||
|
||||
if (item.type === 'user' && item.action === 'created') {
|
||||
frontendType = 'user_created';
|
||||
title = '新用户注册';
|
||||
description = item.userName || item.details?.email || '未知用户';
|
||||
} else if (item.type === 'project' && item.action === 'created') {
|
||||
frontendType = 'project_created';
|
||||
title = '新项目创建';
|
||||
description = item.details?.name || '未知项目';
|
||||
} else if (item.type === 'task') {
|
||||
if (item.details?.status === 'completed') {
|
||||
frontendType = 'task_completed';
|
||||
title = '任务完成';
|
||||
description = `类型:${item.details?.type || '未知'}`;
|
||||
} else {
|
||||
frontendType = 'generation_started';
|
||||
title = '生成任务开始';
|
||||
description = `类型:${item.details?.type || '未知'}, 状态:${item.details?.status || 'pending'}`;
|
||||
}
|
||||
} else if (item.type === 'system') {
|
||||
frontendType = 'system';
|
||||
title = '系统事件';
|
||||
description = item.details?.message || '';
|
||||
} else if (item.type === 'error') {
|
||||
frontendType = 'error';
|
||||
title = '错误事件';
|
||||
description = item.details?.error || '';
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: frontendType,
|
||||
title,
|
||||
description,
|
||||
timestamp: item.createdAt,
|
||||
user: item.userId ? {
|
||||
id: item.userId,
|
||||
username: item.userName || '未知用户',
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Generate activity data for chart from activity items
|
||||
// Group activities by date and count users, projects, tasks
|
||||
const activityDataMap = new Map<string, { date: string; users: number; projects: number; tasks: number }>();
|
||||
|
||||
for (const item of backendItems) {
|
||||
const date = item.createdAt.split('T')[0]; // Get YYYY-MM-DD
|
||||
const existing = activityDataMap.get(date) || { date, users: 0, projects: 0, tasks: 0 };
|
||||
|
||||
if (item.type === 'user') {
|
||||
existing.users += 1;
|
||||
} else if (item.type === 'project') {
|
||||
existing.projects += 1;
|
||||
} else if (item.type === 'task') {
|
||||
existing.tasks += 1;
|
||||
}
|
||||
|
||||
activityDataMap.set(date, existing);
|
||||
}
|
||||
|
||||
// Convert map to array and sort by date
|
||||
const activityData = Array.from(activityDataMap.values()).sort((a, b) =>
|
||||
a.date.localeCompare(b.date)
|
||||
);
|
||||
|
||||
return {
|
||||
activityData,
|
||||
recentActivities: transformedActivities,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { data: stats, isLoading: isStatsLoading } = useQuery({
|
||||
queryKey: ['admin', 'dashboard', 'stats'],
|
||||
queryFn: fetchDashboardStats,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
const { data: activity, isLoading: isActivityLoading } = useQuery({
|
||||
queryKey: ['admin', 'dashboard', 'activity'],
|
||||
queryFn: fetchDashboardActivity,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="平台概览"
|
||||
description="查看系统概览和最近活动"
|
||||
/>
|
||||
|
||||
<StatsCards stats={stats} isLoading={isStatsLoading} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<ActivityChart
|
||||
data={activity?.activityData}
|
||||
isLoading={isActivityLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<RecentActivity
|
||||
activities={activity?.recentActivities}
|
||||
isLoading={isActivityLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
16
frontend/src/app/admin/layout.tsx
Normal file
16
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { AdminLayout } from '@/components/admin/layout/AdminLayout';
|
||||
import { AdminRouteGuard } from '@/components/admin/AdminRouteGuard';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminRootLayout({ children }: AdminLayoutProps) {
|
||||
return (
|
||||
<AdminRouteGuard>
|
||||
<AdminLayout>{children}</AdminLayout>
|
||||
</AdminRouteGuard>
|
||||
);
|
||||
}
|
||||
359
frontend/src/app/admin/models/page.tsx
Normal file
359
frontend/src/app/admin/models/page.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { listModels, toggleModel, setDefaultModel, testModel, listProviders, toggleProvider, deleteProvider } from '@/lib/api/admin/models';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageFilter,
|
||||
PageTable,
|
||||
PageTableEmpty,
|
||||
} from '@/components/admin/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Zap,
|
||||
Star,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminModelsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'models' | 'providers'>('models');
|
||||
const [selectedModelType, setSelectedModelType] = useState<string>('all');
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Models
|
||||
const { data: modelsData, isLoading: modelsLoading } = useQuery({
|
||||
queryKey: ['admin', 'models'],
|
||||
queryFn: listModels,
|
||||
});
|
||||
|
||||
// Providers
|
||||
const { data: providersData } = useQuery({
|
||||
queryKey: ['admin', 'providers'],
|
||||
queryFn: listProviders,
|
||||
});
|
||||
|
||||
// Toggle model mutation
|
||||
const toggleModelMutation = useMutation({
|
||||
mutationFn: ({ modelId, enabled }: { modelId: string; enabled: boolean }) =>
|
||||
toggleModel(modelId, enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'models'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Set default model mutation
|
||||
const setDefaultModelMutation = useMutation({
|
||||
mutationFn: (modelId: string) => setDefaultModel(modelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'models'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Test model mutation
|
||||
const testModelMutation = useMutation({
|
||||
mutationFn: (modelId: string) => testModel(modelId),
|
||||
onSuccess: () => {
|
||||
setTestDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle provider mutation
|
||||
const toggleProviderMutation = useMutation({
|
||||
mutationFn: ({ providerId, enabled }: { providerId: string; enabled: boolean }) =>
|
||||
toggleProvider(providerId, enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete provider mutation
|
||||
const deleteProviderMutation = useMutation({
|
||||
mutationFn: (providerId: string) => deleteProvider(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleTestModel = (modelId: string) => {
|
||||
setSelectedModelId(modelId);
|
||||
setTestDialogOpen(true);
|
||||
};
|
||||
|
||||
const runTest = () => {
|
||||
if (selectedModelId) {
|
||||
testModelMutation.mutate(selectedModelId);
|
||||
}
|
||||
};
|
||||
|
||||
// Flatten models for display
|
||||
const allModels = Object.entries(modelsData || {}).flatMap(([type, models]: [string, any]) =>
|
||||
Object.values(models).map((m: any) => ({ ...m, modelType: type }))
|
||||
);
|
||||
|
||||
const filteredModels = selectedModelType === 'all'
|
||||
? allModels
|
||||
: allModels.filter((m: any) => m.modelType === selectedModelType);
|
||||
|
||||
const modelTypeOptions = [
|
||||
{ value: 'all', label: '全部类型' },
|
||||
{ value: 'image', label: '图片生成' },
|
||||
{ value: 'video', label: '视频生成' },
|
||||
{ value: 'audio', label: '音频生成' },
|
||||
{ value: 'lyrics', label: '歌词生成' },
|
||||
{ value: 'music', label: '音乐生成' },
|
||||
{ value: 'llm', label: 'LLM' },
|
||||
];
|
||||
|
||||
const formatModelId = (id: string) => {
|
||||
const parts = id.split('/');
|
||||
return parts.length > 1 ? parts[1] : id;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="模型配置"
|
||||
description="管理 AI 模型和 Provider 配置"
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="models">模型管理</TabsTrigger>
|
||||
<TabsTrigger value="providers">Provider 管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Models Tab */}
|
||||
<TabsContent value="models" className="space-y-4">
|
||||
<PageFilter variant="ghost">
|
||||
<div className="space-y-2">
|
||||
<Label>模型类型</Label>
|
||||
<Select value={selectedModelType} onValueChange={setSelectedModelType}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelTypeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PageFilter>
|
||||
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>默认</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modelsLoading ? (
|
||||
<PageTableEmpty colSpan={7} message="加载中..." />
|
||||
) : filteredModels.length === 0 ? (
|
||||
<PageTableEmpty colSpan={7} message="暂无数据" />
|
||||
) : (
|
||||
filteredModels.map((model: any) => (
|
||||
<TableRow key={model.id}>
|
||||
<TableCell className="font-mono text-xs max-w-xs truncate">
|
||||
{formatModelId(model.id)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{model.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{model.provider}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{model.modelType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={model.enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleModelMutation.mutate({
|
||||
modelId: model.id,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{model.isDefault && (
|
||||
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestModel(model.id)}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDefaultModelMutation.mutate(model.id)
|
||||
}
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
</TabsContent>
|
||||
|
||||
{/* Providers Tab */}
|
||||
<TabsContent value="providers" className="space-y-4">
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>模型数量</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providersData?.providers.map((provider: any) => (
|
||||
<TableRow key={provider.id}>
|
||||
<TableCell className="font-medium">{provider.id}</TableCell>
|
||||
<TableCell>{provider.name || provider.id}</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={provider.enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleProviderMutation.mutate({
|
||||
providerId: provider.id,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{provider.models?.length || 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
deleteProviderMutation.mutate(provider.id)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Test Dialog */}
|
||||
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
测试模型连接
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">
|
||||
<Label>模型 ID:</Label>
|
||||
<div className="font-mono text-xs mt-1">{selectedModelId}</div>
|
||||
</div>
|
||||
{testModelMutation.data && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{testModelMutation.data.success ? (
|
||||
<Zap className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Zap className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{testModelMutation.data.success ? '测试成功' : '测试失败'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<Label>状态:</Label>
|
||||
<div>{testModelMutation.data.status}</div>
|
||||
</div>
|
||||
{testModelMutation.data.latencyMs && (
|
||||
<div className="text-sm">
|
||||
<Label>延迟:</Label>
|
||||
<div>{testModelMutation.data.latencyMs.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
{(testModelMutation.data as any).error && (
|
||||
<div className="text-sm">
|
||||
<Label>错误:</Label>
|
||||
<div className="text-red-500">{(testModelMutation.data as any).error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={runTest}
|
||||
disabled={testModelMutation.isPending}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${testModelMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{testModelMutation.isPending ? '测试中...' : '运行测试'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/admin/page.tsx
Normal file
5
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect('/admin/dashboard');
|
||||
}
|
||||
254
frontend/src/app/admin/projects/[id]/page.tsx
Normal file
254
frontend/src/app/admin/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAdminProject } from "@/lib/hooks/admin/useAdminProjects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
Calendar,
|
||||
User,
|
||||
FileText,
|
||||
Image,
|
||||
Film,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { useDeleteAdminProject } from "@/lib/hooks/admin/useAdminProjects";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminProjectDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.id as string;
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const { data: project, isLoading } = useAdminProject(projectId);
|
||||
const deleteMutation = useDeleteAdminProject();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!project) return;
|
||||
confirm({
|
||||
title: "确认删除项目?",
|
||||
description: `确定要删除项目 "${project.name}" 吗?此操作无法撤销。`,
|
||||
variant: "destructive",
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(projectId, {
|
||||
onSuccess: () => {
|
||||
router.push("/admin/projects");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{ variant: "default" | "secondary" | "destructive" | "outline"; label: string }
|
||||
> = {
|
||||
active: { variant: "default", label: "进行中" },
|
||||
archived: { variant: "secondary", label: "已归档" },
|
||||
deleted: { variant: "destructive", label: "已删除" },
|
||||
};
|
||||
const config = variants[status] || { variant: "outline", label: status };
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="text-center py-20">
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">
|
||||
项目不存在或已被删除
|
||||
</h2>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">返回项目列表</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<FolderOpen className="h-6 w-6" />
|
||||
{project.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">项目详情与管理</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/project/${project.id}`}>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
打开项目
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
项目状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(project.status)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{project.type === "video" ? "视频项目" : "漫画项目"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
项目进度
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Progress value={project.progress || 0} className="h-2" />
|
||||
<span className="text-sm font-medium">{project.progress || 0}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
所有者
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{project.ownerName || "未知用户"}</p>
|
||||
{project.ownerEmail && (
|
||||
<p className="text-xs text-muted-foreground">{project.ownerEmail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
创建时间
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(project.createdAt).toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
更新于 {new Date(project.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
剧集
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{project.episodesCount || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">项目中的剧集数量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<Image className="h-5 w-5" />
|
||||
资源
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{project.assetsCount || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">项目中的资源数量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<Film className="h-5 w-5" />
|
||||
分镜
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{project.storyboardsCount || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">项目中的分镜数量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>项目描述</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{project.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
frontend/src/app/admin/projects/page.tsx
Normal file
134
frontend/src/app/admin/projects/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ProjectTable } from "@/components/admin/projects/ProjectTable";
|
||||
import { ProjectGrid } from "@/components/admin/projects/ProjectGrid";
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageCard,
|
||||
} from "@/components/admin/common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
LayoutGrid,
|
||||
List,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { AdminProjectFilters } from "@/lib/hooks/admin/useAdminProjects";
|
||||
|
||||
export default function AdminProjectsPage() {
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||
const [filters, setFilters] = useState<AdminProjectFilters>({});
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters((prev) => ({ ...prev, search: searchInput }));
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: status === "all" ? undefined : status,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="项目管理"
|
||||
description="管理系统中的所有项目"
|
||||
actions={
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href="/admin/dashboard">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="all" className="space-y-4" onValueChange={handleStatusChange}>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="active">进行中</TabsTrigger>
|
||||
<TabsTrigger value="archived">已归档</TabsTrigger>
|
||||
<TabsTrigger value="deleted">已删除</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="搜索项目..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="w-64"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex bg-muted rounded-lg p-1 gap-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("list")}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
{viewMode === "grid" ? (
|
||||
<ProjectGrid filters={filters} />
|
||||
) : (
|
||||
<ProjectTable filters={filters} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="active" className="space-y-4">
|
||||
{viewMode === "grid" ? (
|
||||
<ProjectGrid filters={filters} />
|
||||
) : (
|
||||
<ProjectTable filters={filters} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="archived" className="space-y-4">
|
||||
{viewMode === "grid" ? (
|
||||
<ProjectGrid filters={filters} />
|
||||
) : (
|
||||
<ProjectTable filters={filters} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deleted" className="space-y-4">
|
||||
{viewMode === "grid" ? (
|
||||
<ProjectGrid filters={filters} />
|
||||
) : (
|
||||
<ProjectTable filters={filters} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
336
frontend/src/app/admin/settings/page.tsx
Normal file
336
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageCard,
|
||||
} from "@/components/admin/common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Save,
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
Users,
|
||||
Mail,
|
||||
Database,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAdminSettings,
|
||||
useUpdateAdminSettings,
|
||||
SystemSettings,
|
||||
} from "@/lib/hooks/admin/useAdminSettings";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { data: settings, isLoading } = useAdminSettings();
|
||||
const updateMutation = useUpdateAdminSettings();
|
||||
const [formData, setFormData] = useState<Partial<SystemSettings>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData(settings);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof SystemSettings, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="系统设置"
|
||||
description="管理系统的全局配置"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateMutation.isPending ? "保存中..." : "保存设置"}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href="/admin/dashboard">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-fit">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
基本设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
用户设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="email" className="gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
邮件设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
系统维护
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<PageCard
|
||||
title="基本设置"
|
||||
icon={<Settings className="h-5 w-5" />}
|
||||
description="配置系统的基本信息"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site_name">站点名称</Label>
|
||||
<Input
|
||||
id="site_name"
|
||||
value={formData.site_name || ""}
|
||||
onChange={(e) => handleChange("site_name", e.target.value)}
|
||||
placeholder="Pixel Studio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site_description">站点描述</Label>
|
||||
<Textarea
|
||||
id="site_description"
|
||||
value={formData.site_description || ""}
|
||||
onChange={(e) =>
|
||||
handleChange("site_description", e.target.value)
|
||||
}
|
||||
placeholder="AI Comic and Video Creation Platform"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<PageCard
|
||||
title="用户设置"
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
description="配置用户注册和权限相关设置"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>允许新用户注册</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
关闭后新用户将无法注册账号
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allow_registration ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("allow_registration", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>需要邮箱验证</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
新用户注册后需要验证邮箱才能使用
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.require_email_verification ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("require_email_verification", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_projects">
|
||||
每用户最大项目数 (0表示无限制)
|
||||
</Label>
|
||||
<Input
|
||||
id="max_projects"
|
||||
type="number"
|
||||
value={formData.max_projects_per_user || 0}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"max_projects_per_user",
|
||||
parseInt(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_storage">
|
||||
每用户最大存储空间 (MB) (0表示无限制)
|
||||
</Label>
|
||||
<Input
|
||||
id="max_storage"
|
||||
type="number"
|
||||
value={formData.max_storage_per_user_mb || 0}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"max_storage_per_user_mb",
|
||||
parseInt(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="email" className="space-y-6">
|
||||
<PageCard
|
||||
title="邮件设置"
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
description="配置邮件通知相关设置"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用邮件通知</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
开启后系统会发送邮件通知给用户
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.email_notifications_enabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("email_notifications_enabled", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" className="space-y-6">
|
||||
<PageCard
|
||||
title="系统维护"
|
||||
icon={<Shield className="h-5 w-5" />}
|
||||
description="配置系统维护和清理相关设置"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>维护模式</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
开启后只有管理员可以访问系统
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.maintenance_mode ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("maintenance_mode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.maintenance_mode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance_message">维护消息</Label>
|
||||
<Textarea
|
||||
id="maintenance_message"
|
||||
value={formData.maintenance_message || ""}
|
||||
onChange={(e) =>
|
||||
handleChange("maintenance_message", e.target.value)
|
||||
}
|
||||
placeholder="系统正在维护中,请稍后再试..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generation_timeout">
|
||||
默认生成超时时间 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="generation_timeout"
|
||||
type="number"
|
||||
value={formData.default_generation_timeout || 300}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"default_generation_timeout",
|
||||
parseInt(e.target.value) || 300
|
||||
)
|
||||
}
|
||||
min={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>自动清理</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
自动清理已删除的项目和临时文件
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.auto_cleanup_enabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("auto_cleanup_enabled", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.auto_cleanup_enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cleanup_days">
|
||||
清理已删除项目的天数 (删除后多少天清理)
|
||||
</Label>
|
||||
<Input
|
||||
id="cleanup_days"
|
||||
type="number"
|
||||
value={formData.auto_cleanup_days || 30}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"auto_cleanup_days",
|
||||
parseInt(e.target.value) || 30
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
259
frontend/src/app/admin/storage/page.tsx
Normal file
259
frontend/src/app/admin/storage/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getStorageStats, listStorageFiles, getStorageUserRanking, cleanupOrphanFiles } from '@/lib/api/admin/storage';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageStats,
|
||||
PageStatCard,
|
||||
PageCard,
|
||||
PageFilter,
|
||||
PageTable,
|
||||
PageTableEmpty,
|
||||
PageTablePagination,
|
||||
SearchButton,
|
||||
} from '@/components/admin/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { HardDrive, FileBox, Users, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function AdminStoragePage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'ranking' | 'files'>('overview');
|
||||
const [searchUserId, setSearchUserId] = useState('');
|
||||
const [searchProjectId, setSearchProjectId] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['admin', 'storage', 'stats'],
|
||||
queryFn: getStorageStats,
|
||||
});
|
||||
|
||||
const { data: fileList, isLoading: filesLoading } = useQuery({
|
||||
queryKey: ['admin', 'storage', 'files', page, searchUserId, searchProjectId],
|
||||
queryFn: () => listStorageFiles({
|
||||
page,
|
||||
pageSize: 20,
|
||||
userId: searchUserId || undefined,
|
||||
projectId: searchProjectId || undefined,
|
||||
}),
|
||||
enabled: activeTab === 'files',
|
||||
});
|
||||
|
||||
const { data: ranking, isLoading: rankingLoading } = useQuery({
|
||||
queryKey: ['admin', 'storage', 'ranking'],
|
||||
queryFn: () => getStorageUserRanking({ page: 1, pageSize: 10 }),
|
||||
enabled: activeTab === 'ranking',
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (dryRun: boolean) => cleanupOrphanFiles({ dryRun }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storage'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCleanup = () => {
|
||||
if (confirm('确定要清理孤立文件吗?此操作不可恢复。')) {
|
||||
cleanupMutation.mutate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="存储管理"
|
||||
description="管理系统存储资源和文件"
|
||||
/>
|
||||
|
||||
<PageStats columns={4}>
|
||||
<PageStatCard
|
||||
title="总容量"
|
||||
value={formatBytes(stats?.totalCapacity || 0)}
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
loading={statsLoading}
|
||||
/>
|
||||
<PageStatCard
|
||||
title="已用空间"
|
||||
value={formatBytes(stats?.usedSpace || 0)}
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
description={`${stats?.usagePercent?.toFixed(1) || 0}% 使用率`}
|
||||
loading={statsLoading}
|
||||
/>
|
||||
<PageStatCard
|
||||
title="文件数量"
|
||||
value={stats?.fileCount || 0}
|
||||
icon={<FileBox className="h-4 w-4" />}
|
||||
loading={statsLoading}
|
||||
/>
|
||||
<PageStatCard
|
||||
title="用户排行"
|
||||
value={`${ranking?.items.length || 0} 用户`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
loading={rankingLoading}
|
||||
/>
|
||||
</PageStats>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="ranking">用户排行</TabsTrigger>
|
||||
<TabsTrigger value="files">文件列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<PageCard title="存储使用趋势">
|
||||
<div className="h-64 flex items-center justify-center bg-muted/50 rounded-md">
|
||||
<p className="text-muted-foreground">图表功能待实现</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ranking" className="space-y-4">
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>排名</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>存储空间</TableHead>
|
||||
<TableHead>文件数量</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rankingLoading ? (
|
||||
<PageTableEmpty colSpan={4} message="加载中..." />
|
||||
) : ranking?.items.length === 0 ? (
|
||||
<PageTableEmpty colSpan={4} message="暂无数据" />
|
||||
) : (
|
||||
ranking?.items.map((user: { userId: string; rank: number; username: string; storageUsed: number; fileCount: number }) => (
|
||||
<TableRow key={user.userId}>
|
||||
<TableCell>
|
||||
<Badge variant={user.rank <= 3 ? 'default' : 'secondary'}>
|
||||
#{user.rank}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{user.username}</TableCell>
|
||||
<TableCell>{formatBytes(user.storageUsed)}</TableCell>
|
||||
<TableCell>{user.fileCount}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="space-y-4">
|
||||
<PageFilter variant="ghost">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-user-file">用户 ID</Label>
|
||||
<Input
|
||||
id="search-user-file"
|
||||
placeholder="搜索用户 ID"
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-project-file">项目 ID</Label>
|
||||
<Input
|
||||
id="search-project-file"
|
||||
placeholder="搜索项目 ID"
|
||||
value={searchProjectId}
|
||||
onChange={(e) => setSearchProjectId(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
<SearchButton onClick={() => setPage(1)} />
|
||||
</PageFilter>
|
||||
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>路径</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filesLoading ? (
|
||||
<PageTableEmpty colSpan={6} message="加载中..." />
|
||||
) : fileList?.items.length === 0 ? (
|
||||
<PageTableEmpty colSpan={6} message="暂无数据" />
|
||||
) : (
|
||||
fileList?.items.map((file: { id: string; path: string; size: number; username?: string; userId: string; projectName?: string; projectId?: string; createdAt: number }) => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{file.id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">
|
||||
{file.path}
|
||||
</TableCell>
|
||||
<TableCell>{formatBytes(file.size)}</TableCell>
|
||||
<TableCell>{file.username || file.userId}</TableCell>
|
||||
<TableCell>{file.projectName || file.projectId || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(file.createdAt * 1000).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
|
||||
{fileList && (
|
||||
<PageTablePagination
|
||||
page={fileList.page}
|
||||
totalPages={fileList.totalPages}
|
||||
total={fileList.total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PageCard
|
||||
title="孤立文件清理"
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
description="清理没有关联项目的孤立文件,释放存储空间。"
|
||||
>
|
||||
<Button
|
||||
onClick={handleCleanup}
|
||||
disabled={cleanupMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{cleanupMutation.isPending ? '清理中...' : '清理孤立文件'}
|
||||
</Button>
|
||||
</PageCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
389
frontend/src/app/admin/tasks/[id]/page.tsx
Normal file
389
frontend/src/app/admin/tasks/[id]/page.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/apiClient';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
User,
|
||||
FolderKanban,
|
||||
Cpu,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useRetryAdminTask, useDeleteAdminTask, adminTaskKeys } from '@/lib/hooks/admin/useAdminTasks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface TaskDetail {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code?: string;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function fetchTaskDetail(taskId: string): Promise<TaskDetail> {
|
||||
const response = await api.get<ApiResponse<TaskDetail>>(`/api/v1/admin/tasks/${taskId}`);
|
||||
const data = response.data?.data ?? response.data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid task detail response');
|
||||
}
|
||||
return data as TaskDetail;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
pending: { label: '等待中', variant: 'secondary' },
|
||||
processing: { label: '处理中', variant: 'default' },
|
||||
success: { label: '成功', variant: 'default' },
|
||||
succeeded: { label: '成功', variant: 'default' },
|
||||
completed: { label: '已完成', variant: 'default' },
|
||||
failed: { label: '失败', variant: 'destructive' },
|
||||
timeout: { label: '超时', variant: 'destructive' },
|
||||
retrying: { label: '重试中', variant: 'outline' },
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
image: '图像生成',
|
||||
video: '视频生成',
|
||||
audio: '音频生成',
|
||||
music: '音乐生成',
|
||||
script: '脚本处理',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export default function AdminTaskDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const taskId = params.id as string;
|
||||
const queryClient = useQueryClient();
|
||||
const retryMutation = useRetryAdminTask();
|
||||
const deleteMutation = useDeleteAdminTask();
|
||||
|
||||
const { data: task, isLoading, error } = useQuery({
|
||||
queryKey: adminTaskKeys.detail(taskId),
|
||||
queryFn: () => fetchTaskDetail(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: 5 * 1000,
|
||||
refetchInterval: (query) => {
|
||||
const task = query.state.data;
|
||||
return (task?.status === 'pending' || task?.status === 'processing') ? 10000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const handleRetry = () => {
|
||||
retryMutation.mutate(taskId, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminTaskKeys.detail(taskId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定要删除此任务吗?')) {
|
||||
deleteMutation.mutate(taskId, {
|
||||
onSuccess: () => {
|
||||
router.push('/admin/tasks');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins > 0) {
|
||||
return `${mins}分${secs}秒`;
|
||||
}
|
||||
return `${secs}秒`;
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !task) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/admin/tasks')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">任务详情</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">任务不存在或加载失败</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{error instanceof Error ? error.message : '无法获取任务信息'}
|
||||
</p>
|
||||
<Button onClick={() => router.push('/admin/tasks')}>
|
||||
返回任务列表
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = statusConfig[task.status] || { label: task.status, variant: 'secondary' };
|
||||
const typeLabel = typeLabels[task.type] || task.type;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/admin/tasks')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">任务详情</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
ID: {task.id.slice(0, 36)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusInfo.variant} className="text-sm">
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
{(task.status === 'failed' || task.status === 'timeout') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基本信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-blue-500/10">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">任务类型</p>
|
||||
<p className="font-medium">{typeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-purple-500/10">
|
||||
<Cpu className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">模型</p>
|
||||
<p className="font-medium">{task.model || '-'}</p>
|
||||
{task.provider && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{task.provider}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-emerald-500/10">
|
||||
<User className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">用户</p>
|
||||
{task.userName ? (
|
||||
<>
|
||||
<p className="font-medium">{task.userName}</p>
|
||||
{task.userEmail && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{task.userEmail}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="font-medium text-muted-foreground">未知用户</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-amber-500/10">
|
||||
<FolderKanban className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">项目</p>
|
||||
{task.projectId ? (
|
||||
<>
|
||||
<p className="font-medium">{task.projectName || task.projectId.slice(0, 8)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">ID: {task.projectId.slice(0, 36)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="font-medium text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-cyan-500/10">
|
||||
<Clock className="h-4 w-4 text-cyan-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">处理时长</p>
|
||||
<p className="font-medium">{formatDuration(task.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-md bg-rose-500/10">
|
||||
<RotateCcw className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">重试次数</p>
|
||||
<p className="font-medium">{task.retryCount} / {task.maxRetries}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>时间信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">创建时间</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="font-medium">{formatDateTime(task.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">开始时间</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="font-medium">{formatDateTime(task.startedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">完成时间</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === 'success' || task.status === 'succeeded' || task.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : task.status === 'failed' || task.status === 'timeout' ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
)}
|
||||
<p className="font-medium">{formatDateTime(task.completedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Info Card */}
|
||||
{task.error && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="h-5 w-5" />
|
||||
错误信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground bg-destructive/10 p-4 rounded-md">
|
||||
{task.error}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/app/admin/tasks/page.tsx
Normal file
139
frontend/src/app/admin/tasks/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { TaskTable } from '@/components/admin/tasks/TaskTable';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageCard,
|
||||
} from '@/components/admin/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { AdminTaskFilters, TaskStatus, TaskType } from '@/lib/hooks/admin/useAdminTasks';
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
const [filters, setFilters] = useState<AdminTaskFilters>({});
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: status === 'all' ? undefined : (status as TaskStatus),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: type === 'all' ? undefined : (type as TaskType),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="任务管理"
|
||||
description="管理系统中的生成任务"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href="/admin/tasks/stats">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href="/admin/dashboard">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Task List */}
|
||||
<PageCard
|
||||
title="任务列表"
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
headerActions={
|
||||
<Select onValueChange={handleTypeChange} defaultValue="all">
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="任务类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="image">图片生成</SelectItem>
|
||||
<SelectItem value="video">视频生成</SelectItem>
|
||||
<SelectItem value="audio">音频生成</SelectItem>
|
||||
<SelectItem value="script">剧本处理</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
defaultValue="all"
|
||||
className="space-y-4"
|
||||
onValueChange={handleStatusChange}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
<Activity className="h-4 w-4 mr-1" />
|
||||
全部
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pending">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
等待中
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="processing">
|
||||
<Loader2 className="h-4 w-4 mr-1" />
|
||||
处理中
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="completed">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
已完成
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed">
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
失败
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<TaskTable filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pending" className="space-y-4">
|
||||
<TaskTable filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="processing" className="space-y-4">
|
||||
<TaskTable filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="completed" className="space-y-4">
|
||||
<TaskTable filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="failed" className="space-y-4">
|
||||
<TaskTable filters={filters} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
310
frontend/src/app/admin/tasks/stats/page.tsx
Normal file
310
frontend/src/app/admin/tasks/stats/page.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api/apiClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Timer,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskStats {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
by_provider: Record<string, number>;
|
||||
avg_processing_time?: number;
|
||||
success_rate: number;
|
||||
}
|
||||
|
||||
async function fetchTaskStats(): Promise<TaskStats> {
|
||||
const response = await api.get("/api/v1/admin/tasks/stats");
|
||||
const data = response.data?.data ?? response.data;
|
||||
return data as TaskStats;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
blue: "#3b82f6",
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
red: "#ef4444",
|
||||
purple: "#a855f7",
|
||||
orange: "#f97316",
|
||||
cyan: "#06b6d4",
|
||||
pink: "#ec4899",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: COLORS.yellow,
|
||||
processing: COLORS.blue,
|
||||
success: COLORS.green,
|
||||
failed: COLORS.red,
|
||||
cancelled: COLORS.purple,
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "等待中",
|
||||
processing: "处理中",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
image: "图片生成",
|
||||
video: "视频生成",
|
||||
audio: "音频生成",
|
||||
music: "音乐生成",
|
||||
script: "剧本处理",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export default function TaskStatsPage() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["admin", "tasks", "stats"],
|
||||
queryFn: fetchTaskStats,
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-96" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="text-center py-20">
|
||||
<Activity className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">
|
||||
暂无统计数据
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare chart data
|
||||
const statusData = Object.entries(stats.by_status).map(([key, value]) => ({
|
||||
name: statusLabels[key] || key,
|
||||
value,
|
||||
color: statusColors[key] || COLORS.blue,
|
||||
}));
|
||||
|
||||
const typeData = Object.entries(stats.by_type).map(([key, value]) => ({
|
||||
name: typeLabels[key] || key,
|
||||
value,
|
||||
}));
|
||||
|
||||
const providerData = Object.entries(stats.by_provider).map(([key, value]) => ({
|
||||
name: key || "未知",
|
||||
value,
|
||||
}));
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "总任务数",
|
||||
value: stats.total,
|
||||
icon: Activity,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "成功率",
|
||||
value: `${stats.success_rate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: "平均处理时间",
|
||||
value: stats.avg_processing_time
|
||||
? `${Math.round(stats.avg_processing_time)}秒`
|
||||
: "-",
|
||||
icon: Timer,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
},
|
||||
{
|
||||
title: "进行中任务",
|
||||
value: stats.by_status.processing || 0,
|
||||
icon: Loader2,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">任务统计</h1>
|
||||
<p className="text-muted-foreground">查看任务执行情况和性能指标</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="text-3xl font-bold mt-2">{card.value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${card.bgColor}`}>
|
||||
<card.icon className={`h-6 w-6 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
任务状态分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statusData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2 mt-4">
|
||||
{statusData.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{item.name}</span>
|
||||
<span className="text-sm font-medium ml-auto">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Type Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
任务类型分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{typeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill={COLORS.blue} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Provider Stats */}
|
||||
{providerData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
提供商分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{providerData.map((item) => {
|
||||
const percentage = stats.total > 0 ? (item.value / stats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={item.name} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{item.name}</span>
|
||||
<span className="font-medium">
|
||||
{item.value} ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
449
frontend/src/app/admin/users/[id]/page.tsx
Normal file
449
frontend/src/app/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Calendar,
|
||||
Clock,
|
||||
Shield,
|
||||
Key,
|
||||
Folder,
|
||||
Activity,
|
||||
Edit,
|
||||
Power,
|
||||
PowerOff,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { UserEditDialog } from '@/components/admin/users/UserEditDialog';
|
||||
import {
|
||||
getUser,
|
||||
updateUser,
|
||||
toggleUserActive,
|
||||
deleteUser,
|
||||
type User,
|
||||
} from '@/lib/api/admin/users';
|
||||
|
||||
function UserDetailContent() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = params.id as string;
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
// Fetch user details
|
||||
const { data: user, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'users', userId],
|
||||
queryFn: () => getUser(userId),
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: Parameters<typeof updateUser>[1] }) =>
|
||||
updateUser(userId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
setIsEditDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: toggleUserActive,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
router.push('/admin/users');
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleEditSave = (
|
||||
userId: string,
|
||||
data: { isActive: boolean; roles: string[]; permissions: string[] }
|
||||
) => {
|
||||
updateMutation.mutate({ userId, data });
|
||||
};
|
||||
|
||||
const handleToggleActive = () => {
|
||||
if (user) {
|
||||
toggleActiveMutation.mutate(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (user) {
|
||||
deleteMutation.mutate(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp?: number | string) => {
|
||||
if (!timestamp) return '从未';
|
||||
const numTimestamp = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp;
|
||||
return new Date(numTimestamp * 1000).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="text-destructive mb-4">加载用户详情失败</div>
|
||||
<Button onClick={() => router.push('/admin/users')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回用户列表
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" onClick={() => router.push('/admin/users')}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<UserIcon className="w-6 h-6" />
|
||||
{user.username}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggleActive}
|
||||
disabled={toggleActiveMutation.isPending}
|
||||
>
|
||||
{toggleActiveMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : user.isActive ? (
|
||||
<>
|
||||
<PowerOff className="w-4 h-4 mr-2" />
|
||||
禁用
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
启用
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您确定要删除用户 "{user.username}" 吗?
|
||||
此操作不可撤销,用户的数据将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="projects">项目</TabsTrigger>
|
||||
<TabsTrigger value="activity">活动记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* User Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<UserIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">用户名</div>
|
||||
<div className="font-medium">{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">邮箱</div>
|
||||
<div className="font-medium">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">创建时间</div>
|
||||
<div className="font-medium">{formatDate(user.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">最后登录</div>
|
||||
<div className="font-medium">{formatDate(user.lastLogin)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles & Permissions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
角色
|
||||
</CardTitle>
|
||||
<CardDescription>用户所属的角色</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.roles && user.roles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role} variant="secondary">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">暂无角色</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
权限
|
||||
</CardTitle>
|
||||
<CardDescription>用户拥有的权限</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.permissions && user.permissions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="outline">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">暂无权限</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projects">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
项目列表
|
||||
</CardTitle>
|
||||
<CardDescription>用户创建的项目</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.projects && user.projects.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{user.projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{project.name}</div>
|
||||
{project.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
创建于 {formatDate(project.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{project.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Folder className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<div>暂无项目</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
活动记录
|
||||
</CardTitle>
|
||||
<CardDescription>用户最近的活动</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.activityHistory && user.activityHistory.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{user.activityHistory.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-4 p-4 rounded-lg border"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{activity.description}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(activity.createdAt)}
|
||||
</div>
|
||||
{activity.metadata && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{JSON.stringify(activity.metadata)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline">{activity.type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<div>暂无活动记录</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<UserEditDialog
|
||||
user={user}
|
||||
isOpen={isEditDialogOpen}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onSave={handleEditSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<UserDetailContent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
254
frontend/src/app/admin/users/page.tsx
Normal file
254
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageCard,
|
||||
PageFilter,
|
||||
RefreshButton,
|
||||
CreateButton,
|
||||
} from '@/components/admin/common';
|
||||
import { Search, Users } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { UserTable } from '@/components/admin/users/UserTable';
|
||||
import { UserEditDialog } from '@/components/admin/users/UserEditDialog';
|
||||
import { UserCreateDialog } from '@/components/admin/users/UserCreateDialog';
|
||||
import {
|
||||
listUsers,
|
||||
updateUser,
|
||||
toggleUserActive,
|
||||
deleteUser,
|
||||
createUser,
|
||||
type User,
|
||||
} from '@/lib/api/admin/users';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Filter and pagination state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Dialog state
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
// Fetch users
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin', 'users', { page, pageSize, searchQuery, statusFilter, sortBy, sortOrder }],
|
||||
queryFn: () =>
|
||||
listUsers({
|
||||
page,
|
||||
pageSize,
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: Parameters<typeof updateUser>[1] }) =>
|
||||
updateUser(userId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: toggleUserActive,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
setIsCreateDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback((value: 'all' | 'active' | 'inactive') => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleView = useCallback(
|
||||
(user: User) => {
|
||||
router.push(`/admin/users/${user.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((user: User) => {
|
||||
setEditingUser(user);
|
||||
setIsEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditSave = useCallback(
|
||||
(
|
||||
userId: string,
|
||||
data: { isActive: boolean; roles: string[]; permissions: string[] }
|
||||
) => {
|
||||
updateMutation.mutate({ userId, data });
|
||||
},
|
||||
[updateMutation]
|
||||
);
|
||||
|
||||
const handleToggleActive = useCallback(
|
||||
(user: User) => {
|
||||
toggleActiveMutation.mutate(user.id);
|
||||
},
|
||||
[toggleActiveMutation]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(user: User) => {
|
||||
deleteMutation.mutate(user.id);
|
||||
},
|
||||
[deleteMutation]
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc') => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback((data: Parameters<typeof createUser>[0]) => {
|
||||
createMutation.mutate(data);
|
||||
}, [createMutation]);
|
||||
|
||||
const users = data?.items || [];
|
||||
const total = data?.total || 0;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="管理系统用户、角色和权限"
|
||||
/>
|
||||
|
||||
<PageCard
|
||||
title="用户列表"
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
headerActions={
|
||||
<>
|
||||
<CreateButton onClick={() => setIsCreateDialogOpen(true)} label="新建用户" />
|
||||
<RefreshButton onClick={() => refetch()} loading={isLoading} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PageFilter variant="ghost">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名或邮箱..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>状态筛选</Label>
|
||||
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="筛选状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">已启用</SelectItem>
|
||||
<SelectItem value="inactive">已禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PageFilter>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg">
|
||||
加载用户列表失败: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Table */}
|
||||
<UserTable
|
||||
data={users}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
sorting={{
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange: handleSortChange,
|
||||
}}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onToggleActive={handleToggleActive}
|
||||
onDelete={handleDelete}
|
||||
isToggling={toggleActiveMutation.isPending ? toggleActiveMutation.variables : null}
|
||||
isDeleting={deleteMutation.isPending ? deleteMutation.variables : null}
|
||||
/>
|
||||
</PageCard>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<UserEditDialog
|
||||
user={editingUser}
|
||||
isOpen={isEditDialogOpen}
|
||||
onClose={() => {
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
onSave={handleEditSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<UserCreateDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onSave={handleCreate}
|
||||
isSaving={createMutation.isPending}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
134
frontend/src/app/forgot-password/page.tsx
Normal file
134
frontend/src/app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { forgotPassword } from '@/lib/api/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!email.trim()) {
|
||||
setError('请输入邮箱地址');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await forgotPassword(email);
|
||||
setSuccess(true);
|
||||
// In development, we get the token directly
|
||||
if (response.resetToken) {
|
||||
setResetToken(response.resetToken);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '发送重置邮件失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">忘记密码</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{success
|
||||
? '重置邮件已发送,请检查您的邮箱'
|
||||
: '输入您的邮箱地址,我们将发送密码重置链接'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center text-green-600">
|
||||
<CheckCircle className="h-16 w-16" />
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
我们已向 <strong>{email}</strong> 发送了密码重置链接。
|
||||
请检查您的邮箱并按照说明重置密码。
|
||||
</p>
|
||||
{resetToken && (
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-xs text-gray-500 mb-2">开发环境 - 重置令牌:</p>
|
||||
<code className="text-xs break-all">{resetToken}</code>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto text-xs"
|
||||
onClick={() => router.push(`/reset-password?token=${resetToken}`)}
|
||||
>
|
||||
点击直接重置密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入您的邮箱地址"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
发送中...
|
||||
</>
|
||||
) : (
|
||||
'发送重置链接'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
想起密码了?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/" className="text-sm text-center text-gray-500 hover:text-gray-700">
|
||||
返回首页
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
frontend/src/app/layout.tsx
Normal file
62
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "@/styles/globals.css";
|
||||
import { ModelInitializer } from "@/components/ModelInitializer";
|
||||
import QueryProvider from "@/components/providers/QueryProvider";
|
||||
import { ThemeProvider } from "@/components/providers/ThemeProvider";
|
||||
import { OfflineSyncProvider } from "@/components/providers/OfflineSyncProvider";
|
||||
import { OfflineBanner } from "@/components/common/OfflineIndicator";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ErrorBoundary } from "@/components/providers/ErrorBoundary";
|
||||
import { TaskPollingProvider } from "@/components/providers/TaskPollingProvider";
|
||||
import { GlobalSettings } from "@/components/GlobalSettings";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "../styles/fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "../styles/fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pixel Studio",
|
||||
description: "AI Comic and Video Creation Platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
forcedTheme="dark"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<TaskPollingProvider>
|
||||
<OfflineSyncProvider>
|
||||
<ErrorBoundary>
|
||||
<ModelInitializer />
|
||||
<OfflineBanner />
|
||||
{children}
|
||||
<GlobalSettings />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</OfflineSyncProvider>
|
||||
</TaskPollingProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
180
frontend/src/app/login/page.tsx
Normal file
180
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(redirect);
|
||||
}
|
||||
}, [isAuthenticated, router, redirect]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setValidationError('');
|
||||
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError('请输入用户名或邮箱');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setValidationError('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ username, password });
|
||||
router.push(redirect); // Redirect to original requested page or home
|
||||
} catch (error) {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">登录</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
输入您的用户名或邮箱和密码登录账号
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{(error || validationError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{validationError || error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名或邮箱</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名或邮箱"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
还没有账号?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-center text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<p className="text-center mt-4 text-gray-600">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
254
frontend/src/app/page.tsx
Normal file
254
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { Plus, LayoutGrid, List, Loader2, ArrowUpDown } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { handleError, showSuccess } from "@/lib/services/errorHandler";
|
||||
import CreateProject from "@/components/project/CreateProject";
|
||||
import { ProjectData } from '@/types';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectCard } from "@/components/project/ProjectCard";
|
||||
import { CreateProjectCard } from "@/components/project/CreateProjectCard";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Folder } from "lucide-react";
|
||||
import { useProjectListInfinite, useDeleteProjectMutation, projectKeys } from "@/lib/hooks/useProjects";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
function HomeContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState("my-canvas");
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const {
|
||||
projects,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useProjectListInfinite(LIMIT);
|
||||
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0,
|
||||
rootMargin: '100px',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleCreateProject = () => {
|
||||
setShowWizard(true);
|
||||
};
|
||||
|
||||
const handleWizardClose = () => {
|
||||
setShowWizard(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (e: React.MouseEvent, projectId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
confirm({
|
||||
title: '确认删除?',
|
||||
description: '确定要删除这个项目吗?此操作无法撤销。',
|
||||
variant: 'destructive',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteProjectMutation.mutateAsync(projectId);
|
||||
showSuccess('删除成功', '项目已成功删除');
|
||||
} catch (error) {
|
||||
handleError(error, 'DeleteProject', {
|
||||
customMessage: '删除项目失败,请重试'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleProjectUpdate = (_updatedProject: ProjectData) => {
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
||||
};
|
||||
|
||||
// 过滤 projects by search query
|
||||
const projectsByTab = projects.filter((project) => {
|
||||
if (activeTab === 'my-scripts') {
|
||||
return project.type === 'script';
|
||||
}
|
||||
return project.type !== 'script';
|
||||
});
|
||||
|
||||
const filteredProjects = projectsByTab.filter(p =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.description && p.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/30 flex flex-col">
|
||||
|
||||
{showWizard && <CreateProject onClose={handleWizardClose} mode={activeTab === 'my-scripts' ? 'script' : 'canvas'} />}
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 max-w-[1600px] w-full mx-auto px-4 sm:px-6 py-8">
|
||||
|
||||
{/* Top Controls: Tabs & Search */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="bg-transparent p-0 h-auto gap-6">
|
||||
<TabsTrigger
|
||||
value="my-canvas"
|
||||
className="bg-transparent p-0 pb-2 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary data-[state=active]:shadow-none text-muted-foreground hover:text-foreground transition-colors text-base font-medium"
|
||||
>
|
||||
我的画布
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="my-scripts"
|
||||
className="bg-transparent p-0 pb-2 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary data-[state=active]:shadow-none text-muted-foreground hover:text-foreground transition-colors text-base font-medium"
|
||||
>
|
||||
我的剧本
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex bg-muted/50 rounded-lg p-1 gap-1 mr-2">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground gap-1 hidden sm:flex">
|
||||
更新时间
|
||||
<ArrowUpDown size={14} />
|
||||
</Button>
|
||||
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="搜索"
|
||||
className="w-full md:w-64 bg-muted/50 border-border/50 rounded-lg"
|
||||
/>
|
||||
|
||||
<Button onClick={handleCreateProject} className="bg-blue-600 hover:bg-blue-700 text-white gap-2 rounded-lg">
|
||||
<Plus size={18} />
|
||||
{activeTab === 'my-scripts' ? '新建剧本' : '新建画布'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Grid / List */}
|
||||
<div>
|
||||
{isLoading && projects.length === 0 ? (
|
||||
<LoadingState message="加载项目中..." />
|
||||
) : projectsByTab.length === 0 && !searchQuery ? (
|
||||
// Empty State
|
||||
activeTab === 'my-scripts' ? (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="bg-muted/30 p-6 rounded-full inline-block">
|
||||
<Folder size={48} className="text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">暂无剧本</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mb-4">
|
||||
您还没有创建任何剧本。
|
||||
</p>
|
||||
<Button onClick={handleCreateProject} className="bg-blue-600 hover:bg-blue-700 text-white gap-2 rounded-lg">
|
||||
<Plus size={18} />
|
||||
新建剧本
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
<CreateProjectCard onClick={handleCreateProject} label="新建画布" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6'
|
||||
: 'flex flex-col gap-4'
|
||||
}>
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
viewMode={viewMode}
|
||||
onDelete={handleDeleteProject}
|
||||
onUpdate={handleProjectUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty Search Result */}
|
||||
{filteredProjects.length === 0 && searchQuery && (
|
||||
<EmptyState
|
||||
icon={Folder}
|
||||
title="未找到项目"
|
||||
description={`未找到包含 "${searchQuery}" 的项目`}
|
||||
className="bg-transparent border-dashed"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sentinel for Infinite Scroll */}
|
||||
<div ref={ref} className="mt-8 flex justify-center pb-8 min-h-[50px]">
|
||||
{isFetchingNextPage && projects.length > 0 && (
|
||||
<div className="flex items-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<HomeContent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
136
frontend/src/app/project/[id]/page.tsx
Normal file
136
frontend/src/app/project/[id]/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Canvas } from "@/components/canvas/Canvas";
|
||||
import { ScriptWorkspace } from "@/components/project/ScriptWorkspace";
|
||||
import { DefaultService } from "@lib/api";
|
||||
import { useProjectStore } from "@lib/store/project";
|
||||
import { useProjectWorkspace } from "@/lib/hooks/useProjects";
|
||||
import { LoadingState } from "@/components/ui/loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CanvasErrorBoundary } from "@/components/common/ErrorBoundary";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
function ProjectContent() {
|
||||
const params = useParams();
|
||||
const projectId = (params?.id as string) ?? null;
|
||||
|
||||
const setProject = useProjectStore((s) => s.setProject);
|
||||
const updateProjectMeta = useProjectStore((s) => s.updateProjectMeta);
|
||||
const { assets, updateAsset, updateStoryboard } = useProjectStore(
|
||||
useShallow((s) => ({
|
||||
assets: s.assets,
|
||||
updateAsset: s.updateAsset,
|
||||
updateStoryboard: s.updateStoryboard,
|
||||
})),
|
||||
);
|
||||
|
||||
const { data, isLoading, error, refetch } = useProjectWorkspace(projectId);
|
||||
const project = ((data as any)?.data?.data ?? (data as any)?.data) || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
updateProjectMeta({ id: projectId });
|
||||
}
|
||||
}, [projectId, updateProjectMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setProject(project);
|
||||
}
|
||||
}, [project, setProject]);
|
||||
|
||||
const handleUpdateAsset = async (id: string, updates: Record<string, unknown>) => {
|
||||
const previousAsset = assets.find((a) => a.id === id);
|
||||
updateAsset(id, updates);
|
||||
try {
|
||||
await DefaultService.updateAsset(projectId, id, updates);
|
||||
} catch (e) {
|
||||
if (previousAsset) {
|
||||
updateAsset(id, previousAsset);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStoryboard = async (id: string, updates: Record<string, unknown>) => {
|
||||
updateStoryboard(id, updates);
|
||||
try {
|
||||
await DefaultService.updateStoryboard(projectId, id, updates);
|
||||
} catch {
|
||||
// 可在此做乐观回滚
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<LoadingState message="无效的项目" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<LoadingState message="加载项目中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background px-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="rounded-full bg-destructive/10 p-3">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium">加载项目失败</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "请稍后重试"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background">
|
||||
{project?.type === 'script' ? (
|
||||
<ScriptWorkspace
|
||||
project={project}
|
||||
onProjectChange={() => {
|
||||
refetch();
|
||||
}}
|
||||
onRefetch={() => {
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex-1 w-full overflow-hidden">
|
||||
<CanvasErrorBoundary>
|
||||
<Canvas
|
||||
onUpdateAsset={handleUpdateAsset}
|
||||
onUpdateStoryboard={handleUpdateStoryboard}
|
||||
/>
|
||||
</CanvasErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectWorkspace() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<ProjectContent />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/register/page.tsx
Normal file
222
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { register, isLoading, error, clearError } = useAuthStore();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!formData.username.trim()) {
|
||||
return '请输入用户名';
|
||||
}
|
||||
if (formData.username.length < 3) {
|
||||
return '用户名至少需要3个字符';
|
||||
}
|
||||
if (!formData.email.trim()) {
|
||||
return '请输入邮箱';
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email)) {
|
||||
return '请输入有效的邮箱地址';
|
||||
}
|
||||
if (!formData.password) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
return '密码至少需要6个字符';
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setValidationError('');
|
||||
|
||||
const error = validateForm();
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
router.push('/'); // Redirect to home page after successful registration
|
||||
} catch (error) {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">注册</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
创建新账号以开始使用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{(error || validationError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{validationError || error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名(至少3个字符)"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码(至少6个字符)"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="请再次输入密码"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
'注册'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
已有账号?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-center text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
frontend/src/app/reset-password/page.tsx
Normal file
262
frontend/src/app/reset-password/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { verifyResetToken, resetPassword } from '@/lib/api/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tokenValid, setTokenValid] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
// Verify token on mount
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('重置令牌无效或已过期');
|
||||
setIsVerifying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyToken = async () => {
|
||||
try {
|
||||
const response = await verifyResetToken(token);
|
||||
if (response.valid) {
|
||||
setTokenValid(true);
|
||||
setUserEmail(response.email || '');
|
||||
} else {
|
||||
setError('重置令牌无效或已过期');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '重置令牌验证失败');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
verifyToken();
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!newPassword) {
|
||||
setError('请输入新密码');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('密码长度至少为6位');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await resetPassword(token, newPassword);
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '密码重置失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<p className="text-center mt-4 text-gray-600">正在验证重置令牌...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenValid && error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">链接已失效</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<p className="text-center mt-4 text-gray-600">
|
||||
该密码重置链接已过期或无效。
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Link href="/forgot-password">
|
||||
<Button className="w-full">重新申请重置</Button>
|
||||
</Link>
|
||||
<Link href="/login" className="text-sm text-center text-gray-500 hover:text-gray-700">
|
||||
返回登录
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
{success ? '密码重置成功' : '重置密码'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{success
|
||||
? '您的密码已成功重置'
|
||||
: userEmail
|
||||
? `为 ${userEmail} 设置新密码`
|
||||
: '设置您的新密码'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center text-green-600">
|
||||
<CheckCircle className="h-16 w-16" />
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
您的密码已成功重置。3秒后将自动跳转到登录页面。
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button className="w-full">立即登录</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
重置中...
|
||||
</>
|
||||
) : (
|
||||
'重置密码'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
{!success && (
|
||||
<>
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
想起密码了?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/" className="text-sm text-center text-gray-500 hover:text-gray-700">
|
||||
返回首页
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPasswordFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<p className="text-center mt-4 text-gray-600">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<ResetPasswordFallback />}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/GlobalSettings.tsx
Normal file
35
frontend/src/components/GlobalSettings.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
|
||||
// Lazy load SettingsModal to avoid SSR issues
|
||||
const SettingsModal = dynamic(
|
||||
() => import('./canvas/panels/management/settings/index').then(mod => ({ default: mod.SettingsModal })),
|
||||
{
|
||||
loading: () => <LoadingState message="加载设置..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Global Settings Modal
|
||||
* Rendered in root layout to make settings accessible from any page
|
||||
*/
|
||||
export function GlobalSettings() {
|
||||
const { isSettingsOpen, settingsActiveTab, closeSettings } = useUIStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSettingsOpen && (
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={closeSettings}
|
||||
defaultTab={settingsActiveTab || undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/ModelInitializer.tsx
Normal file
106
frontend/src/components/ModelInitializer.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useModelStore } from '@lib/store/modelStore';
|
||||
import { useModels, useUserApiKeys, CONFIG_KEYS } from '@lib/hooks/useConfig';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
|
||||
const log = logger.namespace('ModelInitializer');
|
||||
|
||||
export function ModelInitializer() {
|
||||
const setModels = useModelStore((state) => state.setModels);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const hydrate = useAuthStore((s) => s.hydrate);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Hydrate auth state on mount
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
// useModels auto-fetches on mount (enabled: true by default). No manual refetch needed.
|
||||
const { data, isLoading, error } = useModels();
|
||||
const { data: apiKeys } = useUserApiKeys(isAuthenticated);
|
||||
|
||||
// Get configured providers
|
||||
const configuredProviders = useMemo(() => {
|
||||
return new Set(apiKeys?.filter(k => k.isActive).map(k => k.provider) || []);
|
||||
}, [apiKeys]);
|
||||
|
||||
// Filter models based on configured API keys
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return null;
|
||||
|
||||
const result: {
|
||||
image: Record<string, any>;
|
||||
video: Record<string, any>;
|
||||
audio: Record<string, any>;
|
||||
lyrics: Record<string, any>;
|
||||
music: Record<string, any>;
|
||||
llm: Record<string, any>;
|
||||
} = {
|
||||
image: {},
|
||||
video: {},
|
||||
audio: {},
|
||||
lyrics: {},
|
||||
music: {},
|
||||
llm: {}
|
||||
};
|
||||
|
||||
for (const [type, typeModels] of Object.entries(data)) {
|
||||
if (type in result && typeof typeModels === 'object' && typeModels !== null) {
|
||||
for (const [id, model] of Object.entries(typeModels)) {
|
||||
const modelProvider = (model as any)?.provider;
|
||||
if (modelProvider && configuredProviders.has(modelProvider)) {
|
||||
(result as any)[type][id] = model;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [data, configuredProviders]);
|
||||
|
||||
// Refetch models when authentication state changes to true
|
||||
const prevAuthRef = useRef(isAuthenticated);
|
||||
useEffect(() => {
|
||||
// Only refetch when auth state transitions from false→true (not on initial mount)
|
||||
if (isAuthenticated && !prevAuthRef.current) {
|
||||
log.log('User authenticated, refreshing models...');
|
||||
queryClient.invalidateQueries({ queryKey: CONFIG_KEYS.models() });
|
||||
}
|
||||
prevAuthRef.current = isAuthenticated;
|
||||
}, [isAuthenticated, queryClient]);
|
||||
|
||||
// Sync React Query data → zustand store (runs only when `filteredData` reference changes)
|
||||
const prevDataRef = useRef(filteredData);
|
||||
useEffect(() => {
|
||||
if (!filteredData || filteredData === prevDataRef.current) return;
|
||||
prevDataRef.current = filteredData;
|
||||
|
||||
if (filteredData.image || filteredData.video || filteredData.llm) {
|
||||
setModels(filteredData);
|
||||
|
||||
const imageCount = Object.keys(filteredData.image || {}).length;
|
||||
const videoCount = Object.keys(filteredData.video || {}).length;
|
||||
const llmCount = Object.keys(filteredData.llm || {}).length;
|
||||
const audioCount = Object.keys(filteredData.audio || {}).length;
|
||||
const lyricsCount = Object.keys(filteredData.lyrics || {}).length;
|
||||
const musicCount = Object.keys(filteredData.music || {}).length;
|
||||
|
||||
log.log('Models loaded (filtered by API keys):', {
|
||||
total: imageCount + videoCount + llmCount + audioCount + lyricsCount + musicCount,
|
||||
image: imageCount, video: videoCount, llm: llmCount,
|
||||
audio: audioCount, lyrics: lyricsCount, music: musicCount,
|
||||
});
|
||||
}
|
||||
}, [filteredData, setModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
log.error('Failed to load models:', error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return null;
|
||||
}
|
||||
61
frontend/src/components/admin/AdminRouteGuard.tsx
Normal file
61
frontend/src/components/admin/AdminRouteGuard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { canAccessAdmin } from '@/lib/auth/authorization';
|
||||
|
||||
interface AdminRouteGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Route Guard Component
|
||||
*
|
||||
* Protects admin routes by checking if the user is authenticated
|
||||
* and has admin privileges (is_superuser or has 'admin' permission).
|
||||
*/
|
||||
export function AdminRouteGuard({ children }: AdminRouteGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { status, isLoading, hasHydrated, user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect after hydration is complete
|
||||
if (hasHydrated && !isLoading) {
|
||||
if (status === 'anonymous') {
|
||||
// Redirect to login with return URL
|
||||
const loginUrl = `/login?redirect=${encodeURIComponent(pathname)}`;
|
||||
router.push(loginUrl);
|
||||
} else if (status === 'authenticated' && !canAccessAdmin(user)) {
|
||||
// User is authenticated but not an admin - redirect to home
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
}, [status, isLoading, hasHydrated, router, pathname, user]);
|
||||
|
||||
// Show loading state while hydrating or checking authentication
|
||||
if (!hasHydrated || isLoading || status === 'idle') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only render children if authenticated and admin
|
||||
if (status !== 'authenticated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const isAdmin = canAccessAdmin(user);
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default AdminRouteGuard;
|
||||
141
frontend/src/components/admin/common/DataTable.tsx
Normal file
141
frontend/src/components/admin/common/DataTable.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/utils";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
width?: string;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
keyExtractor: (item: T) => string;
|
||||
isLoading?: boolean;
|
||||
pagination?: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
isLoading = false,
|
||||
pagination,
|
||||
emptyMessage = "暂无数据",
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const totalPages = pagination
|
||||
? Math.ceil(pagination.total / pagination.pageSize)
|
||||
: 1;
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
{columns.map((col, j) => (
|
||||
<TableCell key={`${col.key}-${j}`}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
style={{ width: column.width }}
|
||||
className="font-semibold"
|
||||
>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
renderSkeleton()
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<TableRow key={keyExtractor(item)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={`${keyExtractor(item)}-${column.key}`}>
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: (item as Record<string, unknown>)[column.key] as React.ReactNode}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
共 {pagination.total} 条记录,第 {pagination.page} / {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
||||
disabled={pagination.page <= 1 || isLoading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
||||
disabled={pagination.page >= totalPages || isLoading}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/admin/common/PageActions.tsx
Normal file
96
frontend/src/components/admin/common/PageActions.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, ButtonProps } from '@/components/ui/button';
|
||||
import { RefreshCw, Plus, Search, Download } from 'lucide-react';
|
||||
|
||||
interface PageActionsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageActions({ children, className }: PageActionsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RefreshButtonProps extends Omit<ButtonProps, 'onClick'> {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function RefreshButton({ onClick, loading, className, ...props }: RefreshButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateButtonProps extends Omit<ButtonProps, 'onClick'> {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function CreateButton({ onClick, label = '新建', className, ...props }: CreateButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className={cn('gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchButtonProps extends Omit<ButtonProps, 'onClick'> {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function SearchButton({ onClick, label = '搜索', className, ...props }: SearchButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className={cn('gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportButtonProps extends Omit<ButtonProps, 'onClick'> {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ExportButton({ onClick, label = '导出 CSV', className, ...props }: ExportButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
className={cn('gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageActions;
|
||||
67
frontend/src/components/admin/common/PageCard.tsx
Normal file
67
frontend/src/components/admin/common/PageCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface PageCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
noHeaderPadding?: boolean;
|
||||
}
|
||||
|
||||
export function PageCard({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
headerActions,
|
||||
noHeaderPadding = false,
|
||||
}: PageCardProps) {
|
||||
const hasHeader = title || description || icon || headerActions;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{hasHeader && (
|
||||
<CardHeader className={cn(
|
||||
'flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4',
|
||||
noHeaderPadding && 'pb-0'
|
||||
)}>
|
||||
<div>
|
||||
{title && (
|
||||
<CardTitle className={cn(
|
||||
'flex items-center gap-2',
|
||||
description ? 'text-lg font-medium' : 'text-xl font-semibold'
|
||||
)}>
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
{description && (
|
||||
<CardDescription className="mt-1">{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{headerActions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageCard;
|
||||
43
frontend/src/components/admin/common/PageContainer.tsx
Normal file
43
frontend/src/components/admin/common/PageContainer.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
spacing?: 'default' | 'compact' | 'loose';
|
||||
}
|
||||
|
||||
export function PageContainer({
|
||||
children,
|
||||
className,
|
||||
spacing = 'default',
|
||||
}: PageContainerProps) {
|
||||
const spacingClasses = {
|
||||
compact: 'space-y-4',
|
||||
default: 'space-y-6',
|
||||
loose: 'space-y-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('container mx-auto p-6', spacingClasses[spacing], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageSection({ children, className }: PageSectionProps) {
|
||||
return (
|
||||
<section className={cn('space-y-4', className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageContainer;
|
||||
71
frontend/src/components/admin/common/PageFilter.tsx
Normal file
71
frontend/src/components/admin/common/PageFilter.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface PageFilterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'bordered' | 'ghost';
|
||||
}
|
||||
|
||||
export function PageFilter({
|
||||
children,
|
||||
className,
|
||||
variant = 'default',
|
||||
}: PageFilterProps) {
|
||||
const variants = {
|
||||
default: 'grid gap-4 p-4 border rounded-lg bg-card',
|
||||
bordered: 'grid gap-4 p-4 border rounded-lg',
|
||||
ghost: 'flex flex-wrap gap-4 items-end',
|
||||
};
|
||||
|
||||
if (variant === 'ghost') {
|
||||
return (
|
||||
<div className={cn(variants.ghost, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn(variant === 'bordered' ? 'bg-transparent' : '', className)}>
|
||||
<CardContent className={cn(variants[variant], 'p-4')}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FilterGroup({ children, className }: FilterGroupProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterLabelProps {
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FilterLabel({ children, htmlFor, className }: FilterLabelProps) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageFilter;
|
||||
37
frontend/src/components/admin/common/PageHeader.tsx
Normal file
37
frontend/src/components/admin/common/PageHeader.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
||||
81
frontend/src/components/admin/common/PageStats.tsx
Normal file
81
frontend/src/components/admin/common/PageStats.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface PageStatsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
columns?: 2 | 3 | 4 | 5 | 6;
|
||||
}
|
||||
|
||||
export function PageStats({ children, className, columns = 4 }: PageStatsProps) {
|
||||
const columnClasses = {
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-5',
|
||||
6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4', columnClasses[columns], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageStatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: ReactNode;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
trend?: {
|
||||
value: number;
|
||||
label: string;
|
||||
positive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageStatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
description,
|
||||
loading,
|
||||
trend,
|
||||
className,
|
||||
}: PageStatCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<p className="text-xs mt-1">
|
||||
<span className={trend.positive ? 'text-green-500' : 'text-red-500'}>
|
||||
{trend.positive ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
</span>
|
||||
{' '}{trend.label}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageStats;
|
||||
132
frontend/src/components/admin/common/PageTable.tsx
Normal file
132
frontend/src/components/admin/common/PageTable.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PageTableProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTable({ children, className }: PageTableProps) {
|
||||
return (
|
||||
<div className={cn('border rounded-lg overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageTableContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PageTableContent({ children }: PageTableContentProps) {
|
||||
return (
|
||||
<Table>
|
||||
{children}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageTableLoadingProps {
|
||||
columns: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function PageTableLoading({ columns, rows = 5 }: PageTableLoadingProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<TableRow key={rowIdx}>
|
||||
{Array.from({ length: columns }).map((_, colIdx) => (
|
||||
<TableCell key={colIdx}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageTableEmptyProps {
|
||||
colSpan: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function PageTableEmpty({ colSpan, message = '暂无数据' }: PageTableEmptyProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className="text-center py-8 text-muted-foreground">
|
||||
{message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageTablePaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTablePagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
onPageChange,
|
||||
pageSize = 20,
|
||||
className,
|
||||
}: PageTablePaginationProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
第 {page} 页,共 {totalPages || 1} 页,总计 {total} 条
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= (totalPages || 1)}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageTable;
|
||||
213
frontend/src/components/admin/common/README.md
Normal file
213
frontend/src/components/admin/common/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Admin Common Components
|
||||
|
||||
统一的 Admin 后台页面组件库。
|
||||
|
||||
## 组件列表
|
||||
|
||||
### PageContainer
|
||||
页面容器组件,统一页面布局和间距。
|
||||
|
||||
```tsx
|
||||
import { PageContainer, PageSection } from './PageContainer';
|
||||
|
||||
<PageContainer spacing="default">
|
||||
<PageSection>
|
||||
{/* 内容 */}
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
```
|
||||
|
||||
### PageHeader
|
||||
页面头部组件,统一标题、描述和操作按钮区域。
|
||||
|
||||
```tsx
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="管理系统用户、角色和权限"
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
actions={
|
||||
<>
|
||||
<Button>新建用户</Button>
|
||||
<RefreshButton onClick={refetch} loading={isLoading} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### PageCard
|
||||
卡片容器组件,用于包裹内容区块。
|
||||
|
||||
```tsx
|
||||
import { PageCard } from './PageCard';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
|
||||
<PageCard
|
||||
title="项目列表"
|
||||
icon={<FolderOpen className="h-5 w-5" />}
|
||||
headerActions={<Button>操作</Button>}
|
||||
>
|
||||
{/* 内容 */}
|
||||
</PageCard>
|
||||
```
|
||||
|
||||
### PageFilter
|
||||
筛选器区域组件,统一筛选器样式。
|
||||
|
||||
```tsx
|
||||
import { PageFilter, FilterGroup, FilterLabel } from './PageFilter';
|
||||
import { SearchButton } from './PageActions';
|
||||
|
||||
<PageFilter variant="default">
|
||||
<FilterGroup>
|
||||
<FilterLabel htmlFor="search">搜索</FilterLabel>
|
||||
<Input id="search" placeholder="搜索..." />
|
||||
</FilterGroup>
|
||||
<SearchButton onClick={handleSearch} />
|
||||
</PageFilter>
|
||||
```
|
||||
|
||||
### PageTable
|
||||
表格包装组件,统一表格样式、加载状态和分页。
|
||||
|
||||
```tsx
|
||||
import { PageTable, PageTablePagination, PageTableEmpty } from './PageTable';
|
||||
|
||||
<PageTable>
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 && <PageTableEmpty colSpan={5} />}
|
||||
{data.map(item => <TableRow key={item.id}>...</TableRow>)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageTable>
|
||||
|
||||
<PageTablePagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
```
|
||||
|
||||
### PageStats
|
||||
统计卡片组件,用于仪表盘等页面。
|
||||
|
||||
```tsx
|
||||
import { PageStats, PageStatCard } from './PageStats';
|
||||
import { Users, HardDrive } from 'lucide-react';
|
||||
|
||||
<PageStats columns={4}>
|
||||
<PageStatCard
|
||||
title="总用户"
|
||||
value={1234}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
description="较上月增长 12%"
|
||||
/>
|
||||
<PageStatCard
|
||||
title="存储空间"
|
||||
value="1.2 GB"
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</PageStats>
|
||||
```
|
||||
|
||||
### PageActions
|
||||
操作按钮组件,包含常用的刷新、创建、搜索、导出按钮。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
PageActions,
|
||||
RefreshButton,
|
||||
CreateButton,
|
||||
SearchButton,
|
||||
ExportButton,
|
||||
} from './PageActions';
|
||||
|
||||
<PageActions>
|
||||
<CreateButton onClick={handleCreate} label="新建用户" />
|
||||
<RefreshButton onClick={refetch} loading={isLoading} />
|
||||
</PageActions>
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整的列表页面
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import {
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageCard,
|
||||
PageFilter,
|
||||
PageTable,
|
||||
PageTablePagination,
|
||||
RefreshButton,
|
||||
CreateButton,
|
||||
} from '@/components/admin/common';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="管理系统用户"
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
actions={
|
||||
<>
|
||||
<CreateButton onClick={handleCreate} />
|
||||
<RefreshButton onClick={refetch} loading={isLoading} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageFilter variant="ghost">
|
||||
<div className="space-y-2">
|
||||
<Label>搜索</Label>
|
||||
<Input placeholder="搜索用户..." />
|
||||
</div>
|
||||
</PageFilter>
|
||||
|
||||
<PageCard title="用户列表">
|
||||
<PageTable>
|
||||
<Table>...</Table>
|
||||
</PageTable>
|
||||
</PageCard>
|
||||
|
||||
<PageTablePagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧页面迁移到新组件
|
||||
|
||||
1. **页面容器**: 将 `py-6 space-y-6` 替换为 `<PageContainer>`
|
||||
2. **页面头部**: 将分散的标题、描述和操作按钮替换为 `<PageHeader>`
|
||||
3. **筛选器**: 将分散的筛选器替换为 `<PageFilter>`
|
||||
4. **表格**: 将 `<div className="border rounded-lg"><Table>...</Table></div>` 替换为 `<PageTable>`
|
||||
5. **分页**: 将分散的分页按钮替换为 `<PageTablePagination>`
|
||||
31
frontend/src/components/admin/common/index.ts
Normal file
31
frontend/src/components/admin/common/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Layout components
|
||||
export { PageContainer, PageSection } from './PageContainer';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export { PageCard } from './PageCard';
|
||||
|
||||
// Stats components
|
||||
export { PageStats, PageStatCard } from './PageStats';
|
||||
|
||||
// Filter components
|
||||
export { PageFilter, FilterGroup, FilterLabel } from './PageFilter';
|
||||
|
||||
// Table components
|
||||
export {
|
||||
PageTable,
|
||||
PageTableContent,
|
||||
PageTableLoading,
|
||||
PageTableEmpty,
|
||||
PageTablePagination,
|
||||
} from './PageTable';
|
||||
|
||||
// Action components
|
||||
export {
|
||||
PageActions,
|
||||
RefreshButton,
|
||||
CreateButton,
|
||||
SearchButton,
|
||||
ExportButton,
|
||||
} from './PageActions';
|
||||
|
||||
// Legacy table component
|
||||
export { DataTable, type Column } from './DataTable';
|
||||
158
frontend/src/components/admin/dashboard/ActivityChart.tsx
Normal file
158
frontend/src/components/admin/dashboard/ActivityChart.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
interface ActivityDataPoint {
|
||||
date: string;
|
||||
users: number;
|
||||
projects: number;
|
||||
tasks: number;
|
||||
}
|
||||
|
||||
interface ActivityChartProps {
|
||||
data?: ActivityDataPoint[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ActivityChart({ data, isLoading }: ActivityChartProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>活动趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[calc(100%-64px)]">
|
||||
{isLoading ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorTasks" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
className="text-xs text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<div className="text-sm font-medium text-card-foreground">
|
||||
{new Date(label || '').toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{payload.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color ?? entry.fill }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{entry.name === 'users' && '用户'}
|
||||
{entry.name === 'projects' && '项目'}
|
||||
{entry.name === 'tasks' && '任务'}
|
||||
</span>
|
||||
<span className="font-medium text-card-foreground">
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
name="users"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUsers)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="projects"
|
||||
name="projects"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorProjects)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="tasks"
|
||||
name="tasks"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorTasks)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/admin/dashboard/RecentActivity.tsx
Normal file
132
frontend/src/components/admin/dashboard/RecentActivity.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
UserPlus,
|
||||
FolderPlus,
|
||||
CheckSquare,
|
||||
Zap,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'user_created' | 'project_created' | 'task_completed' | 'generation_started' | 'system' | 'error';
|
||||
title: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities?: ActivityItem[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const activityIcons = {
|
||||
user_created: { icon: UserPlus, color: 'text-blue-500', bgColor: 'bg-blue-500/10' },
|
||||
project_created: { icon: FolderPlus, color: 'text-emerald-500', bgColor: 'bg-emerald-500/10' },
|
||||
task_completed: { icon: CheckSquare, color: 'text-amber-500', bgColor: 'bg-amber-500/10' },
|
||||
generation_started: { icon: Zap, color: 'text-purple-500', bgColor: 'bg-purple-500/10' },
|
||||
system: { icon: Settings, color: 'text-gray-500', bgColor: 'bg-gray-500/10' },
|
||||
error: { icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' },
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return '刚刚';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes} 分钟前`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours} 小时前`;
|
||||
} else if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days} 天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>最近活动</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activities && activities.length > 0 ? (
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => {
|
||||
const { icon: Icon, color, bgColor } = activityIcons[activity.type];
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="flex items-start gap-4">
|
||||
<div className={`${bgColor} p-2 rounded-full shrink-0`}>
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{activity.title}
|
||||
</p>
|
||||
{activity.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{activity.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</span>
|
||||
{activity.user && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activity.user.username}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无活动记录</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/admin/dashboard/StatsCards.tsx
Normal file
82
frontend/src/components/admin/dashboard/StatsCards.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Users, FolderKanban, CheckSquare, Zap } from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalProjects: number;
|
||||
totalTasks: number;
|
||||
activeTasks: number;
|
||||
pendingTasks: number;
|
||||
failedTasks: number;
|
||||
completedTasks: number;
|
||||
}
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats?: DashboardStats;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const statItems = [
|
||||
{
|
||||
key: 'totalUsers' as const,
|
||||
label: '总用户数',
|
||||
icon: Users,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
{
|
||||
key: 'totalProjects' as const,
|
||||
label: '总项目数',
|
||||
icon: FolderKanban,
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
key: 'totalTasks' as const,
|
||||
label: '总任务数',
|
||||
icon: CheckSquare,
|
||||
color: 'text-amber-500',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
key: 'activeTasks' as const,
|
||||
label: '活跃任务',
|
||||
icon: Zap,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{statItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const value = stats?.[item.key] ?? 0;
|
||||
|
||||
return (
|
||||
<Card key={item.key}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{item.label}
|
||||
</CardTitle>
|
||||
<div className={`${item.bgColor} p-2 rounded-md`}>
|
||||
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/admin/dashboard/index.ts
Normal file
3
frontend/src/components/admin/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { StatsCards } from './StatsCards';
|
||||
export { ActivityChart } from './ActivityChart';
|
||||
export { RecentActivity } from './RecentActivity';
|
||||
89
frontend/src/components/admin/layout/AdminHeader.tsx
Normal file
89
frontend/src/components/admin/layout/AdminHeader.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LogOut, User, Settings, Film } from 'lucide-react';
|
||||
|
||||
export function AdminHeader() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b border-border bg-card flex items-center justify-between px-6 shrink-0">
|
||||
{/* Logo / Left */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 bg-primary rounded-xl flex items-center justify-center shadow-lg ring-1 ring-white/10">
|
||||
<Film className="text-primary-foreground w-5 h-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">Pixel 管理后台</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Side - User Menu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-3 outline-none">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-medium">{user?.username}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user?.isSuperuser ? '超级管理员' : '管理员'}
|
||||
</p>
|
||||
</div>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user?.avatarUrl} alt={user?.username} />
|
||||
<AvatarFallback>{getInitials(user?.username || 'A')}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>我的账户</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
个人设置
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/admin/settings" className="flex items-center gap-2 cursor-pointer">
|
||||
<Settings className="h-4 w-4" />
|
||||
系统设置
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="flex items-center gap-2 cursor-pointer">
|
||||
<LogOut className="h-4 w-4" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminHeader;
|
||||
30
frontend/src/components/admin/layout/AdminLayout.tsx
Normal file
30
frontend/src/components/admin/layout/AdminLayout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
import { AdminHeader } from './AdminHeader';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminLayout({ children }: AdminLayoutProps) {
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||
{/* Fixed Top Header */}
|
||||
<AdminHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Fixed Left Sidebar */}
|
||||
<AdminSidebar />
|
||||
|
||||
{/* Scrollable Right Content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
83
frontend/src/components/admin/layout/AdminSidebar.tsx
Normal file
83
frontend/src/components/admin/layout/AdminSidebar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FolderKanban,
|
||||
ListTodo,
|
||||
Settings,
|
||||
ArrowLeft,
|
||||
Key,
|
||||
Cpu,
|
||||
Database,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: '概览', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||
{ label: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ label: '密钥管理', href: '/admin/api-keys', icon: Key },
|
||||
{ label: '项目管理', href: '/admin/projects', icon: FolderKanban },
|
||||
{ label: '任务管理', href: '/admin/tasks', icon: ListTodo },
|
||||
{ label: '模型配置', href: '/admin/models', icon: Cpu },
|
||||
{ label: '存储管理', href: '/admin/storage', icon: Database },
|
||||
{ label: '审计日志', href: '/admin/audit-logs', icon: FileText },
|
||||
{ label: '系统设置', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-card border-r border-border flex flex-col shrink-0 overflow-hidden">
|
||||
{/* Navigation - 可滚动 */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Back to App - 固定在底部 */}
|
||||
<div className="p-3 border-t border-border shrink-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回应用
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSidebar;
|
||||
3
frontend/src/components/admin/layout/index.ts
Normal file
3
frontend/src/components/admin/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AdminLayout } from './AdminLayout';
|
||||
export { AdminSidebar } from './AdminSidebar';
|
||||
export { AdminHeader } from './AdminHeader';
|
||||
241
frontend/src/components/admin/projects/ProjectGrid.tsx
Normal file
241
frontend/src/components/admin/projects/ProjectGrid.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Image,
|
||||
Film,
|
||||
Calendar,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAdminProjects,
|
||||
useDeleteAdminProject,
|
||||
AdminProject,
|
||||
AdminProjectFilters,
|
||||
} from "@/lib/hooks/admin/useAdminProjects";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ProjectGridViewProps {
|
||||
filters?: AdminProjectFilters;
|
||||
}
|
||||
|
||||
export function ProjectGrid({ filters = {} }: ProjectGridViewProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const { data, isLoading } = useAdminProjects({
|
||||
...filters,
|
||||
page,
|
||||
page_size: 12,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteAdminProject();
|
||||
|
||||
const handleDelete = (project: AdminProject) => {
|
||||
confirm({
|
||||
title: "确认删除项目?",
|
||||
description: `确定要删除项目 "${project.name}" 吗?此操作无法撤销。`,
|
||||
variant: "destructive",
|
||||
onConfirm: () => deleteMutation.mutate(project.id),
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{ variant: "default" | "secondary" | "destructive" | "outline"; label: string }
|
||||
> = {
|
||||
active: { variant: "default", label: "进行中" },
|
||||
archived: { variant: "secondary", label: "已归档" },
|
||||
deleted: { variant: "destructive", label: "已删除" },
|
||||
};
|
||||
const config = variants[status] || { variant: "outline", label: status };
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
video: "视频",
|
||||
comic: "漫画",
|
||||
};
|
||||
return <Badge variant="outline">{labels[type] || type}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-80" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.items?.length) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<FolderOpen className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">暂无项目</h3>
|
||||
<p className="text-muted-foreground">创建您的第一个项目以开始创作</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data.items.map((project) => (
|
||||
<Card key={project.id} className="flex flex-col h-full hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
project.type === 'video' ? 'bg-blue-500/10' : 'bg-purple-500/10'
|
||||
}`}>
|
||||
<FolderOpen className={`h-5 w-5 ${
|
||||
project.type === 'video' ? 'text-blue-500' : 'text-purple-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg truncate max-w-[200px]">
|
||||
{project.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getTypeBadge(project.type)}
|
||||
{getStatusBadge(project.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">项目进度</span>
|
||||
<span className="font-medium">{project.progress || 0}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress || 0} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||
<div className="text-center">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-xs font-medium">{project.episodesCount || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">剧集</p>
|
||||
</div>
|
||||
<div className="text-center border-l">
|
||||
<Image className="h-4 w-4 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-xs font-medium">{project.assetsCount || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">资源</p>
|
||||
</div>
|
||||
<div className="text-center border-l">
|
||||
<Film className="h-4 w-4 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-xs font-medium">{project.storyboardsCount || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">分镜</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="truncate">{project.ownerName || "未知用户"}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex items-center justify-between border-t pt-4">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
{project.createdAt
|
||||
? new Date(project.createdAt).toLocaleDateString("zh-CN")
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/projects/${project.id}`} className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
查看详情
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/project/${project.id}`} className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
打开项目
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive flex items-center gap-2"
|
||||
onClick={() => handleDelete(project)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除项目
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.pagination && (
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示 {(page - 1) * 12 + 1} - {Math.min(page * 12, data.pagination.total)} 共{" "}
|
||||
{data.pagination.total} 条
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.pagination.total_pages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
196
frontend/src/components/admin/projects/ProjectTable.tsx
Normal file
196
frontend/src/components/admin/projects/ProjectTable.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DataTable, Column } from "@/components/admin/common/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAdminProjects,
|
||||
useDeleteAdminProject,
|
||||
AdminProject,
|
||||
AdminProjectFilters,
|
||||
} from "@/lib/hooks/admin/useAdminProjects";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
|
||||
interface ProjectTableProps {
|
||||
filters?: AdminProjectFilters;
|
||||
}
|
||||
|
||||
export function ProjectTable({ filters = {} }: ProjectTableProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const { data, isLoading } = useAdminProjects({
|
||||
...filters,
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteAdminProject();
|
||||
|
||||
const handleDelete = (project: AdminProject) => {
|
||||
confirm({
|
||||
title: "确认删除项目?",
|
||||
description: `确定要删除项目 "${project.name}" 吗?此操作无法撤销。`,
|
||||
variant: "destructive",
|
||||
onConfirm: () => deleteMutation.mutate(project.id),
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
|
||||
active: { variant: "default", label: "进行中" },
|
||||
archived: { variant: "secondary", label: "已归档" },
|
||||
deleted: { variant: "destructive", label: "已删除" },
|
||||
};
|
||||
const config = variants[status] || { variant: "outline", label: status };
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
video: "视频",
|
||||
comic: "漫画",
|
||||
};
|
||||
return <Badge variant="outline">{labels[type] || type}</Badge>;
|
||||
};
|
||||
|
||||
const columns: Column<AdminProject>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "项目名称",
|
||||
width: "25%",
|
||||
render: (project) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium truncate max-w-[200px]">{project.name}</span>
|
||||
{project.description && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{project.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
header: "所有者",
|
||||
width: "15%",
|
||||
render: (project) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{project.ownerName || "未知用户"}</span>
|
||||
{project.ownerEmail && (
|
||||
<span className="text-xs text-muted-foreground">{project.ownerEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "类型",
|
||||
width: "10%",
|
||||
render: (project) => getTypeBadge(project.type),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
width: "10%",
|
||||
render: (project) => getStatusBadge(project.status),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: "进度",
|
||||
width: "15%",
|
||||
render: (project) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Progress value={project.progress || 0} className="h-2" />
|
||||
<span className="text-xs text-muted-foreground">{project.progress || 0}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "创建时间",
|
||||
width: "15%",
|
||||
render: (project) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{project.createdAt ? new Date(project.createdAt).toLocaleDateString("zh-CN") : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
width: "10%",
|
||||
render: (project) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/projects/${project.id}`} className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
查看详情
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/project/${project.id}`} className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
打开项目
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive flex items-center gap-2"
|
||||
onClick={() => handleDelete(project)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除项目
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items || []}
|
||||
keyExtractor={(item) => item.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="暂无项目数据"
|
||||
pagination={
|
||||
data?.pagination
|
||||
? {
|
||||
page,
|
||||
pageSize: 20,
|
||||
total: data.pagination.total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
179
frontend/src/components/admin/tasks/QueueStatus.tsx
Normal file
179
frontend/src/components/admin/tasks/QueueStatus.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useAdminTaskStats,
|
||||
useAdminTaskQueue,
|
||||
} from "@/lib/hooks/admin/useAdminTasks";
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Layers,
|
||||
Zap,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
export function QueueStatus() {
|
||||
const { data: stats, isLoading: statsLoading } = useAdminTaskStats();
|
||||
const { data: queue, isLoading: queueLoading } = useAdminTaskQueue();
|
||||
|
||||
const isLoading = statsLoading || queueLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "总任务数",
|
||||
value: stats?.total || 0,
|
||||
icon: Layers,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "等待中",
|
||||
value: stats?.pending || 0,
|
||||
icon: Clock,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
{
|
||||
title: "处理中",
|
||||
value: stats?.processing || 0,
|
||||
icon: Loader2,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "已完成",
|
||||
value: stats?.completed || 0,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: "失败",
|
||||
value: stats?.failed || 0,
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: "队列长度",
|
||||
value: queue?.queueLength || 0,
|
||||
icon: Activity,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
},
|
||||
{
|
||||
title: "活跃工作器",
|
||||
value: queue?.activeWorkers || 0,
|
||||
icon: Zap,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: "平均耗时",
|
||||
value: stats?.avgDuration
|
||||
? `${Math.round(stats.avgDuration)}秒`
|
||||
: "-",
|
||||
icon: Timer,
|
||||
color: "text-cyan-500",
|
||||
bgColor: "bg-cyan-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
{statCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`p-2 rounded-lg ${card.bgColor}`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{card.title}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
{queue && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
队列状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">工作器使用率</span>
|
||||
<span className="font-medium">
|
||||
{queue.maxWorkers > 0
|
||||
? Math.round(
|
||||
(queue.activeWorkers / queue.maxWorkers) * 100
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
queue.maxWorkers > 0
|
||||
? (queue.activeWorkers / queue.maxWorkers) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{queue.activeWorkers} / {queue.maxWorkers} 工作器运行中
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">每分钟吞吐量</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{(queue.throughputPerMinute ?? 0).toFixed(1)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">任务/分钟</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">预计等待时间</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{queue.estimatedWaitSeconds === undefined
|
||||
? "-"
|
||||
: queue.estimatedWaitSeconds < 60
|
||||
? `${Math.round(queue.estimatedWaitSeconds)}秒`
|
||||
: `${Math.round(queue.estimatedWaitSeconds / 60)}分钟`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">队列中的任务</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
456
frontend/src/components/admin/tasks/TaskDetailPanel.tsx
Normal file
456
frontend/src/components/admin/tasks/TaskDetailPanel.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
User,
|
||||
FolderOpen,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Clock3,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAdminTaskDetail,
|
||||
useRetryAdminTask,
|
||||
AdminTaskDetail,
|
||||
TaskStatus,
|
||||
} from "@/lib/hooks/admin/useAdminTasks";
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
taskId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return new Date(dateString).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
export function TaskDetailPanel({
|
||||
taskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TaskDetailPanelProps) {
|
||||
const { data: task, isLoading } = useAdminTaskDetail(taskId);
|
||||
const retryMutation = useRetryAdminTask();
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const configs: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; icon: React.ReactNode; label: string }> = {
|
||||
pending: { variant: "secondary", icon: <Clock className="h-3 w-3" />, label: "等待中" },
|
||||
processing: { variant: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, label: "处理中" },
|
||||
success: { variant: "default", icon: <CheckCircle2 className="h-3 w-3" />, label: "成功" },
|
||||
failed: { variant: "destructive", icon: <XCircle className="h-3 w-3" />, label: "失败" },
|
||||
cancelled: { variant: "outline", icon: <PauseCircle className="h-3 w-3" />, label: "已取消" },
|
||||
};
|
||||
const config = configs[status] || { variant: "outline", icon: null, label: status };
|
||||
return (
|
||||
<Badge variant={config.variant} className="flex items-center gap-1">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
image: <ImageIcon className="h-3 w-3" />,
|
||||
video: <Video className="h-3 w-3" />,
|
||||
audio: <Music className="h-3 w-3" />,
|
||||
script: <FileText className="h-3 w-3" />,
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
script: "剧本",
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
{icons[type] || <FileText className="h-3 w-3" />}
|
||||
{labels[type] || type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (task) {
|
||||
retryMutation.mutate(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const renderResult = () => {
|
||||
if (!task?.result) return null;
|
||||
|
||||
const { result } = task;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm text-muted-foreground">生成结果</h4>
|
||||
|
||||
{/* Result URL / Media Preview */}
|
||||
{(result.imageUrl || result.videoUrl || result.audioUrl || result.url) && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg border">
|
||||
{result.imageUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
生成图片
|
||||
</div>
|
||||
<img
|
||||
src={result.imageUrl}
|
||||
alt="Generated result"
|
||||
className="w-full h-auto rounded-md border"
|
||||
/>
|
||||
<a
|
||||
href={result.imageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
查看原图 <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.videoUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Video className="h-4 w-4" />
|
||||
生成视频
|
||||
</div>
|
||||
<video
|
||||
src={result.videoUrl}
|
||||
controls
|
||||
className="w-full rounded-md border"
|
||||
/>
|
||||
<a
|
||||
href={result.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
查看原视频 <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.audioUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Music className="h-4 w-4" />
|
||||
生成音频
|
||||
</div>
|
||||
<audio
|
||||
src={result.audioUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
<a
|
||||
href={result.audioUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
查看原音频 <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.imageUrl && !result.videoUrl && !result.audioUrl && result.url && (
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
查看结果 <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation Parameters */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{result.prompt && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">提示词</div>
|
||||
<div className="p-2 bg-muted/50 rounded text-xs break-words">
|
||||
{result.prompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.negativePrompt && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">负向提示词</div>
|
||||
<div className="p-2 bg-muted/50 rounded text-xs">
|
||||
{result.negativePrompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.model && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
模型
|
||||
</div>
|
||||
<div className="text-xs font-medium">{result.model}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.provider && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">提供商</div>
|
||||
<div className="text-xs font-medium">{result.provider}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.resolution && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">分辨率</div>
|
||||
<div className="text-xs font-medium">{result.resolution}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.format && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">格式</div>
|
||||
<div className="text-xs font-medium">{result.format}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.durationSeconds !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
时长
|
||||
</div>
|
||||
<div className="text-xs font-medium">{result.durationSeconds.toFixed(1)}s</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.fileSize && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
文件大小
|
||||
</div>
|
||||
<div className="text-xs font-medium">
|
||||
{(result.fileSize / 1024 / 1024).toFixed(2)} MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.cost !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">成本</div>
|
||||
<div className="text-xs font-medium">${result.cost.toFixed(4)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.providerTaskId && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">Provider Task ID</div>
|
||||
<div className="text-xs font-mono bg-muted/50 px-2 py-1 rounded break-all">
|
||||
{result.providerTaskId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata JSON */}
|
||||
{result.metadata && Object.keys(result.metadata).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">元数据</div>
|
||||
<pre className="p-2 bg-muted/50 rounded text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(result.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskInfo = () => {
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm text-muted-foreground">任务信息</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(task.status)}
|
||||
{getTypeBadge(task.type)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">#{task.id.slice(-8)}</div>
|
||||
</div>
|
||||
|
||||
{task.projectName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{task.projectName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.userName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{task.userName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.userEmail && (
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
{task.userEmail}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.model && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">模型</div>
|
||||
<div className="text-sm">{task.model}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.provider && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">提供商</div>
|
||||
<div className="text-sm">{task.provider}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
创建时间
|
||||
</div>
|
||||
<div className="text-sm">{formatDateTime(task.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
{task.completedAt && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
完成时间
|
||||
</div>
|
||||
<div className="text-sm">{formatDateTime(task.completedAt)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.duration && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
耗时
|
||||
</div>
|
||||
<div className="text-sm">{task.duration}s</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">重试</div>
|
||||
<div className="text-sm">
|
||||
{task.retryCount} / {task.maxRetries}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm mb-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
错误信息
|
||||
</div>
|
||||
<pre className="text-xs text-destructive whitespace-pre-wrap">{task.error}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.params && Object.keys(task.params).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">请求参数</div>
|
||||
<pre className="p-2 bg-muted/50 rounded text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(task.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span>任务详情</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{task?.status === 'failed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${retryMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : task ? (
|
||||
<div className="space-y-6">
|
||||
{renderTaskInfo()}
|
||||
{renderResult()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
任务不存在
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Retry confirmation is handled by the mutation */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
frontend/src/components/admin/tasks/TaskTable.tsx
Normal file
257
frontend/src/components/admin/tasks/TaskTable.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DataTable, Column } from "@/components/admin/common/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAdminTasks,
|
||||
useRetryAdminTask,
|
||||
useDeleteAdminTask,
|
||||
AdminTask,
|
||||
AdminTaskFilters,
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
} from "@/lib/hooks/admin/useAdminTasks";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { TaskDetailPanel } from "./TaskDetailPanel";
|
||||
|
||||
interface TaskTableProps {
|
||||
filters?: AdminTaskFilters;
|
||||
}
|
||||
|
||||
export function TaskTable({ filters = {} }: TaskTableProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const { data, isLoading } = useAdminTasks({
|
||||
...filters,
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const retryMutation = useRetryAdminTask();
|
||||
const deleteMutation = useDeleteAdminTask();
|
||||
|
||||
const handleViewDetail = (task: AdminTask) => {
|
||||
setSelectedTaskId(task.id);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleRetry = (task: AdminTask) => {
|
||||
confirm({
|
||||
title: "确认重试任务?",
|
||||
description: `确定要重新执行任务 "${task.id}" 吗?`,
|
||||
onConfirm: () => retryMutation.mutate(task.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (task: AdminTask) => {
|
||||
confirm({
|
||||
title: "确认删除任务?",
|
||||
description: `确定要删除任务 "${task.id}" 吗?此操作无法撤销。`,
|
||||
variant: "destructive",
|
||||
onConfirm: () => deleteMutation.mutate(task.id),
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TaskStatus) => {
|
||||
const config: Record<
|
||||
TaskStatus,
|
||||
{ variant: "default" | "secondary" | "destructive" | "outline"; label: string; icon: React.ReactNode }
|
||||
> = {
|
||||
pending: { variant: "secondary", label: "等待中", icon: <Clock className="h-3 w-3" /> },
|
||||
processing: { variant: "default", label: "处理中", icon: <Loader2 className="h-3 w-3 animate-spin" /> },
|
||||
completed: { variant: "default", label: "已完成", icon: <CheckCircle2 className="h-3 w-3" /> },
|
||||
failed: { variant: "destructive", label: "失败", icon: <XCircle className="h-3 w-3" /> },
|
||||
cancelled: { variant: "outline", label: "已取消", icon: <PauseCircle className="h-3 w-3" /> },
|
||||
};
|
||||
const c = config[status] || { variant: "outline", label: status, icon: null };
|
||||
return (
|
||||
<Badge variant={c.variant} className="flex items-center gap-1 w-fit">
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: TaskType) => {
|
||||
const labels: Record<TaskType, string> = {
|
||||
image: "图片生成",
|
||||
video: "视频生成",
|
||||
audio: "音频生成",
|
||||
script: "剧本处理",
|
||||
other: "其他",
|
||||
};
|
||||
return <Badge variant="outline">{labels[type] || type}</Badge>;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return "-";
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
return `${Math.floor(seconds / 3600)}时${Math.floor((seconds % 3600) / 60)}分`;
|
||||
};
|
||||
|
||||
const columns: Column<AdminTask>[] = [
|
||||
{
|
||||
key: "id",
|
||||
header: "任务ID",
|
||||
width: "15%",
|
||||
render: (task) => (
|
||||
<button
|
||||
onClick={() => handleViewDetail(task)}
|
||||
className="font-mono text-xs text-primary hover:underline text-left"
|
||||
>
|
||||
{task.id.slice(0, 8)}...
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "类型",
|
||||
width: "10%",
|
||||
render: (task) => getTypeBadge(task.type),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
width: "10%",
|
||||
render: (task) => getStatusBadge(task.status),
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
header: "项目",
|
||||
width: "15%",
|
||||
render: (task) =>
|
||||
task.projectId ? (
|
||||
<Link
|
||||
href={`/admin/projects/${task.projectId}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{task.projectName || task.projectId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
header: "用户",
|
||||
width: "15%",
|
||||
render: (task) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{task.userName || "未知用户"}</span>
|
||||
{task.userEmail && (
|
||||
<span className="text-xs text-muted-foreground">{task.userEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "创建时间",
|
||||
width: "12%",
|
||||
render: (task) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{task.createdAt ? new Date(task.createdAt).toLocaleString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "耗时",
|
||||
width: "10%",
|
||||
render: (task) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDuration(task.duration)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
width: "13%",
|
||||
render: (task) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{task.status === "failed" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRetry(task)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重试任务
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive flex items-center gap-2"
|
||||
onClick={() => handleDelete(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除任务
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items || []}
|
||||
keyExtractor={(item) => item.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="暂无任务数据"
|
||||
pagination={
|
||||
data?.pagination
|
||||
? {
|
||||
page,
|
||||
pageSize: 20,
|
||||
total: data.pagination.total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmDialog />
|
||||
<TaskDetailPanel
|
||||
taskId={selectedTaskId}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
349
frontend/src/components/admin/users/UserCreateDialog.tsx
Normal file
349
frontend/src/components/admin/users/UserCreateDialog.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Loader2, UserPlus, Shield, Key, Mail, Lock } from 'lucide-react';
|
||||
import { AVAILABLE_ROLES, AVAILABLE_PERMISSIONS } from '@/lib/api/admin/users';
|
||||
|
||||
interface UserCreateDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
isActive: boolean;
|
||||
isSuperuser: boolean;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}) => void;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export function UserCreateDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: UserCreateDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
isActive: true,
|
||||
isSuperuser: false,
|
||||
});
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>(['user']);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = '用户名不能为空';
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username = '用户名至少需要3个字符';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = '邮箱不能为空';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = '请输入有效的邮箱地址';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = '密码不能为空';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = '密码至少需要6个字符';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
setSelectedRoles((prev) =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter((r) => r !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionId: string) => {
|
||||
setSelectedPermissions((prev) =>
|
||||
prev.includes(permissionId)
|
||||
? prev.filter((p) => p !== permissionId)
|
||||
: [...prev, permissionId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSave({
|
||||
username: formData.username.trim(),
|
||||
email: formData.email.trim(),
|
||||
password: formData.password,
|
||||
isActive: formData.isActive,
|
||||
isSuperuser: formData.isSuperuser,
|
||||
roles: selectedRoles,
|
||||
permissions: selectedPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
isActive: true,
|
||||
isSuperuser: false,
|
||||
});
|
||||
setSelectedRoles(['user']);
|
||||
setSelectedPermissions([]);
|
||||
setErrors({});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
新建用户
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新用户账号并设置基本信息、角色和权限
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">基本信息</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="flex items-center gap-2">
|
||||
<span>用户名</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="输入用户名"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, username: e.target.value }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
className={errors.username ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>邮箱</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="输入邮箱地址"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
className={errors.email ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>密码</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="设置密码(至少6个字符)"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
className={errors.password ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">账户状态</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="user-active">启用账户</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
创建后立即启用该账户
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="user-active"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, isActive: checked }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="user-superuser">超级用户</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
授予该用户所有系统权限
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="user-superuser"
|
||||
checked={formData.isSuperuser}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, isSuperuser: checked }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Roles Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">角色</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{AVAILABLE_ROLES.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||
selectedRoles.includes(role.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handleRoleToggle(role.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role.id)}
|
||||
onCheckedChange={() => handleRoleToggle(role.id)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{role.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">权限</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{AVAILABLE_PERMISSIONS.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||
selectedPermissions.includes(permission.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handlePermissionToggle(permission.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(permission.id)}
|
||||
onCheckedChange={() => handlePermissionToggle(permission.id)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{permission.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permission.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
'创建用户'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
235
frontend/src/components/admin/users/UserEditDialog.tsx
Normal file
235
frontend/src/components/admin/users/UserEditDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Loader2, User as UserIcon, Mail, Shield, Key } from 'lucide-react';
|
||||
import { type User as UserType, AVAILABLE_ROLES, AVAILABLE_PERMISSIONS } from '@/lib/api/admin/users';
|
||||
|
||||
interface UserEditDialogProps {
|
||||
user: UserType | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (userId: string, data: { isActive: boolean; roles: string[]; permissions: string[] }) => void;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export function UserEditDialog({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: UserEditDialogProps) {
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setIsActive(user.isActive);
|
||||
setSelectedRoles(user.roles || []);
|
||||
setSelectedPermissions(user.permissions || []);
|
||||
}
|
||||
}, [user, isOpen]);
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
setSelectedRoles((prev) =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter((r) => r !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionId: string) => {
|
||||
setSelectedPermissions((prev) =>
|
||||
prev.includes(permissionId)
|
||||
? prev.filter((p) => p !== permissionId)
|
||||
: [...prev, permissionId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (user) {
|
||||
onSave(user.id, {
|
||||
isActive,
|
||||
roles: selectedRoles,
|
||||
permissions: selectedPermissions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp?: number | string) => {
|
||||
if (!timestamp) return '从未';
|
||||
const numTimestamp = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp;
|
||||
return new Date(numTimestamp * 1000).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
编辑用户
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改用户状态、角色和权限设置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* User Info Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserIcon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">{user.username}</span>
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>创建于: {formatDate(user.createdAt)}</div>
|
||||
<div>最后登录: {formatDate(user.lastLogin)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">账户状态</Label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="user-active">启用账户</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
禁用后用户将无法登录系统
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="user-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Roles Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">角色</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{AVAILABLE_ROLES.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||
selectedRoles.includes(role.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handleRoleToggle(role.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role.id)}
|
||||
onCheckedChange={() => handleRoleToggle(role.id)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{role.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
<Label className="text-base font-medium">权限</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{AVAILABLE_PERMISSIONS.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
||||
selectedPermissions.includes(permission.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handlePermissionToggle(permission.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(permission.id)}
|
||||
onCheckedChange={() => handlePermissionToggle(permission.id)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{permission.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permission.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
'保存更改'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
374
frontend/src/components/admin/users/UserTable.tsx
Normal file
374
frontend/src/components/admin/users/UserTable.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Power,
|
||||
PowerOff,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react';
|
||||
import { type User } from '@/lib/api/admin/users';
|
||||
|
||||
interface UserTableProps {
|
||||
data: User[];
|
||||
isLoading: boolean;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
};
|
||||
sorting: {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
};
|
||||
onView: (user: User) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onToggleActive: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
isToggling?: string | null;
|
||||
isDeleting?: string | null;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
cell: (user: User) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
data,
|
||||
isLoading,
|
||||
pagination,
|
||||
sorting,
|
||||
onView,
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
isToggling,
|
||||
isDeleting,
|
||||
}: UserTableProps) {
|
||||
const [deletingUser, setDeletingUser] = useState<User | null>(null);
|
||||
|
||||
const formatDate = (dateStr?: string | number) => {
|
||||
if (!dateStr) return '从未';
|
||||
const strDate = typeof dateStr === 'number' ? dateStr.toString() : dateStr;
|
||||
return new Date(strDate).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
if (sorting.sortBy === columnKey) {
|
||||
sorting.onSortChange(columnKey, sorting.sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
sorting.onSortChange(columnKey, 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ columnKey }: { columnKey: string }) => {
|
||||
if (sorting.sortBy !== columnKey) {
|
||||
return <ArrowUpDown className="w-3 h-3 ml-1 opacity-50" />;
|
||||
}
|
||||
return sorting.sortOrder === 'asc' ? (
|
||||
<ArrowUp className="w-3 h-3 ml-1" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3 ml-1" />
|
||||
);
|
||||
};
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
key: 'username',
|
||||
header: '用户名',
|
||||
sortable: true,
|
||||
cell: (user) => <div className="font-medium">{user.username}</div>,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
header: '邮箱',
|
||||
sortable: true,
|
||||
cell: (user) => <div className="text-sm text-muted-foreground">{user.email}</div>,
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: '状态',
|
||||
sortable: true,
|
||||
cell: (user) => (
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
header: '角色',
|
||||
cell: (user) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles?.slice(0, 2).map((role) => (
|
||||
<Badge key={role} variant="outline" className="text-xs">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
{user.roles && user.roles.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{user.roles.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
header: '权限',
|
||||
cell: (user) => {
|
||||
const permissions = user.permissions;
|
||||
if (!permissions || permissions.length === 0) return '-';
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{permissions.slice(0, 2).map((perm) => (
|
||||
<Badge key={perm} variant="outline" className="text-xs">
|
||||
{perm}
|
||||
</Badge>
|
||||
))}
|
||||
{permissions.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{permissions.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: '创建时间',
|
||||
sortable: true,
|
||||
cell: (user) => (
|
||||
<div className="text-sm text-muted-foreground">{formatDate(user.createdAt)}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastLogin',
|
||||
header: '最后登录',
|
||||
sortable: true,
|
||||
cell: (user) => (
|
||||
<div className="text-sm text-muted-foreground">{formatDate(user.lastLogin)}</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(pagination.total / pagination.pageSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key}>
|
||||
{column.sortable ? (
|
||||
<button
|
||||
className="flex items-center hover:text-foreground transition-colors"
|
||||
onClick={() => handleSort(column.key)}
|
||||
>
|
||||
{column.header}
|
||||
<SortIcon columnKey={column.key} />
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="h-24 text-center">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>{column.cell(user)}</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onView(user)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(user)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onToggleActive(user)}
|
||||
disabled={isToggling === user.id}
|
||||
title={user.isActive ? '禁用' : '启用'}
|
||||
>
|
||||
{isToggling === user.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : user.isActive ? (
|
||||
<PowerOff className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<Power className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={deletingUser?.id === user.id}
|
||||
onOpenChange={(open) => !open && setDeletingUser(null)}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeletingUser(user)}
|
||||
disabled={isDeleting === user.id}
|
||||
title="删除"
|
||||
>
|
||||
{isDeleting === user.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您确定要删除用户 "{user.username}" 吗?
|
||||
此操作不可撤销,用户的数据将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeletingUser(null)}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
onDelete(user);
|
||||
setDeletingUser(null);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
第 {pagination.page} 页,共 {totalPages || 1} 页
|
||||
</span>
|
||||
<span className="hidden sm:inline">({pagination.total} 条记录)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
||||
disabled={pagination.page >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(totalPages)}
|
||||
disabled={pagination.page >= totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
49
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side Protected Route Component
|
||||
*
|
||||
* This component provides an additional layer of protection on top of middleware.
|
||||
* It checks authentication status and redirects if not authenticated.
|
||||
*/
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { status, isLoading, hasHydrated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect after hydration is complete
|
||||
if (hasHydrated && !isLoading && status === 'anonymous') {
|
||||
// Redirect to login with return URL
|
||||
const loginUrl = `/login?redirect=${encodeURIComponent(pathname)}`;
|
||||
router.push(loginUrl);
|
||||
}
|
||||
}, [status, isLoading, hasHydrated, router, pathname]);
|
||||
|
||||
// Show loading state while hydrating or checking authentication
|
||||
if (!hasHydrated || isLoading || status === 'idle') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only render children if authenticated
|
||||
if (status !== 'authenticated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
920
frontend/src/components/canvas/Canvas.tsx
Normal file
920
frontend/src/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,920 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useMemo, useState, useEffect, memo, useRef } from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import dynamic from 'next/dynamic';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Connection,
|
||||
Edge,
|
||||
BackgroundVariant,
|
||||
NodeTypes,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
useViewport,
|
||||
ConnectionLineType,
|
||||
MiniMap,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { AppNode } from './nodes/AppNode';
|
||||
import { GroupNode } from './nodes/GroupNode';
|
||||
import { GradientEdge } from './edges/GradientEdge';
|
||||
import { AppNodeData, AppNodeType, NodeType, ContextMenuState, ContextMenuTarget } from './types/node';
|
||||
import {
|
||||
X,
|
||||
List
|
||||
} from 'lucide-react';
|
||||
import { CanvasContextMenu } from './controls/CanvasContextMenu';
|
||||
import { CanvasViewportControls } from './controls/CanvasViewportControls';
|
||||
import { CanvasShortcuts } from './controls/CanvasShortcuts';
|
||||
import { OnboardingTour } from './controls/OnboardingTour';
|
||||
import { BatchGenerationToolbar } from './controls/BatchGenerationToolbar';
|
||||
import { GenerationQueuePanel } from './panels/GenerationQueuePanel';
|
||||
import { PromptTemplateLibrary } from './panels/PromptTemplateLibrary';
|
||||
import { CanvasProviders } from './CanvasProviders';
|
||||
import { useSocketInteraction } from './hooks/useSocketInteraction';
|
||||
import { useCanvasHotkeys } from './hooks/useCanvasHotkeys';
|
||||
import { useCanvasDragDrop } from './hooks/useCanvasDragDrop';
|
||||
import { useNodeSnapping } from './hooks/useNodeSnapping';
|
||||
import { Sidebar } from './panels/docks/CanvasLeftDock';
|
||||
import { useProjectStore } from '@lib/store/project';
|
||||
|
||||
import { NodeStatus } from './types/node';
|
||||
|
||||
import { useCanvasContext, CanvasContextType } from './CanvasContext';
|
||||
import { useAssetSaver } from './hooks/useAssetSaver';
|
||||
import { useCanvasEvents } from './hooks/useCanvasEvents';
|
||||
import { useResumeTaskPolling } from './hooks/useResumeTaskPolling';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
|
||||
// Managers
|
||||
import { useCanvasPersistence } from './managers/useCanvasPersistence';
|
||||
import { useGenerationHistory } from './managers/useGenerationHistory';
|
||||
import { useCanvasInteraction } from './managers/useCanvasInteraction';
|
||||
import { useCanvasStore } from '@lib/store/canvasStore';
|
||||
import { useAutoWorkflow } from './hooks/useAutoWorkflow';
|
||||
import { SaveFeedback } from './SaveFeedback';
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
import { useModelStore } from '@lib/store/modelStore';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
|
||||
const log = logger.namespace('Canvas');
|
||||
|
||||
// Lazy load heavy components
|
||||
const ImageCropper = dynamic(() => import('./controls/ImageCropper').then(mod => ({ default: mod.ImageCropper })), {
|
||||
loading: () => <LoadingState message="加载裁剪工具..." />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const SketchEditor = dynamic(() => import('./controls/SketchEditor').then(mod => ({ default: mod.SketchEditor })), {
|
||||
loading: () => <LoadingState message="加载绘图工具..." />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const ProjectDetailsModal = dynamic(() => import('./panels/management/project-details/index').then(mod => ({ default: mod.ProjectDetailsModal })), {
|
||||
loading: () => <LoadingState message="加载项目详情..." />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const ExpandedView = dynamic(() => import('./panels/ExpandedView').then(mod => ({ default: mod.ExpandedView })), {
|
||||
loading: () => <LoadingState message="加载预览..." />,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const RightSidebar = dynamic(() => import('./panels/docks/CanvasRightDock').then(mod => ({ default: mod.RightSidebar })), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const InteractionInput = dynamic(
|
||||
() => import('./InteractionInput').then(mod => ({ default: mod.InteractionInput })),
|
||||
{ ssr: false, loading: () => <LoadingState message="加载输入栏..." /> }
|
||||
);
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
appNode: AppNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
gradient: GradientEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
|
||||
// Memoized components to prevent unnecessary re-renders
|
||||
const MemoizedSidebar = memo(Sidebar);
|
||||
const MemoizedRightSidebar = memo(RightSidebar);
|
||||
const MemoizedCanvasContextMenu = memo(CanvasContextMenu);
|
||||
|
||||
function Flow() {
|
||||
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
|
||||
const [isQueuePanelOpen, setIsQueuePanelOpen] = useState(false);
|
||||
const [isBatchToolbarOpen, setIsBatchToolbarOpen] = useState(false);
|
||||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||
|
||||
// Ctrl+Q shortcut for queue panel, Ctrl+B for batch generation, Ctrl+T for template library
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'q') {
|
||||
e.preventDefault();
|
||||
setIsQueuePanelOpen(prev => !prev);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
setIsBatchToolbarOpen(prev => !prev);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 't') {
|
||||
e.preventDefault();
|
||||
setIsTemplateLibraryOpen(prev => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const isConnecting = useStore((state: any) => !!state.connectionNodeId);
|
||||
const {
|
||||
onClose,
|
||||
onUpdateAsset,
|
||||
onUpdateStoryboard,
|
||||
activeCanvasId,
|
||||
activeCanvasType,
|
||||
switchCanvas,
|
||||
isCanvasLoaded,
|
||||
currentCanvasMetadata
|
||||
} = useCanvasContext();
|
||||
|
||||
// Select from store
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const onNodesChange = useCanvasStore((state) => state.onNodesChange);
|
||||
const setNodes = useCanvasStore((state) => state.setNodes);
|
||||
|
||||
const edges = useCanvasStore((state) => state.edges);
|
||||
const onEdgesChange = useCanvasStore((state) => state.onEdgesChange);
|
||||
const setEdges = useCanvasStore((state) => state.setEdges);
|
||||
const onConnect = useCanvasStore((state) => state.onConnect);
|
||||
const takeSnapshot = useCanvasStore((state) => state.saveHistory);
|
||||
const undo = useCanvasStore((state) => state.undo);
|
||||
const redo = useCanvasStore((state) => state.redo);
|
||||
|
||||
// Groups
|
||||
const groups = useCanvasStore((state) => state.groups);
|
||||
const selectedGroupId = useCanvasStore((state) => state.selectedGroupId);
|
||||
const setSelectedGroupId = useCanvasStore((state) => state.setSelectedGroupId);
|
||||
const createGroupFromSelected = useCanvasStore((state) => state.createGroupFromSelected);
|
||||
const deleteGroup = useCanvasStore((state) => state.deleteGroup);
|
||||
const ungroup = useCanvasStore((state) => state.ungroup);
|
||||
const updateGroup = useCanvasStore((state) => state.updateGroup);
|
||||
|
||||
const { screenToFlowPosition, zoomIn, zoomOut, zoomTo, fitView } = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
|
||||
// Check model availability
|
||||
const { imageModels, videoModels, llmModels } = useModelStore();
|
||||
|
||||
// Warn if models are not loaded (only once on mount)
|
||||
useEffect(() => {
|
||||
const hasModels = Object.keys(imageModels).length > 0 ||
|
||||
Object.keys(videoModels).length > 0 ||
|
||||
Object.keys(llmModels).length > 0;
|
||||
|
||||
if (!hasModels) {
|
||||
log.warn('No models loaded yet. Node creation may use fallback defaults.');
|
||||
}
|
||||
}, [imageModels, videoModels, llmModels]);
|
||||
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
type: 'gradient',
|
||||
// animated: true,
|
||||
}), []);
|
||||
|
||||
const isSaving = useCanvasStore((state) => state.isSaving);
|
||||
const isDirty = useCanvasStore((state) => state.isDirty);
|
||||
const lastSavedAt = useCanvasStore((state) => state.lastSavedAt);
|
||||
|
||||
// Select from project store
|
||||
const assets = useProjectStore((state) => state.assets);
|
||||
const storyboards = useProjectStore((state) => state.storyboards);
|
||||
const episodes = useProjectStore((state) => state.episodes);
|
||||
const projectName = useProjectStore((state) => state.name);
|
||||
|
||||
// Memoize sorted storyboards for consistent navigation and shortcuts
|
||||
const sortedStoryboards = useMemo(() => {
|
||||
const episodeOrderMap = new Map(episodes.map(e => [e.id, e.order]));
|
||||
return [...storyboards].sort((a, b) => {
|
||||
const orderA = episodeOrderMap.get(a.episodeId) ?? Infinity;
|
||||
const orderB = episodeOrderMap.get(b.episodeId) ?? Infinity;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.order - b.order;
|
||||
});
|
||||
}, [storyboards, episodes]);
|
||||
|
||||
const deleteAsset = useProjectStore((state) => state.deleteAsset);
|
||||
|
||||
const [manualSaveRequested, setManualSaveRequested] = useState(false);
|
||||
const updateAsset = useProjectStore((state) => state.updateAsset);
|
||||
const projectId = useProjectStore((state) => state.id);
|
||||
|
||||
const { saveAsset } = useAssetSaver();
|
||||
|
||||
// Select from canvas store
|
||||
const generationHistory = useCanvasStore((state) => state.generationHistory);
|
||||
const setGenerationHistory = useCanvasStore((state) => state.setGenerationHistory);
|
||||
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
// --- Managers ---
|
||||
|
||||
// 1. History Manager (Generation History)
|
||||
const { loadHistory } = useGenerationHistory();
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
// 2. Persistence Manager
|
||||
const { saveImmediately, isSaving: isSavingMutation, saveError } = useCanvasPersistence({
|
||||
nodes,
|
||||
edges,
|
||||
activeCanvasId: activeCanvasId || null,
|
||||
activeCanvasType: activeCanvasType || 'main',
|
||||
isCanvasLoaded: !!isCanvasLoaded,
|
||||
canvasMetadata: currentCanvasMetadata
|
||||
});
|
||||
|
||||
// Context for Saving - memoized to prevent recreation
|
||||
const saveContext = useMemo(() => ({
|
||||
assetId: activeCanvasType === 'asset' ? (activeCanvasId || null) : null,
|
||||
storyboardId: activeCanvasType === 'storyboard' ? (activeCanvasId || null) : null
|
||||
}), [activeCanvasType, activeCanvasId]);
|
||||
|
||||
// 4. Interaction Manager
|
||||
const {
|
||||
handleAddNode,
|
||||
handleSketchResult,
|
||||
handleInteractionGenerate,
|
||||
handleCreateAndConnect: handleCreateAndConnectManager
|
||||
} = useCanvasInteraction({
|
||||
saveContext,
|
||||
setNodes,
|
||||
setEdges,
|
||||
saveAsset
|
||||
});
|
||||
|
||||
// 5. Auto Workflow Manager
|
||||
const {
|
||||
createNodesFromAsset,
|
||||
createWorkflowFromStoryboard,
|
||||
applyWorkflow,
|
||||
} = useAutoWorkflow();
|
||||
|
||||
|
||||
const { isValidConnection } = useSocketInteraction(nodes, setEdges);
|
||||
const { onDragOver, onDrop } = useCanvasDragDrop(setNodes);
|
||||
|
||||
// 画布加载后恢复进行中的异步任务轮询(音乐/音频/歌词等,与各节点写入 taskId 逻辑统一)
|
||||
useResumeTaskPolling();
|
||||
const { onNodeDrag } = useNodeSnapping(setNodes);
|
||||
|
||||
// Handle custom keyboard shortcut events
|
||||
useEffect(() => {
|
||||
const handleFitView = () => {
|
||||
fitView({
|
||||
duration: 300,
|
||||
padding: {
|
||||
top: 0.1,
|
||||
right: 0.15,
|
||||
bottom: 0.15,
|
||||
left: 0.12
|
||||
},
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
zoomIn({ duration: 200 });
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
zoomOut({ duration: 200 });
|
||||
};
|
||||
|
||||
window.addEventListener('canvas:fitView', handleFitView);
|
||||
window.addEventListener('canvas:zoomIn', handleZoomIn);
|
||||
window.addEventListener('canvas:zoomOut', handleZoomOut);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('canvas:fitView', handleFitView);
|
||||
window.removeEventListener('canvas:zoomIn', handleZoomIn);
|
||||
window.removeEventListener('canvas:zoomOut', handleZoomOut);
|
||||
};
|
||||
}, [fitView, zoomIn, zoomOut]);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
setManualSaveRequested(true);
|
||||
saveImmediately();
|
||||
}, [saveImmediately]);
|
||||
|
||||
const handleSwitchNext = useCallback(() => {
|
||||
if (!switchCanvas || !activeCanvasId) return;
|
||||
|
||||
if (activeCanvasType === 'asset') {
|
||||
const currentAsset = assets.find(a => a.id === activeCanvasId);
|
||||
if (!currentAsset) return;
|
||||
|
||||
// Filter assets by the same type as the current one to maintain context
|
||||
// and sort them by name for consistent navigation
|
||||
const sameTypeAssets = assets
|
||||
.filter(a => a.type === currentAsset.type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
|
||||
if (sameTypeAssets.length === 0) return;
|
||||
|
||||
const currentIndex = sameTypeAssets.findIndex(item => item.id === activeCanvasId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const nextIndex = (currentIndex + 1) % sameTypeAssets.length;
|
||||
switchCanvas(sameTypeAssets[nextIndex].id, 'asset');
|
||||
|
||||
} else if (activeCanvasType === 'storyboard') {
|
||||
// Use memoized sorted storyboards
|
||||
if (sortedStoryboards.length === 0) return;
|
||||
|
||||
const currentIndex = sortedStoryboards.findIndex(item => item.id === activeCanvasId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const nextIndex = (currentIndex + 1) % sortedStoryboards.length;
|
||||
switchCanvas(sortedStoryboards[nextIndex].id, 'storyboard');
|
||||
}
|
||||
}, [activeCanvasType, activeCanvasId, assets, sortedStoryboards, switchCanvas]);
|
||||
|
||||
const handleSwitchPrev = useCallback(() => {
|
||||
if (!switchCanvas || !activeCanvasId) return;
|
||||
|
||||
if (activeCanvasType === 'asset') {
|
||||
const currentAsset = assets.find(a => a.id === activeCanvasId);
|
||||
if (!currentAsset) return;
|
||||
|
||||
const sameTypeAssets = assets
|
||||
.filter(a => a.type === currentAsset.type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
|
||||
if (sameTypeAssets.length === 0) return;
|
||||
|
||||
const currentIndex = sameTypeAssets.findIndex(item => item.id === activeCanvasId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const prevIndex = (currentIndex - 1 + sameTypeAssets.length) % sameTypeAssets.length;
|
||||
switchCanvas(sameTypeAssets[prevIndex].id, 'asset');
|
||||
|
||||
} else if (activeCanvasType === 'storyboard') {
|
||||
// Use memoized sorted storyboards
|
||||
if (sortedStoryboards.length === 0) return;
|
||||
|
||||
const currentIndex = sortedStoryboards.findIndex(item => item.id === activeCanvasId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const prevIndex = (currentIndex - 1 + sortedStoryboards.length) % sortedStoryboards.length;
|
||||
switchCanvas(sortedStoryboards[prevIndex].id, 'storyboard');
|
||||
}
|
||||
}, [activeCanvasType, activeCanvasId, assets, sortedStoryboards, switchCanvas]);
|
||||
|
||||
const { setClipboard, clipboard, pasteNodes } = useCanvasHotkeys({
|
||||
undo,
|
||||
redo,
|
||||
saveHistory: takeSnapshot,
|
||||
save: handleManualSave,
|
||||
switchNext: handleSwitchNext,
|
||||
switchPrev: handleSwitchPrev,
|
||||
switchCanvas,
|
||||
assets,
|
||||
storyboards: sortedStoryboards,
|
||||
createGroup: createGroupFromSelected,
|
||||
selectedNodeIds: useCanvasStore((state) => state.selectedNodeIds)
|
||||
});
|
||||
|
||||
const onConnectWrapper = useCallback((params: Connection) => {
|
||||
takeSnapshot();
|
||||
onConnect(params);
|
||||
}, [onConnect, takeSnapshot]);
|
||||
|
||||
const onNodeDragStart = useCallback(() => {
|
||||
takeSnapshot();
|
||||
}, [takeSnapshot]);
|
||||
|
||||
// Auto-generate workflow when canvas is loaded but empty
|
||||
useEffect(() => {
|
||||
if (!isCanvasLoaded) return;
|
||||
|
||||
// If canvas already has content, don't auto-generate
|
||||
if (nodes.length > 0) return;
|
||||
|
||||
if (activeCanvasType === 'asset' && activeCanvasId) {
|
||||
const asset = assets.find(a => a.id === activeCanvasId);
|
||||
if (asset) {
|
||||
log.log('Auto-generating workflow for asset:', asset.name);
|
||||
const { nodes: workflowNodes, edges: workflowEdges } = createNodesFromAsset(
|
||||
asset,
|
||||
{ x: 100, y: 100 }
|
||||
);
|
||||
applyWorkflow(workflowNodes, workflowEdges);
|
||||
|
||||
// Auto-fit view with safe area padding (avoid docks and input)
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
duration: 300,
|
||||
padding: {
|
||||
top: 0.1, // 10% top padding
|
||||
right: 0.15, // 15% right padding (avoid right dock)
|
||||
bottom: 0.15, // 15% bottom padding (avoid input box)
|
||||
left: 0.12 // 12% left padding (avoid left dock)
|
||||
},
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
} else if (activeCanvasType === 'storyboard' && activeCanvasId) {
|
||||
const storyboard = storyboards.find(s => s.id === activeCanvasId);
|
||||
if (storyboard) {
|
||||
log.log('Auto-generating workflow for storyboard:', storyboard.shot);
|
||||
const { nodes: workflowNodes, edges: workflowEdges } = createWorkflowFromStoryboard(
|
||||
storyboard,
|
||||
assets,
|
||||
{ x: 100, y: 100 }
|
||||
);
|
||||
applyWorkflow(workflowNodes, workflowEdges);
|
||||
|
||||
// Auto-fit view with safe area padding (avoid docks and input)
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
duration: 300,
|
||||
padding: {
|
||||
top: 0.1, // 10% top padding
|
||||
right: 0.15, // 15% right padding (avoid right dock)
|
||||
bottom: 0.15, // 15% bottom padding (avoid input box)
|
||||
left: 0.12 // 12% left padding (avoid left dock)
|
||||
},
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}, [isCanvasLoaded, nodes.length, activeCanvasType, activeCanvasId, assets, storyboards, createNodesFromAsset, createWorkflowFromStoryboard, applyWorkflow, fitView]);
|
||||
|
||||
const sidebarSourceContext = useMemo(() => {
|
||||
if (activeCanvasType === 'asset' && activeCanvasId) return { id: activeCanvasId, type: 'asset' as const };
|
||||
if (activeCanvasType === 'storyboard' && activeCanvasId) return { id: activeCanvasId, type: 'storyboard' as const };
|
||||
return null;
|
||||
}, [activeCanvasType, activeCanvasId]);
|
||||
|
||||
// Context Menu
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [contextMenuTarget, setContextMenuTarget] = useState<ContextMenuTarget | null>(null);
|
||||
|
||||
// Replacement Input Refs
|
||||
const replaceVideoInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const replaceImageInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const replacementTargetRef = React.useRef<string | null>(null);
|
||||
|
||||
// --- Missing State from Canvas.tsx ---
|
||||
const {
|
||||
cropRequest,
|
||||
setCropRequest,
|
||||
handleClick,
|
||||
setHandleClick,
|
||||
expandedMedia
|
||||
} = useCanvasEvents();
|
||||
|
||||
// Sync handleClick to Context Menu
|
||||
useEffect(() => {
|
||||
if (handleClick) {
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: handleClick.x,
|
||||
y: handleClick.y,
|
||||
id: handleClick.nodeId
|
||||
});
|
||||
setContextMenuTarget({ type: 'handle-create', nodeId: handleClick.nodeId });
|
||||
setHandleClick(null);
|
||||
}
|
||||
}, [handleClick, setHandleClick]);
|
||||
|
||||
const [isSketchEditorOpen, setIsSketchEditorOpen] = useState(false);
|
||||
const [isProjectDetailsOpen, setIsProjectDetailsOpen] = useState(false);
|
||||
|
||||
// --- Dock Interaction ---
|
||||
const [dockConnectionStart, setDockConnectionStart] = useState<{ x: number, y: number } | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleReplaceFile = useCallback((e: React.ChangeEvent<HTMLInputElement>, type: 'image' | 'video') => {
|
||||
takeSnapshot();
|
||||
const file = e.target.files?.[0];
|
||||
const targetId = replacementTargetRef.current;
|
||||
if (file && targetId) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
if (type === 'image') {
|
||||
setNodes((nds) => nds.map(n => (n.id === targetId && n.type === 'appNode') ? { ...n, data: { ...n.data, image: result } } : n));
|
||||
} else {
|
||||
setNodes((nds) => nds.map(n => (n.id === targetId && n.type === 'appNode') ? { ...n, data: { ...n.data, videoUri: result } } : n));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
setContextMenu(null);
|
||||
replacementTargetRef.current = null;
|
||||
}, [setNodes, takeSnapshot]);
|
||||
|
||||
const handleCreateAndConnect = useCallback((type: NodeType, data?: Partial<AppNodeData>) => {
|
||||
handleCreateAndConnectManager(type, contextMenuTarget?.nodeId, nodes, edges, takeSnapshot, data);
|
||||
setContextMenu(null);
|
||||
}, [handleCreateAndConnectManager, contextMenuTarget, nodes, edges, takeSnapshot]);
|
||||
|
||||
const onNodeContextMenu = useCallback(
|
||||
(event: React.MouseEvent, node: AppNodeType) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id: node.id,
|
||||
});
|
||||
setContextMenuTarget({ type: 'node', id: node.id });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onEdgeContextMenu = useCallback(
|
||||
(event: React.MouseEvent, edge: Edge) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id: edge.id,
|
||||
});
|
||||
setContextMenuTarget({ type: 'connection', id: edge.id });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onPaneContextMenu = useCallback(
|
||||
(event: React.MouseEvent | MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
setContextMenuTarget({ type: 'pane' });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => setContextMenu(null), []);
|
||||
|
||||
const handleCopyNode = useCallback((nodeId: string) => {
|
||||
const targetNode = nodes.find(n => n.id === nodeId);
|
||||
if (targetNode && targetNode.type === 'appNode') {
|
||||
setClipboard(targetNode);
|
||||
}
|
||||
}, [nodes, setClipboard]);
|
||||
|
||||
const handleReplaceMedia = useCallback((nodeId: string, mediaType: 'image' | 'video') => {
|
||||
replacementTargetRef.current = nodeId;
|
||||
if (mediaType === 'video') replaceVideoInputRef.current?.click();
|
||||
else replaceImageInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleDeleteNode = useCallback((nodeId: string) => {
|
||||
takeSnapshot();
|
||||
setNodes((nds) => nds.filter(n => n.id !== nodeId));
|
||||
}, [setNodes, takeSnapshot]);
|
||||
|
||||
const handleDeleteEdge = useCallback((edgeId: string) => {
|
||||
takeSnapshot();
|
||||
setEdges((eds) => eds.filter(e => e.id !== edgeId));
|
||||
}, [setEdges, takeSnapshot]);
|
||||
|
||||
const handleAddToLibrary = useCallback((item: any) => {
|
||||
const type = item.type.includes('video') ? 'video' : 'image';
|
||||
// Prefer item.context (original generation context) over current context
|
||||
const targetContext = item.context || saveContext;
|
||||
saveAsset(type, item.src, item.title || 'Saved Asset', { prompt: item.title }, targetContext);
|
||||
}, [saveAsset, saveContext]);
|
||||
|
||||
// --- Double Click Handler ---
|
||||
const onPaneDoubleClick = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
// Only trigger if clicking on the pane itself, not on nodes
|
||||
if ((event.target as HTMLElement).classList.contains('react-flow__pane')) {
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id: 'create-menu',
|
||||
});
|
||||
setContextMenuTarget({ type: 'create' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoized handlers for sidebar
|
||||
const handleHistoryItemClick = useCallback((item: any) => {
|
||||
const type = item.type.includes('image') ? NodeType.IMAGE_GENERATOR : NodeType.VIDEO_GENERATOR;
|
||||
const data = item.type.includes('image') ? { image: item.src } : { videoUri: item.src };
|
||||
const pos = screenToFlowPosition({ x: window.innerWidth/2 - 200, y: window.innerHeight/2 - 150 });
|
||||
handleAddNode(type, pos, data);
|
||||
}, [screenToFlowPosition, handleAddNode]);
|
||||
|
||||
const handleDeleteHistory = useCallback((id: string) => {
|
||||
setGenerationHistory(prev => prev.filter(i => i.id !== id));
|
||||
}, [setGenerationHistory]);
|
||||
|
||||
const handleRenameAsset = useCallback((id: string, name: string) => {
|
||||
updateAsset(id, { name });
|
||||
}, [updateAsset]);
|
||||
|
||||
const handleClearCanvas = useCallback(() => {
|
||||
confirm({
|
||||
title: '确认清空画布?',
|
||||
description: '确定要清空画布吗?此操作无法撤销。',
|
||||
variant: 'destructive',
|
||||
onConfirm: () => {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}
|
||||
});
|
||||
}, [confirm, setNodes, setEdges]);
|
||||
|
||||
const handleUndo = useCallback(() => undo(), [undo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog />
|
||||
<div
|
||||
className={`w-full h-full overflow-hidden bg-background relative ${isConnecting ? 'is-connecting' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseUp={() => setDockConnectionStart(null)}
|
||||
>
|
||||
<div className="absolute inset-0 noise-bg pointer-events-none z-0" />
|
||||
|
||||
{dockConnectionStart && (
|
||||
<svg className="absolute inset-0 pointer-events-none z-[2000] overflow-visible" style={{ width: '100%', height: '100%' }}>
|
||||
<line
|
||||
x1={dockConnectionStart.x} y1={dockConnectionStart.y}
|
||||
x2={mousePos.x} y2={mousePos.y}
|
||||
stroke="#22d3ee" strokeWidth="2" strokeDasharray="5,5"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
<circle cx={dockConnectionStart.x} cy={dockConnectionStart.y} r="4" fill="#22d3ee" />
|
||||
<circle cx={mousePos.x} cy={mousePos.y} r="4" fill="#22d3ee" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<MemoizedSidebar
|
||||
onAddNode={(type, data) => handleAddNode(type, undefined, data)}
|
||||
onUndo={handleUndo}
|
||||
assetHistory={generationHistory}
|
||||
projectAssets={assets}
|
||||
projectStoryboards={storyboards}
|
||||
projectEpisodes={episodes}
|
||||
onHistoryItemClick={handleHistoryItemClick}
|
||||
onDeleteAsset={handleDeleteHistory}
|
||||
onDeleteProjectAsset={deleteAsset}
|
||||
onRenameAsset={handleRenameAsset}
|
||||
onAddToLibrary={handleAddToLibrary}
|
||||
sourceContext={sidebarSourceContext}
|
||||
onOpenProjectDetails={() => setIsProjectDetailsOpen(true)}
|
||||
onClear={handleClearCanvas}
|
||||
/>
|
||||
|
||||
<SaveFeedback
|
||||
isSaving={isSaving}
|
||||
lastSavedAt={lastSavedAt || undefined}
|
||||
error={saveError}
|
||||
trigger={manualSaveRequested}
|
||||
onComplete={() => setManualSaveRequested(false)}
|
||||
/>
|
||||
|
||||
<CanvasHeader
|
||||
title={projectName || '未命名项目'}
|
||||
rightContent={
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar />
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-muted-foreground hover:text-white bg-black/20 hover:bg-black/40 backdrop-blur-sm border border-white/5 rounded-full transition-all"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<input type="file" ref={replaceVideoInputRef} className="hidden" accept="video/*" onChange={(e) => handleReplaceFile(e, 'video')} />
|
||||
<input type="file" ref={replaceImageInputRef} className="hidden" accept="image/*" onChange={(e) => handleReplaceFile(e, 'image')} />
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes.filter((n) => n.type === 'appNode')}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnectWrapper}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onPaneClick={onPaneClick}
|
||||
onDoubleClick={onPaneDoubleClick}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.75 }}
|
||||
className="bg-black"
|
||||
colorMode="dark"
|
||||
minZoom={0.1}
|
||||
maxZoom={4}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
connectionLineType={ConnectionLineType.Bezier}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
connectionLineStyle={{ stroke: 'rgba(255,255,255,0.8)', strokeWidth: 2 }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={32}
|
||||
size={1}
|
||||
color="#aaa"
|
||||
className="opacity-[0.06]"
|
||||
/>
|
||||
{isMiniMapOpen && (
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
style={{ width: 240, height: 140 }}
|
||||
className="!left-6 !bottom-20 !m-0 bg-black/40 backdrop-blur-md border border-white/10 rounded-xl overflow-hidden shadow-xl z-50 animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
nodeColor={() => '#3b82f6'}
|
||||
maskColor="rgba(0, 0, 0, 0.6)"
|
||||
nodeStrokeColor="transparent"
|
||||
nodeBorderRadius={4}
|
||||
zoomable
|
||||
pannable
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
|
||||
{/* Render Groups */}
|
||||
{groups.map((group) => {
|
||||
// Get nodes in this group
|
||||
const groupNodes = nodes.filter(n => (n as any).parentId === group.id);
|
||||
|
||||
return (
|
||||
<GroupNode
|
||||
key={group.id}
|
||||
group={group}
|
||||
isSelected={selectedGroupId === group.id}
|
||||
onSelect={() => setSelectedGroupId(group.id)}
|
||||
onUpdate={(updates) => updateGroup(group.id, updates)}
|
||||
onDelete={() => deleteGroup(group.id)}
|
||||
onUngroup={() => ungroup(group.id)}
|
||||
onContextMenu={(e) => {
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
id: group.id
|
||||
});
|
||||
setContextMenuTarget({ type: 'group', id: group.id });
|
||||
}}
|
||||
onDragStart={() => {
|
||||
takeSnapshot();
|
||||
}}
|
||||
onDrag={(dx, dy) => {
|
||||
// Move all nodes in the group
|
||||
setNodes((prevNodes) =>
|
||||
prevNodes.map(node => {
|
||||
if ((node as any).parentId === group.id) {
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: node.position.x + dx,
|
||||
y: node.position.y + dy
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
// Update group position
|
||||
updateGroup(group.id, {
|
||||
x: group.x + dx,
|
||||
y: group.y + dy
|
||||
});
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// Group position already updated during drag
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CanvasViewportControls
|
||||
isMiniMapOpen={isMiniMapOpen}
|
||||
onToggleMiniMap={() => setIsMiniMapOpen(!isMiniMapOpen)}
|
||||
onToggleQueuePanel={() => setIsQueuePanelOpen(!isQueuePanelOpen)}
|
||||
isQueuePanelOpen={isQueuePanelOpen}
|
||||
/>
|
||||
<CanvasShortcuts />
|
||||
|
||||
{contextMenu && contextMenuTarget && (
|
||||
<MemoizedCanvasContextMenu
|
||||
contextMenu={contextMenu}
|
||||
target={contextMenuTarget}
|
||||
nodes={nodes}
|
||||
clipboard={clipboard}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onCreateNode={(type, data) => handleAddNode(type, screenToFlowPosition({ x: contextMenu.x, y: contextMenu.y }), data)}
|
||||
onCreateAndConnect={handleCreateAndConnect}
|
||||
onCopyNode={handleCopyNode}
|
||||
onReplaceMedia={handleReplaceMedia}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onPaste={() => pasteNodes({ x: contextMenu.x, y: contextMenu.y })}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
onCreateGroup={() => createGroupFromSelected()}
|
||||
onUngroup={ungroup}
|
||||
onDeleteGroup={deleteGroup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlays */}
|
||||
{cropRequest && <ImageCropper />}
|
||||
{expandedMedia && <ExpandedView />}
|
||||
{isSketchEditorOpen && <SketchEditor onClose={() => setIsSketchEditorOpen(false)} onGenerate={handleSketchResult} />}
|
||||
{isProjectDetailsOpen && <ProjectDetailsModal isOpen={isProjectDetailsOpen} onClose={() => setIsProjectDetailsOpen(false)} />}
|
||||
|
||||
<MemoizedRightSidebar
|
||||
activeCanvasId={activeCanvasId || null}
|
||||
activeCanvasType={activeCanvasType || 'main'}
|
||||
onSwitchCanvas={switchCanvas || (async () => {})}
|
||||
/>
|
||||
|
||||
{/* Interaction Input */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50">
|
||||
<InteractionInput
|
||||
onGenerate={handleInteractionGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Tour */}
|
||||
<OnboardingTour />
|
||||
|
||||
{/* Batch Generation Toolbar */}
|
||||
<AnimatePresence>
|
||||
{isBatchToolbarOpen && (
|
||||
<BatchGenerationToolbar
|
||||
onClose={() => setIsBatchToolbarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Generation Queue Panel */}
|
||||
<GenerationQueuePanel
|
||||
isOpen={isQueuePanelOpen}
|
||||
onClose={() => setIsQueuePanelOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Prompt Template Library */}
|
||||
<PromptTemplateLibrary
|
||||
isOpen={isTemplateLibraryOpen}
|
||||
onClose={() => setIsTemplateLibraryOpen(false)}
|
||||
onSelectTemplate={(template) => {
|
||||
// Handle template selection - copy to clipboard for now
|
||||
navigator.clipboard.writeText(template.content);
|
||||
// Could also apply directly to selected node
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Canvas(props: CanvasContextType) {
|
||||
return (
|
||||
<CanvasProviders {...props}>
|
||||
<Flow />
|
||||
</CanvasProviders>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/canvas/CanvasContext.tsx
Normal file
27
frontend/src/components/canvas/CanvasContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { Asset, Storyboard, CanvasMetadata } from '@/types';
|
||||
|
||||
export interface CanvasContextType {
|
||||
initialAssetId?: string;
|
||||
initialStoryboardId?: string;
|
||||
onClose?: () => void;
|
||||
onUpdateAsset?: (id: string, updates: Partial<Asset>) => void;
|
||||
onUpdateStoryboard?: (id: string, updates: Partial<Storyboard>) => void;
|
||||
|
||||
// Canvas Switching & Persistence
|
||||
activeCanvasId?: string | null;
|
||||
activeCanvasType?: 'main' | 'asset' | 'storyboard';
|
||||
switchCanvas?: (id: string | null, type: 'main' | 'asset' | 'storyboard') => Promise<void>;
|
||||
isCanvasLoaded?: boolean;
|
||||
currentCanvasMetadata?: CanvasMetadata | null;
|
||||
}
|
||||
|
||||
export const CanvasContext = createContext<CanvasContextType>({});
|
||||
|
||||
export const useCanvasContext = () => useContext(CanvasContext);
|
||||
|
||||
export interface CanvasActionContextType {
|
||||
addHistoryItem: (item: any) => void;
|
||||
}
|
||||
export const CanvasActionContext = createContext<CanvasActionContextType>({ addHistoryItem: () => {} });
|
||||
export const useCanvasActionContext = () => useContext(CanvasActionContext);
|
||||
40
frontend/src/components/canvas/CanvasHeader.tsx
Normal file
40
frontend/src/components/canvas/CanvasHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Film } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
title?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
rightContent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CanvasHeader = ({ title = 'Pixel', children, rightContent, className }: CanvasHeaderProps) => {
|
||||
return (
|
||||
<header className={cn(
|
||||
"absolute top-0 left-0 right-0 h-16 z-50 flex items-center justify-between px-6 pointer-events-none",
|
||||
className
|
||||
)}>
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center gap-3 pointer-events-auto min-w-[200px]">
|
||||
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
<div className="w-9 h-9 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-900/20 ring-1 ring-white/10">
|
||||
<Film className="text-white w-5 h-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white tracking-wide drop-shadow-md font-sans">{title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Children (Save Status) - Absolutely Centered */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Right: Content (Close Button) */}
|
||||
<div className="flex items-center justify-end min-w-[200px] pointer-events-auto">
|
||||
{rightContent}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
90
frontend/src/components/canvas/CanvasManager.tsx
Normal file
90
frontend/src/components/canvas/CanvasManager.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { useCanvasStore } from '@/lib/store/canvasStore';
|
||||
import { useProjectStore } from '@/lib/store/project';
|
||||
import { CanvasContext, CanvasContextType } from './CanvasContext';
|
||||
import { useCanvasInitialization } from './managers/useCanvasInitialization';
|
||||
import { getStorageKey } from './managers/persistenceUtils';
|
||||
import { saveCanvasStateToStorage } from './services/storage';
|
||||
|
||||
interface CanvasManagerProps extends CanvasContextType {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CanvasManager({
|
||||
children,
|
||||
initialAssetId,
|
||||
initialStoryboardId,
|
||||
onClose,
|
||||
onUpdateAsset,
|
||||
onUpdateStoryboard
|
||||
}: CanvasManagerProps) {
|
||||
const projectId = useProjectStore((s) => s.id);
|
||||
const setNodes = useCanvasStore((state) => state.setNodes);
|
||||
const setEdges = useCanvasStore((state) => state.setEdges);
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const edges = useCanvasStore((state) => state.edges);
|
||||
|
||||
const getStorageKeyWithProject = useCallback(
|
||||
(id: string | null, type: string) => getStorageKey(id, type, projectId),
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const {
|
||||
activeCanvasId, setActiveCanvasId,
|
||||
activeCanvasType, setActiveCanvasType,
|
||||
isCanvasLoaded, setIsCanvasLoaded,
|
||||
currentCanvasMetadata
|
||||
} = useCanvasInitialization({
|
||||
setNodes,
|
||||
setEdges,
|
||||
getStorageKey: getStorageKeyWithProject
|
||||
});
|
||||
|
||||
const handleSwitchCanvas = useCallback(async (id: string | null, type: 'main' | 'asset' | 'storyboard') => {
|
||||
// 1. Save Current
|
||||
try {
|
||||
const currentKey = getStorageKey(activeCanvasId, activeCanvasType, projectId);
|
||||
await saveCanvasStateToStorage(currentKey, { nodes, edges }, projectId || undefined);
|
||||
} catch (error) {
|
||||
console.error('[Canvas] Failed to save before switch:', error);
|
||||
}
|
||||
|
||||
// 2. Set New State & Trigger Re-init
|
||||
setActiveCanvasId(id);
|
||||
setActiveCanvasType(type);
|
||||
setIsCanvasLoaded(false);
|
||||
}, [activeCanvasId, activeCanvasType, nodes, edges, projectId, setActiveCanvasId, setActiveCanvasType, setIsCanvasLoaded]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
initialAssetId,
|
||||
initialStoryboardId,
|
||||
onClose,
|
||||
onUpdateAsset,
|
||||
onUpdateStoryboard,
|
||||
activeCanvasId,
|
||||
activeCanvasType,
|
||||
switchCanvas: handleSwitchCanvas,
|
||||
isCanvasLoaded,
|
||||
currentCanvasMetadata
|
||||
}), [
|
||||
initialAssetId,
|
||||
initialStoryboardId,
|
||||
onClose,
|
||||
onUpdateAsset,
|
||||
onUpdateStoryboard,
|
||||
activeCanvasId,
|
||||
activeCanvasType,
|
||||
handleSwitchCanvas,
|
||||
isCanvasLoaded,
|
||||
currentCanvasMetadata
|
||||
]);
|
||||
|
||||
return (
|
||||
<CanvasContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CanvasContext.Provider>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/canvas/CanvasProviders.tsx
Normal file
32
frontend/src/components/canvas/CanvasProviders.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import { CanvasContext, CanvasActionContext, CanvasContextType } from './CanvasContext';
|
||||
import { useCanvasStore } from '@/lib/store/canvasStore';
|
||||
import { CanvasManager } from './CanvasManager';
|
||||
|
||||
interface CanvasProvidersProps extends CanvasContextType {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CanvasProviders({
|
||||
children,
|
||||
...props
|
||||
}: CanvasProvidersProps) {
|
||||
const addGenerationHistoryItem = useCanvasStore((state) => state.addGenerationHistoryItem);
|
||||
|
||||
const actionValue = useMemo(() => ({
|
||||
addHistoryItem: addGenerationHistoryItem
|
||||
}), [addGenerationHistoryItem]);
|
||||
|
||||
return (
|
||||
<CanvasActionContext.Provider value={actionValue}>
|
||||
<ReactFlowProvider>
|
||||
<CanvasManager {...props}>
|
||||
{children}
|
||||
</CanvasManager>
|
||||
</ReactFlowProvider>
|
||||
</CanvasActionContext.Provider>
|
||||
);
|
||||
}
|
||||
621
frontend/src/components/canvas/ConflictResolver.tsx
Normal file
621
frontend/src/components/canvas/ConflictResolver.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* Conflict Resolver Component
|
||||
*
|
||||
* This component handles conflicts that occur when syncing offline changes
|
||||
* with server data. It provides a UI for users to resolve conflicts by:
|
||||
* - Viewing local vs server versions side-by-side
|
||||
* - Choosing to keep local version
|
||||
* - Choosing to use server version
|
||||
* - Manually merging changes
|
||||
*
|
||||
* Requirements: REQ-5.5.4
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
GitMerge,
|
||||
Clock,
|
||||
User,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
/**
|
||||
* Conflict data structure
|
||||
*/
|
||||
export interface ConflictData {
|
||||
/** Unique identifier for the conflict */
|
||||
id: string;
|
||||
|
||||
/** Resource type that has a conflict */
|
||||
resourceType: 'canvas' | 'canvas-metadata' | 'asset' | 'storyboard';
|
||||
|
||||
/** Resource ID */
|
||||
resourceId: string;
|
||||
|
||||
/** Resource name for display */
|
||||
resourceName: string;
|
||||
|
||||
/** Local version of the data */
|
||||
localVersion: any;
|
||||
|
||||
/** Server version of the data */
|
||||
serverVersion: any;
|
||||
|
||||
/** Timestamp of local version */
|
||||
localTimestamp: number;
|
||||
|
||||
/** Timestamp of server version */
|
||||
serverTimestamp: number;
|
||||
|
||||
/** Fields that have conflicts */
|
||||
conflictingFields?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution choice
|
||||
*/
|
||||
export type ResolutionChoice = 'local' | 'server' | 'merged';
|
||||
|
||||
/**
|
||||
* Conflict resolver props
|
||||
*/
|
||||
export interface ConflictResolverProps {
|
||||
/** Conflict data to resolve */
|
||||
conflict: ConflictData | null;
|
||||
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
|
||||
/** Callback when dialog should close */
|
||||
onClose: () => void;
|
||||
|
||||
/** Callback when conflict is resolved */
|
||||
onResolve: (conflictId: string, choice: ResolutionChoice, mergedData?: any) => void;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render data comparison
|
||||
*/
|
||||
function DataComparison({
|
||||
label,
|
||||
localData,
|
||||
serverData,
|
||||
conflictingFields
|
||||
}: {
|
||||
label: string;
|
||||
localData: any;
|
||||
serverData: any;
|
||||
conflictingFields?: string[];
|
||||
}) {
|
||||
const renderValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const isConflicting = (field: string) => {
|
||||
return conflictingFields?.includes(field) ?? false;
|
||||
};
|
||||
|
||||
// Get all unique keys from both versions
|
||||
const allKeys = new Set([
|
||||
...Object.keys(localData || {}),
|
||||
...Object.keys(serverData || {})
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{label}</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Local Version */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Your Version</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[300px] rounded-md border bg-muted/50 p-3">
|
||||
<div className="space-y-2 text-xs font-mono">
|
||||
{Array.from(allKeys).map((key) => (
|
||||
<div key={key} className={cn(
|
||||
'rounded p-1',
|
||||
isConflicting(key) && 'bg-orange-100 dark:bg-orange-950/30'
|
||||
)}>
|
||||
<div className="font-semibold text-foreground">{key}:</div>
|
||||
<div className="text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{renderValue(localData?.[key])}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Server Version */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Server Version</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[300px] rounded-md border bg-muted/50 p-3">
|
||||
<div className="space-y-2 text-xs font-mono">
|
||||
{Array.from(allKeys).map((key) => (
|
||||
<div key={key} className={cn(
|
||||
'rounded p-1',
|
||||
isConflicting(key) && 'bg-orange-100 dark:bg-orange-950/30'
|
||||
)}>
|
||||
<div className="font-semibold text-foreground">{key}:</div>
|
||||
<div className="text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{renderValue(serverData?.[key])}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict Resolver Component
|
||||
*
|
||||
* Displays a dialog for resolving sync conflicts between local and server data.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [conflict, setConflict] = useState<ConflictData | null>(null);
|
||||
*
|
||||
* <ConflictResolver
|
||||
* conflict={conflict}
|
||||
* open={!!conflict}
|
||||
* onClose={() => setConflict(null)}
|
||||
* onResolve={(id, choice, mergedData) => {
|
||||
* // Handle resolution
|
||||
* console.log(`Resolved ${id} with choice: ${choice}`);
|
||||
* setConflict(null);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ConflictResolver({
|
||||
conflict,
|
||||
open,
|
||||
onClose,
|
||||
onResolve,
|
||||
className
|
||||
}: ConflictResolverProps) {
|
||||
const [selectedChoice, setSelectedChoice] = useState<ResolutionChoice | null>(null);
|
||||
const [mergedData, setMergedData] = useState<any>(null);
|
||||
const [fieldSelections, setFieldSelections] = useState<Map<string, 'local' | 'server'>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (conflict) {
|
||||
setFieldSelections(new Map());
|
||||
setSelectedChoice(null);
|
||||
setMergedData(null);
|
||||
}
|
||||
}, [conflict]);
|
||||
|
||||
if (!conflict) return null;
|
||||
|
||||
// Build merged data from field selections
|
||||
const buildMergedData = () => {
|
||||
const merged: any = {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(conflict.localVersion || {}),
|
||||
...Object.keys(conflict.serverVersion || {})
|
||||
]);
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const selection = fieldSelections.get(key);
|
||||
if (selection === 'local') {
|
||||
merged[key] = conflict.localVersion?.[key];
|
||||
} else if (selection === 'server') {
|
||||
merged[key] = conflict.serverVersion?.[key];
|
||||
} else {
|
||||
// Default to local if no selection
|
||||
merged[key] = conflict.localVersion?.[key];
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const handleResolve = () => {
|
||||
if (!selectedChoice) return;
|
||||
|
||||
let dataToResolve = mergedData;
|
||||
|
||||
// If manual merge, build the merged data from field selections
|
||||
if (selectedChoice === 'merged') {
|
||||
dataToResolve = buildMergedData();
|
||||
}
|
||||
|
||||
onResolve(conflict.id, selectedChoice, dataToResolve);
|
||||
setSelectedChoice(null);
|
||||
setMergedData(null);
|
||||
setFieldSelections(new Map());
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedChoice(null);
|
||||
setMergedData(null);
|
||||
setFieldSelections(new Map());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFieldSelection = (field: string, version: 'local' | 'server') => {
|
||||
setFieldSelections((prev) => {
|
||||
const newSelections = new Map(prev);
|
||||
newSelections.set(field, version);
|
||||
return newSelections;
|
||||
});
|
||||
};
|
||||
|
||||
const getFieldSelection = (field: string): 'local' | 'server' | null => {
|
||||
return fieldSelections.get(field) || null;
|
||||
};
|
||||
|
||||
// Check if all fields have been selected for manual merge
|
||||
const allFieldsSelected = () => {
|
||||
if (selectedChoice !== 'merged') return true;
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(conflict.localVersion || {}),
|
||||
...Object.keys(conflict.serverVersion || {})
|
||||
]);
|
||||
|
||||
return Array.from(allKeys).every((key) => fieldSelections.has(key));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleCancel()}>
|
||||
<DialogContent className={cn('max-w-4xl max-h-[90vh]', className)}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
<DialogTitle>Sync Conflict Detected</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Changes were made to <strong>{conflict.resourceName}</strong> both locally and on the server.
|
||||
Please choose how to resolve this conflict.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Conflict Info */}
|
||||
<div className="flex items-center gap-4 rounded-lg border bg-muted/50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{conflict.resourceType}</Badge>
|
||||
<span className="font-medium">{conflict.resourceName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Local: {formatTimestamp(conflict.localTimestamp)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Server: {formatTimestamp(conflict.serverTimestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflicting Fields */}
|
||||
{conflict.conflictingFields && conflict.conflictingFields.length > 0 && (
|
||||
<div className="rounded-lg border bg-orange-50 dark:bg-orange-950/20 p-3">
|
||||
<p className="text-sm font-medium text-orange-900 dark:text-orange-100">
|
||||
Conflicting fields:
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{conflict.conflictingFields.map((field) => (
|
||||
<Badge key={field} variant="secondary" className="text-xs">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolution Options */}
|
||||
<Tabs
|
||||
defaultValue="compare"
|
||||
className="w-full"
|
||||
onValueChange={(value) => {
|
||||
if (value === 'merged') {
|
||||
setSelectedChoice('merged');
|
||||
} else {
|
||||
// Reset merge selection when switching away
|
||||
if (selectedChoice === 'merged') {
|
||||
setSelectedChoice(null);
|
||||
setFieldSelections(new Map());
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="compare">Compare Versions</TabsTrigger>
|
||||
<TabsTrigger value="merge">
|
||||
Manual Merge
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="compare" className="space-y-4">
|
||||
<DataComparison
|
||||
label="Data Comparison"
|
||||
localData={conflict.localVersion}
|
||||
serverData={conflict.serverVersion}
|
||||
conflictingFields={conflict.conflictingFields}
|
||||
/>
|
||||
|
||||
{/* Choice Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={selectedChoice === 'local' ? 'default' : 'outline'}
|
||||
className="h-auto flex-col items-start gap-2 p-4"
|
||||
onClick={() => setSelectedChoice('local')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span className="font-semibold">Keep My Version</span>
|
||||
</div>
|
||||
<p className="text-xs text-left text-muted-foreground">
|
||||
Discard server changes and keep your local changes
|
||||
</p>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedChoice === 'server' ? 'default' : 'outline'}
|
||||
className="h-auto flex-col items-start gap-2 p-4"
|
||||
onClick={() => setSelectedChoice('server')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="font-semibold">Use Server Version</span>
|
||||
</div>
|
||||
<p className="text-xs text-left text-muted-foreground">
|
||||
Discard your local changes and use the server version
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="merge" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<GitMerge className="h-4 w-4" />
|
||||
<span>Select which version to keep for each field</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px] rounded-md border">
|
||||
<div className="p-4 space-y-3">
|
||||
{(() => {
|
||||
const allKeys = new Set([
|
||||
...Object.keys(conflict.localVersion || {}),
|
||||
...Object.keys(conflict.serverVersion || {})
|
||||
]);
|
||||
|
||||
return Array.from(allKeys).map((key) => {
|
||||
const localValue = conflict.localVersion?.[key];
|
||||
const serverValue = conflict.serverVersion?.[key];
|
||||
const isConflicting = conflict.conflictingFields?.includes(key);
|
||||
const selection = getFieldSelection(key);
|
||||
|
||||
const renderValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'rounded-lg border p-3 space-y-2',
|
||||
isConflicting && 'border-orange-300 bg-orange-50 dark:bg-orange-950/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{key}</span>
|
||||
{isConflicting && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Conflict
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selection && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selection === 'local' ? 'Using Local' : 'Using Server'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Local Version */}
|
||||
<Button
|
||||
variant={selection === 'local' ? 'default' : 'outline'}
|
||||
className="h-auto flex-col items-start gap-1 p-3 text-left"
|
||||
onClick={() => handleFieldSelection(key, 'local')}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<User className="h-3 w-3" />
|
||||
<span>Your Version</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all max-h-20 overflow-hidden">
|
||||
{renderValue(localValue)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Server Version */}
|
||||
<Button
|
||||
variant={selection === 'server' ? 'default' : 'outline'}
|
||||
className="h-auto flex-col items-start gap-1 p-3 text-left"
|
||||
onClick={() => handleFieldSelection(key, 'server')}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>Server Version</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all max-h-20 overflow-hidden">
|
||||
{renderValue(serverValue)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{!allFieldsSelected() && (
|
||||
<div className="rounded-lg border border-orange-300 bg-orange-50 dark:bg-orange-950/20 p-3">
|
||||
<p className="text-sm text-orange-900 dark:text-orange-100">
|
||||
Please select a version for all fields before resolving.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleResolve}
|
||||
disabled={!selectedChoice || (selectedChoice === 'merged' && !allFieldsSelected())}
|
||||
>
|
||||
Resolve Conflict
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing conflict resolution
|
||||
*
|
||||
* Provides state management for conflict resolution workflow.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const {
|
||||
* conflict,
|
||||
* showConflict,
|
||||
* resolveConflict,
|
||||
* dismissConflict
|
||||
* } = useConflictResolver();
|
||||
*
|
||||
* // When a conflict is detected
|
||||
* showConflict({
|
||||
* id: 'conflict-1',
|
||||
* resourceType: 'canvas',
|
||||
* resourceId: 'canvas-123',
|
||||
* resourceName: 'My Canvas',
|
||||
* localVersion: localData,
|
||||
* serverVersion: serverData,
|
||||
* localTimestamp: Date.now(),
|
||||
* serverTimestamp: Date.now() - 1000
|
||||
* });
|
||||
*
|
||||
* // Render the resolver
|
||||
* <ConflictResolver
|
||||
* conflict={conflict}
|
||||
* open={!!conflict}
|
||||
* onClose={dismissConflict}
|
||||
* onResolve={resolveConflict}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function useConflictResolver() {
|
||||
const [conflict, setConflict] = useState<ConflictData | null>(null);
|
||||
const [resolvedConflicts, setResolvedConflicts] = useState<Map<string, ResolutionChoice>>(new Map());
|
||||
|
||||
const showConflict = (conflictData: ConflictData) => {
|
||||
setConflict(conflictData);
|
||||
};
|
||||
|
||||
const dismissConflict = () => {
|
||||
setConflict(null);
|
||||
};
|
||||
|
||||
const resolveConflict = (
|
||||
conflictId: string,
|
||||
choice: ResolutionChoice,
|
||||
mergedData?: any
|
||||
) => {
|
||||
// Store the resolution
|
||||
setResolvedConflicts((prev) => new Map(prev).set(conflictId, choice));
|
||||
|
||||
// Clear current conflict
|
||||
setConflict(null);
|
||||
|
||||
// Return the resolution for the caller to handle
|
||||
return { choice, mergedData };
|
||||
};
|
||||
|
||||
const getResolution = (conflictId: string): ResolutionChoice | undefined => {
|
||||
return resolvedConflicts.get(conflictId);
|
||||
};
|
||||
|
||||
const clearResolutions = () => {
|
||||
setResolvedConflicts(new Map());
|
||||
};
|
||||
|
||||
return {
|
||||
conflict,
|
||||
showConflict,
|
||||
dismissConflict,
|
||||
resolveConflict,
|
||||
getResolution,
|
||||
clearResolutions
|
||||
};
|
||||
}
|
||||
879
frontend/src/components/canvas/InteractionInput.tsx
Normal file
879
frontend/src/components/canvas/InteractionInput.tsx
Normal file
@@ -0,0 +1,879 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Film, Image as ImageIcon, MessageSquare, Mic2, Music2, Workflow } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { useModelStore } from '@lib/store/modelStore';
|
||||
import { sendChatMessage } from './services/canvasService';
|
||||
import { Message } from './utils/chatUtils';
|
||||
import { useReactFlow, useNodes, useEdges } from '@xyflow/react';
|
||||
import { AppNodeData, NodeType, NodeStatus, AppNodeType } from './types/node';
|
||||
import { retryWithBackoff, parseApiError, ErrorCode } from '@/lib/errors';
|
||||
import { processPromptWithAssets } from './utils';
|
||||
import { buildInputAssets } from './interaction-input-helpers';
|
||||
|
||||
import { AssetSelector } from './controls/interaction-input/AssetSelector';
|
||||
import { CollapsedView } from './controls/interaction-input/CollapsedView';
|
||||
import { AutoResizeTextarea } from './controls/interaction-input/AutoResizeTextarea';
|
||||
import { InputFooter } from './controls/interaction-input/InputFooter';
|
||||
import { useStyles } from '@lib/hooks/useConfig';
|
||||
import { StyleConfig } from '@/types';
|
||||
import { InputAsset } from './types/node';
|
||||
import {
|
||||
ModelSelector, ResolutionSelector, ImageResolutionSelector, AspectRatioSelector,
|
||||
DurationSelector, CountSelector, StyleSelector,
|
||||
CameraControlSelector, ShotTypeToggle, AudioToggle, VoiceSelector
|
||||
} from './controls/interaction-input/Selectors';
|
||||
import {
|
||||
GenerationSettings,
|
||||
GenerationType,
|
||||
InteractionInputProps
|
||||
} from './controls/interaction-input/types';
|
||||
import { DEFAULT_SETTINGS } from './controls/interaction-input/constants';
|
||||
|
||||
import { InputThumbnails } from './nodes/components/InputThumbnails';
|
||||
import { TabButton } from './controls/interaction-input/TabButton';
|
||||
import { styles as uiStyles } from '@/styles/constants';
|
||||
import { useWorkflow } from '@lib/hooks/useWorkflow';
|
||||
import { useSelectedNode } from './hooks/useSelectedNode';
|
||||
import { useNodeData } from './hooks/useNodeData';
|
||||
import { useNodeExecution } from './hooks/useNodeExecution';
|
||||
import { useAgentCanvasActions } from './hooks/useAgentCanvasActions';
|
||||
import { useInteractionDefaults } from './hooks/useInteractionDefaults';
|
||||
import { useInteractionEditMode } from './hooks/useInteractionEditMode';
|
||||
import { DefaultService } from '@lib/api';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
|
||||
const ChatDisplay = dynamic(
|
||||
() => import('./controls/interaction-input/ChatDisplay').then(m => ({ default: m.ChatDisplay })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const InteractionInputComponent = ({ onGenerate }: InteractionInputProps) => {
|
||||
const { imageModels, videoModels, audioModels, lyricsModels, musicModels, llmModels } = useModelStore();
|
||||
const { data: stylesData } = useStyles();
|
||||
const styleConfigs = (stylesData?.styles || []) as StyleConfig[];
|
||||
const [activeTab, setActiveTab] = useState<GenerationType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Chat State
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
// Expand/Collapse State
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Settings State
|
||||
const [settings, setSettings] = useState<GenerationSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// --- Edit Mode: Selected Node ---
|
||||
const { selectedNode, selectedNodeData, selectedNodeId, isEditMode } = useSelectedNode();
|
||||
const { update: updateNodeData } = useNodeData(selectedNodeId || '__noop__');
|
||||
const { executeNode } = useNodeExecution();
|
||||
|
||||
// 任务状态绑定当前选中节点:编辑态由节点 status/taskId 推导,非编辑态用全局 isGenerating(创建节点等)
|
||||
const isGeneratingForBar = isEditMode
|
||||
? (selectedNodeData?.status === 'WORKING' || !!selectedNodeData?.taskId)
|
||||
: isGenerating;
|
||||
|
||||
const isLyricsEditNode = selectedNodeData?.type === NodeType.LYRICS_GENERATOR;
|
||||
const isMusicEditNode =
|
||||
selectedNodeData?.type === NodeType.AUDIO_GENERATOR && selectedNodeData?.generationType === 'music';
|
||||
|
||||
const { handleInputChange, effectiveSetSettings, clearPromptSync } = useInteractionEditMode({
|
||||
isEditMode,
|
||||
selectedNodeId,
|
||||
selectedNodeData: selectedNodeData ?? undefined,
|
||||
updateNodeData,
|
||||
inputText,
|
||||
settings,
|
||||
activeTab,
|
||||
setInputText,
|
||||
setSettings,
|
||||
setActiveTab,
|
||||
setIsExpanded,
|
||||
defaultSettings: DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
// When entering edit mode, close chat
|
||||
useEffect(() => {
|
||||
if (isEditMode && selectedNodeData) setShowChat(false);
|
||||
}, [isEditMode, selectedNodeData]);
|
||||
|
||||
const toggleMusicLyricsMode = useCallback(() => {
|
||||
const nextLyrics = !(settings.musicGenerateLyrics ?? false);
|
||||
const currentMusic = settings.musicGenerateMusic ?? true;
|
||||
if (!nextLyrics && !currentMusic) return;
|
||||
|
||||
effectiveSetSettings({
|
||||
...settings,
|
||||
musicGenerateLyrics: nextLyrics,
|
||||
model: currentMusic
|
||||
? (settings.musicModel || settings.model)
|
||||
: (nextLyrics ? (settings.lyricsModel || settings.model) : settings.model),
|
||||
});
|
||||
}, [effectiveSetSettings, settings]);
|
||||
|
||||
const toggleMusicSongMode = useCallback(() => {
|
||||
const currentLyrics = settings.musicGenerateLyrics ?? false;
|
||||
const nextMusic = !(settings.musicGenerateMusic ?? true);
|
||||
if (!currentLyrics && !nextMusic) return;
|
||||
|
||||
effectiveSetSettings({
|
||||
...settings,
|
||||
musicGenerateMusic: nextMusic,
|
||||
model: nextMusic
|
||||
? (settings.musicModel || settings.model)
|
||||
: (currentLyrics ? (settings.lyricsModel || settings.model) : settings.model),
|
||||
});
|
||||
}, [effectiveSetSettings, settings]);
|
||||
|
||||
const handleLyricsModelSelect = useCallback((id: string) => {
|
||||
effectiveSetSettings({
|
||||
...settings,
|
||||
lyricsModel: id,
|
||||
musicTarget: 'lyrics',
|
||||
musicGenerateLyrics: true,
|
||||
musicGenerateMusic: false,
|
||||
model: id,
|
||||
});
|
||||
}, [effectiveSetSettings, settings]);
|
||||
|
||||
const handleMusicModelSelect = useCallback((id: string) => {
|
||||
effectiveSetSettings({
|
||||
...settings,
|
||||
musicModel: id,
|
||||
musicTarget: 'music',
|
||||
musicGenerateMusic: true,
|
||||
musicGenerateLyrics: false,
|
||||
model: id,
|
||||
});
|
||||
}, [effectiveSetSettings, settings]);
|
||||
|
||||
// Get available models based on active tab
|
||||
const availableModels = useMemo(() => {
|
||||
logger.debug('[InteractionInput] Computing availableModels for tab:', activeTab);
|
||||
if (isEditMode && isLyricsEditNode) return lyricsModels;
|
||||
if (isEditMode && isMusicEditNode) return musicModels;
|
||||
if (activeTab === 'text') return llmModels;
|
||||
if (activeTab === 'image') return imageModels;
|
||||
if (activeTab === 'video') return videoModels;
|
||||
if (activeTab === 'audio') return audioModels;
|
||||
if (activeTab === 'music') {
|
||||
return (settings.musicGenerateMusic ?? true) ? musicModels : lyricsModels;
|
||||
}
|
||||
return llmModels;
|
||||
}, [
|
||||
activeTab,
|
||||
isEditMode,
|
||||
isLyricsEditNode,
|
||||
isMusicEditNode,
|
||||
settings.musicGenerateMusic,
|
||||
imageModels,
|
||||
videoModels,
|
||||
audioModels,
|
||||
lyricsModels,
|
||||
musicModels,
|
||||
llmModels
|
||||
]);
|
||||
|
||||
// Get current model config for capability-based controls
|
||||
const currentModelConfig = useMemo(() => {
|
||||
if (!settings.model || !availableModels) return null;
|
||||
return availableModels[settings.model];
|
||||
}, [settings.model, availableModels]);
|
||||
|
||||
const capabilities = currentModelConfig?.capabilities;
|
||||
const inputPlaceholder = useMemo(() => {
|
||||
if (activeTab === 'music') {
|
||||
const generateLyrics = settings.musicGenerateLyrics ?? false;
|
||||
const generateMusic = settings.musicGenerateMusic ?? true;
|
||||
if (generateLyrics && generateMusic) return '输入主题/风格,先生成歌词再生成音乐';
|
||||
if (generateLyrics) return '输入主题或风格,生成歌词';
|
||||
return '输入歌词,生成音乐';
|
||||
}
|
||||
return isExpanded ? '输入你的创意' : '有什么可以帮您?';
|
||||
}, [activeTab, isExpanded, settings.musicGenerateLyrics, settings.musicGenerateMusic]);
|
||||
|
||||
// React Flow hooks for node and edge operations
|
||||
const { getNodes } = useReactFlow();
|
||||
|
||||
// Workflow orchestration hook | 工作流编排 hook
|
||||
const workflow = useWorkflow({
|
||||
getDefaultImageModel: () => Object.values(imageModels).find(m => m.is_default)?.id || Object.keys(imageModels)[0] || '',
|
||||
getDefaultVideoModel: () => Object.values(videoModels).find(m => m.is_default)?.id || Object.keys(videoModels)[0] || '',
|
||||
});
|
||||
|
||||
// Get input assets for the selected node
|
||||
const edges = useEdges();
|
||||
const nodes = useNodes();
|
||||
|
||||
const inputAssets = useMemo(() => {
|
||||
return buildInputAssets({
|
||||
isEditMode,
|
||||
selectedNodeId,
|
||||
edges,
|
||||
nodes,
|
||||
audioModels,
|
||||
});
|
||||
}, [isEditMode, selectedNodeId, edges, nodes, audioModels]);
|
||||
|
||||
// Track processed message indices to avoid duplicate node creation
|
||||
const processedIndices = useRef(new Set<number>());
|
||||
|
||||
// Auto-collapse on outside click (not in edit mode - edit mode is controlled by node selection)
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Element;
|
||||
if (!document.contains(target)) return;
|
||||
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(target) &&
|
||||
!target.closest('.interaction-model-popover')
|
||||
) {
|
||||
if (!isEditMode) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
setShowChat(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
};
|
||||
}, [isEditMode]);
|
||||
|
||||
useInteractionDefaults(activeTab, availableModels, lyricsModels, musicModels, setSettings, isEditMode);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelectedAssets(prev =>
|
||||
prev.includes(id) ? prev.filter(r => r !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Agent 画布操作(节点/边/工作流/actions 解析与应用)
|
||||
const { processDeleteNodes, processUpdateNode, processWorkflow, processAgentCommands } =
|
||||
useAgentCanvasActions();
|
||||
|
||||
// Process AI messages for node creation commands
|
||||
useEffect(() => {
|
||||
if (!isChatLoading && messages.length > 0) {
|
||||
messages.forEach((msg, index) => {
|
||||
if (msg.role === 'model' && !processedIndices.current.has(index)) {
|
||||
processAgentCommands(msg.text);
|
||||
processedIndices.current.add(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isChatLoading, messages, processAgentCommands]);
|
||||
|
||||
// --- Get Selected Node Context ---
|
||||
|
||||
const getSelectedContext = useCallback(() => {
|
||||
const nodes = getNodes();
|
||||
const selectedNodes = nodes.filter(node => node.selected);
|
||||
|
||||
if (selectedNodes.length === 0) return "";
|
||||
|
||||
const contextParts = selectedNodes.map(node => {
|
||||
const data = node.data as AppNodeData;
|
||||
let info = `- Node: ${data.title || node.type} (ID: ${node.id})`;
|
||||
if (data.prompt) info += `\n Prompt: ${data.prompt}`;
|
||||
if (data.negativePrompt) info += `\n Negative Prompt: ${data.negativePrompt}`;
|
||||
// Add more specific fields if needed
|
||||
return info;
|
||||
});
|
||||
|
||||
return "\n\n[Current Context: Selected Nodes]\n" + contextParts.join('\n') + "\n";
|
||||
}, [getNodes]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!inputText.trim()) return;
|
||||
|
||||
// Clear previous error
|
||||
setError(null);
|
||||
|
||||
// --- Edit Mode: execute the selected node(状态由节点 status/taskId 驱动,不写全局 isGenerating)---
|
||||
if (isEditMode && selectedNodeId) {
|
||||
try {
|
||||
clearPromptSync();
|
||||
const isPromptGen = selectedNodeData?.type === 'PROMPT_GENERATOR';
|
||||
updateNodeData(isPromptGen ? { userPrompt: inputText } : { prompt: inputText });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await executeNode(selectedNodeId);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
console.error("Node execution failed", e);
|
||||
const parsedError = parseApiError(e);
|
||||
setError(parsedError.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'text') {
|
||||
const userText = inputText;
|
||||
setInputText('');
|
||||
setShowChat(true);
|
||||
|
||||
// Inject context if available
|
||||
const context = getSelectedContext();
|
||||
const finalUserText = context ? `${userText}\n${context}` : userText;
|
||||
|
||||
// Display original user text in UI, but send enhanced text to backend
|
||||
const newMessages: Message[] = [...messages, {
|
||||
role: 'user',
|
||||
text: userText,
|
||||
fullText: context ? finalUserText : undefined
|
||||
}];
|
||||
setMessages(newMessages);
|
||||
setIsChatLoading(true);
|
||||
|
||||
try {
|
||||
// Build history payload
|
||||
const historyPayload = messages.map(m => ({
|
||||
role: m.role,
|
||||
parts: [{ text: m.fullText || m.text }]
|
||||
}));
|
||||
|
||||
// Add placeholder for assistant message
|
||||
setMessages(prev => [...prev, { role: 'model', text: '' }]);
|
||||
|
||||
// Use the prepared text (with context if any) as the new message
|
||||
const messagePayload = context ? finalUserText : userText;
|
||||
|
||||
// Wrap sendChatMessage with retry logic
|
||||
await retryWithBackoff(
|
||||
() => sendChatMessage(historyPayload, messagePayload, {
|
||||
onChunk: (chunk) => {
|
||||
setMessages(prev => {
|
||||
const newMsgs = [...prev];
|
||||
const lastIndex = newMsgs.length - 1;
|
||||
const lastMsg = newMsgs[lastIndex];
|
||||
|
||||
if (lastMsg && lastMsg.role === 'model') {
|
||||
newMsgs[lastIndex] = {
|
||||
...lastMsg,
|
||||
text: lastMsg.text + chunk
|
||||
};
|
||||
return newMsgs;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
maxRetries: 2,
|
||||
initialDelay: 1000,
|
||||
retryableErrors: [ErrorCode.NETWORK_ERROR, ErrorCode.TIMEOUT_ERROR, ErrorCode.SERVICE_UNAVAILABLE]
|
||||
}
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error("Chat failed", e);
|
||||
const parsedError = parseApiError(e);
|
||||
const errorMessage = parsedError.message || "连接错误,请稍后重试。";
|
||||
|
||||
setError(errorMessage);
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.role === 'model' && !last.text) {
|
||||
return [...prev.slice(0, -1), { role: 'model', text: `❌ ${errorMessage}` }];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} finally {
|
||||
setIsChatLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
const generatePrompt = inputText;
|
||||
setInputText('');
|
||||
setIsExpanded(false);
|
||||
|
||||
try {
|
||||
if (onGenerate) {
|
||||
// 处理 prompt 中通过 @别名 引用到的素材
|
||||
const { processedPrompt, mappings } = processPromptWithAssets(generatePrompt, inputAssets);
|
||||
|
||||
await retryWithBackoff(
|
||||
() => onGenerate({
|
||||
type: activeTab,
|
||||
prompt: processedPrompt,
|
||||
settings: settings,
|
||||
inputAssets: inputAssets,
|
||||
assetMappings: mappings
|
||||
}),
|
||||
{
|
||||
maxRetries: 2,
|
||||
initialDelay: 2000,
|
||||
retryableErrors: [ErrorCode.NETWORK_ERROR, ErrorCode.TIMEOUT_ERROR, ErrorCode.PROVIDER_ERROR]
|
||||
}
|
||||
);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Generation failed", e);
|
||||
const parsedError = parseApiError(e);
|
||||
setError(parsedError.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearChat = () => {
|
||||
setMessages([]);
|
||||
// Optional: Keep current input text or clear it?
|
||||
// Usually restart means fresh start, but keeping input allows retrying.
|
||||
// User said "清空上下文", so messages only is safe.
|
||||
// Let's also close the chat bubbles if open, to simulate a full reset?
|
||||
// "重新开始对话" implies the chat history is gone.
|
||||
};
|
||||
|
||||
// Handle workflow generation (Cmd/Ctrl + Enter in text mode)
|
||||
// Analyzes user input and automatically creates workflow nodes
|
||||
const handleWorkflowGenerate = async () => {
|
||||
if (!inputText.trim() || workflow.isAnalyzing || workflow.isExecuting) return;
|
||||
|
||||
const userText = inputText;
|
||||
setInputText('');
|
||||
setError(null);
|
||||
|
||||
// Show feedback in chat
|
||||
setShowChat(true);
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'user',
|
||||
text: `[工作流] ${userText}`
|
||||
}]);
|
||||
|
||||
try {
|
||||
// Add status message
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'model',
|
||||
text: '正在分析工作流...'
|
||||
}]);
|
||||
|
||||
// Run workflow orchestration
|
||||
await workflow.runWorkflow(userText);
|
||||
|
||||
// Update status message
|
||||
setMessages(prev => {
|
||||
const newMsgs = [...prev];
|
||||
const lastIndex = newMsgs.length - 1;
|
||||
if (newMsgs[lastIndex]?.role === 'model') {
|
||||
newMsgs[lastIndex] = {
|
||||
...newMsgs[lastIndex],
|
||||
text: '✅ 工作流已创建!节点已自动布局在画布上。'
|
||||
};
|
||||
}
|
||||
return newMsgs;
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.error("Workflow failed", e);
|
||||
setError((e instanceof Error ? e.message : null) ?? '工作流执行失败');
|
||||
|
||||
// Update status message with error
|
||||
setMessages(prev => {
|
||||
const newMsgs = [...prev];
|
||||
const lastIndex = newMsgs.length - 1;
|
||||
if (newMsgs[lastIndex]?.role === 'model') {
|
||||
newMsgs[lastIndex] = {
|
||||
...newMsgs[lastIndex],
|
||||
text: `❌ 工作流失败: ${e instanceof Error ? e.message : '未知错误'}`
|
||||
};
|
||||
}
|
||||
return newMsgs;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full max-w-[760px] z-10 px-4 pointer-events-none"
|
||||
onFocusCapture={() => setShowChat(true)}
|
||||
>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-3 pointer-events-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-2 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400 text-sm">⚠️ {error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Controls */}
|
||||
<div className="flex items-end justify-between relative">
|
||||
<AssetSelector
|
||||
isExpanded={isExpanded}
|
||||
selectedAssets={selectedAssets}
|
||||
onToggleAsset={toggleAsset}
|
||||
inputAssets={inputAssets}
|
||||
/>
|
||||
|
||||
{/* Chat Overlay Display */}
|
||||
<AnimatePresence>
|
||||
{activeTab === 'text' && showChat && (
|
||||
<ChatDisplay messages={messages} isLoading={isChatLoading} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`flex items-center gap-1 ${uiStyles.glassPanel} p-1.5 ${uiStyles.roundedFull} pointer-events-auto mb-3`}
|
||||
>
|
||||
<TabButton
|
||||
isActive={activeTab === 'text'}
|
||||
onClick={() => {
|
||||
setActiveTab('text');
|
||||
setShowChat(true);
|
||||
setIsExpanded(false); // Switch to collapsed text mode
|
||||
}}
|
||||
icon={<MessageSquare className="w-3.5 h-3.5" />}
|
||||
label="助手"
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
<TabButton
|
||||
isActive={activeTab === 'image'}
|
||||
onClick={() => setActiveTab('image')}
|
||||
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
||||
label="图片"
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
<TabButton
|
||||
isActive={activeTab === 'video'}
|
||||
onClick={() => setActiveTab('video')}
|
||||
icon={<Film className="w-3.5 h-3.5" />}
|
||||
label="视频"
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
<TabButton
|
||||
isActive={activeTab === 'audio'}
|
||||
onClick={() => setActiveTab('audio')}
|
||||
icon={<Mic2 className="w-3.5 h-3.5" />}
|
||||
label="音频"
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
<TabButton
|
||||
isActive={activeTab === 'music'}
|
||||
onClick={() => setActiveTab('music')}
|
||||
icon={<Music2 className="w-3.5 h-3.5" />}
|
||||
label="音乐"
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Input Assets Display (Collapsed Mode) */}
|
||||
<AnimatePresence>
|
||||
{!isExpanded && inputAssets.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="mb-2 pointer-events-auto"
|
||||
>
|
||||
<InputThumbnails assets={inputAssets} onReorder={() => {}} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Settings Bar for Collapsed Media Mode - Only Model Selector */}
|
||||
<AnimatePresence>
|
||||
{!isExpanded && (activeTab === 'image' || activeTab === 'video' || activeTab === 'audio' || activeTab === 'music' || activeTab === 'text') && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="flex justify-center mb-3 pointer-events-auto"
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md p-1.5 rounded-full border border-white/10 shadow-lg">
|
||||
{/* Edit mode indicator */}
|
||||
{isEditMode && selectedNodeData && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/10 border border-cyan-500/20 text-[10px] font-bold text-cyan-400">
|
||||
<span className="truncate max-w-[80px]">{selectedNodeData.title}</span>
|
||||
</div>
|
||||
<div className="h-4 w-[1px] bg-white/10 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'music' && !isEditMode ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
onClick={toggleMusicLyricsMode}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
|
||||
(settings.musicGenerateLyrics ?? false)
|
||||
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
|
||||
)}
|
||||
>
|
||||
歌词模型
|
||||
</span>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.lyricsModel || ''}
|
||||
models={lyricsModels}
|
||||
onSelect={handleLyricsModelSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-[1px] bg-white/10 mx-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
onClick={toggleMusicSongMode}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
|
||||
(settings.musicGenerateMusic ?? true)
|
||||
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
|
||||
)}
|
||||
>
|
||||
歌曲模型
|
||||
</span>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.musicModel || ''}
|
||||
models={musicModels}
|
||||
onSelect={handleMusicModelSelect}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
onSelect={(id) => effectiveSetSettings({ ...settings, model: id })}
|
||||
/>
|
||||
{/* Divider for all tabs */}
|
||||
<div className="h-4 w-[1px] bg-white/10 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text/Assistant: Show LLM-specific controls */}
|
||||
{activeTab === 'text' && (
|
||||
<>
|
||||
{/* Temperature control could be added here */}
|
||||
{/* For now, just showing model selector is sufficient */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image: Resolution, Aspect Ratio, Style, Count */}
|
||||
{activeTab === 'image' && (
|
||||
<>
|
||||
<ImageResolutionSelector
|
||||
resolution={settings.resolution || '1K'}
|
||||
onSelect={(r) => effectiveSetSettings({ ...settings, resolution: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<AspectRatioSelector
|
||||
aspectRatio={settings.aspectRatio}
|
||||
isVideo={false}
|
||||
onSelect={(r) => effectiveSetSettings({ ...settings, aspectRatio: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
<StyleSelector
|
||||
selectedStyleId={settings.styleId}
|
||||
styles={styleConfigs}
|
||||
onSelect={(id) => effectiveSetSettings({ ...settings, styleId: id || undefined })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<CountSelector
|
||||
count={settings.count}
|
||||
isVideo={false}
|
||||
onSelect={(c) => effectiveSetSettings({ ...settings, count: c })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video: Keep Resolution, Aspect Ratio, Duration, Count */}
|
||||
{activeTab === 'video' && (
|
||||
<>
|
||||
<ResolutionSelector
|
||||
resolution={settings.resolution}
|
||||
onSelect={(r) => effectiveSetSettings({ ...settings, resolution: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<AspectRatioSelector
|
||||
aspectRatio={settings.aspectRatio}
|
||||
isVideo={true}
|
||||
onSelect={(r) => effectiveSetSettings({ ...settings, aspectRatio: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
<DurationSelector
|
||||
duration={settings.duration}
|
||||
onSelect={(d) => effectiveSetSettings({ ...settings, duration: d })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<CountSelector
|
||||
count={settings.count}
|
||||
isVideo={true}
|
||||
onSelect={(c) => effectiveSetSettings({ ...settings, count: c })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Audio: Keep ALL controls (Voice) */}
|
||||
{activeTab === 'audio' && (() => {
|
||||
const audioModel = availableModels[settings.model];
|
||||
const voiceList = (audioModel?.voices ?? []).map((v: { id?: string; name?: string; value?: string; label?: string; gender?: string; desc?: string }) => ({
|
||||
value: v.value ?? v.id ?? '',
|
||||
label: v.label ?? v.name ?? v.value ?? v.id ?? '',
|
||||
...(v.gender != null && { gender: v.gender }),
|
||||
...(v.desc != null && { desc: v.desc }),
|
||||
}));
|
||||
return voiceList.length > 0 ? (
|
||||
<VoiceSelector
|
||||
voice={settings.voice}
|
||||
voices={voiceList}
|
||||
onSelect={(v) => effectiveSetSettings({ ...settings, voice: v })}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{activeTab === 'music' && (
|
||||
<span className="text-[10px] text-muted-foreground px-2">
|
||||
当前模式:
|
||||
{(settings.musicGenerateLyrics ?? false) && (settings.musicGenerateMusic ?? true)
|
||||
? '歌词 -> 音乐'
|
||||
: (settings.musicGenerateLyrics ?? false)
|
||||
? '仅歌词'
|
||||
: '仅音乐'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Input Bar */}
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.5 }}
|
||||
className={cn(
|
||||
"bg-black/40 backdrop-blur-md border border-white/10 shadow-xl relative overflow-hidden z-20 mx-auto pointer-events-auto transform-gpu",
|
||||
isExpanded ? "rounded-3xl w-[720px]" : "rounded-full hover:border-white/20 w-[580px]"
|
||||
)}
|
||||
>
|
||||
<div className={cn("w-full transition-opacity duration-200", isExpanded ? "hidden opacity-0" : "block opacity-100 h-[52px]")}>
|
||||
<CollapsedView
|
||||
inputText={inputText}
|
||||
activeTab={activeTab}
|
||||
disableTabs={isEditMode}
|
||||
inputAssets={inputAssets}
|
||||
isGenerating={isGeneratingForBar}
|
||||
canGenerate={!!inputText.trim()}
|
||||
currentTaskId={isEditMode ? selectedNodeData?.taskId : undefined}
|
||||
onCancel={isEditMode && selectedNodeData?.taskId && selectedNodeId ? () => {
|
||||
DefaultService.cancelTask(selectedNodeData!.taskId!);
|
||||
updateNodeData({ taskId: undefined, status: 'IDLE', error: '已取消' });
|
||||
} : undefined}
|
||||
onTabChange={(tab) => {
|
||||
if (isEditMode) return; // Lock tab in edit mode
|
||||
setActiveTab(tab);
|
||||
if (tab === 'text') {
|
||||
setShowChat(true);
|
||||
}
|
||||
if (tab === 'image' || tab === 'video' || tab === 'audio' || tab === 'music') {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleGenerate}
|
||||
onWorkflowSubmit={handleWorkflowGenerate}
|
||||
onExpand={() => setIsExpanded(true)}
|
||||
onClearChat={handleClearChat}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
isVisible={!isExpanded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn("w-full transition-opacity duration-200", isExpanded ? "block opacity-100" : "hidden opacity-0 h-0 overflow-hidden")}>
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="flex-1 flex flex-col px-2.5 pb-2.5 pt-4 min-h-[100px]">
|
||||
{/* Edit mode indicator */}
|
||||
{isEditMode && selectedNodeData && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/20 text-[10px] font-bold text-cyan-400">
|
||||
<span className="truncate max-w-[120px]">{selectedNodeData.title}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">编辑节点</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoResizeTextarea
|
||||
value={inputText}
|
||||
onChange={handleInputChange}
|
||||
onEnter={handleGenerate}
|
||||
isExpanded={isExpanded}
|
||||
placeholder={inputPlaceholder}
|
||||
inputAssets={inputAssets}
|
||||
/>
|
||||
|
||||
<InputFooter
|
||||
isGenerating={isGeneratingForBar}
|
||||
canGenerate={!!inputText.trim()}
|
||||
onGenerate={handleGenerate}
|
||||
currentTaskId={isEditMode ? selectedNodeData?.taskId : undefined}
|
||||
onCancel={isEditMode && selectedNodeData?.taskId && selectedNodeId ? () => {
|
||||
DefaultService.cancelTask(selectedNodeData!.taskId!);
|
||||
updateNodeData({ taskId: undefined, status: 'IDLE', error: '已取消' });
|
||||
} : undefined}
|
||||
activeTab={activeTab}
|
||||
settings={settings}
|
||||
availableModels={availableModels}
|
||||
lyricsModels={lyricsModels}
|
||||
musicModels={musicModels}
|
||||
onUpdateSettings={effectiveSetSettings}
|
||||
isEditMode={isEditMode}
|
||||
editNodeType={selectedNodeData?.type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InteractionInput = React.memo(InteractionInputComponent);
|
||||
316
frontend/src/components/canvas/README.md
Normal file
316
frontend/src/components/canvas/README.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Canvas Component Architecture
|
||||
|
||||
This document describes the refactored Canvas component architecture, which follows clean architecture principles and implements the Command Pattern for undo/redo functionality.
|
||||
|
||||
## Overview
|
||||
|
||||
The Canvas component has been refactored to:
|
||||
1. **Separate node types into individual files** for better maintainability
|
||||
2. **Extract common logic into reusable hooks** to reduce duplication
|
||||
3. **Implement the Command Pattern** for robust undo/redo functionality
|
||||
4. **Add debounced auto-save** to prevent data loss
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
canvas/
|
||||
├── commands/ # Command Pattern implementation
|
||||
│ ├── Command.ts # Command interface
|
||||
│ ├── CommandManager.ts # Command history manager
|
||||
│ ├── AddNodeCommand.ts
|
||||
│ ├── DeleteNodeCommand.ts
|
||||
│ ├── UpdateNodeCommand.ts
|
||||
│ ├── MoveNodeCommand.ts
|
||||
│ ├── AddEdgeCommand.ts
|
||||
│ ├── DeleteEdgeCommand.ts
|
||||
│ └── index.ts
|
||||
├── hooks/ # Custom hooks
|
||||
│ ├── useAutoSave.ts # Generic auto-save hook
|
||||
│ ├── useCanvasAutoSave.ts # Canvas-specific auto-save
|
||||
│ ├── useCanvasState.ts # Canvas state management
|
||||
│ ├── useCanvasNavigation.ts # Canvas navigation (next/prev)
|
||||
│ ├── useCanvasContextMenu.ts # Context menu logic
|
||||
│ ├── useUndoRedo.ts # Command-based undo/redo
|
||||
│ └── ... (existing hooks)
|
||||
├── nodes/
|
||||
│ ├── types/ # Individual node type components
|
||||
│ │ ├── PromptInputNode.tsx
|
||||
│ │ ├── ImageGeneratorNode.tsx
|
||||
│ │ ├── VideoGeneratorNode.tsx
|
||||
│ │ ├── AudioGeneratorNode.tsx
|
||||
│ │ ├── VideoAnalyzerNode.tsx
|
||||
│ │ ├── ImageEditorNode.tsx
|
||||
│ │ ├── InfoDisplayNode.tsx
|
||||
│ │ ├── PromptGeneratorNode.tsx
|
||||
│ │ └── index.ts
|
||||
│ ├── BaseNode.tsx # Base node component with common logic
|
||||
│ └── AppNode.tsx # Original unified node (still supported)
|
||||
└── Canvas.tsx # Main Canvas component
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Node Type Separation
|
||||
|
||||
Each node type now has its own component file, making it easier to:
|
||||
- Understand and modify individual node types
|
||||
- Add new node types without affecting existing ones
|
||||
- Test node types in isolation
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// PromptInputNode.tsx
|
||||
export const PromptInputNode = memo(({ id, data, selected, dragging }) => {
|
||||
return (
|
||||
<BaseNode
|
||||
id={id}
|
||||
data={data}
|
||||
selected={selected}
|
||||
dragging={dragging}
|
||||
icon={Type}
|
||||
color="text-amber-400"
|
||||
renderer={PromptInputRenderer}
|
||||
/>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. BaseNode Component
|
||||
|
||||
The `BaseNode` component extracts common logic shared by all node types:
|
||||
- Auto-execute logic
|
||||
- Node dimensions
|
||||
- Data synchronization
|
||||
- Node actions (download, expand, crop, etc.)
|
||||
- Hover and resize state management
|
||||
|
||||
This reduces code duplication and ensures consistent behavior across all node types.
|
||||
|
||||
### 3. Command Pattern for Undo/Redo
|
||||
|
||||
The Command Pattern provides a robust and extensible undo/redo system:
|
||||
|
||||
**Command Interface:**
|
||||
```typescript
|
||||
interface Command {
|
||||
execute(): void;
|
||||
undo(): void;
|
||||
getDescription?(): string;
|
||||
}
|
||||
```
|
||||
|
||||
**Available Commands:**
|
||||
- `AddNodeCommand` - Add a node to the canvas
|
||||
- `DeleteNodeCommand` - Delete a node and its connections
|
||||
- `UpdateNodeCommand` - Update node data
|
||||
- `MoveNodeCommand` - Move a node to a new position
|
||||
- `AddEdgeCommand` - Add an edge between nodes
|
||||
- `DeleteEdgeCommand` - Delete an edge
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
const { execute, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
|
||||
// Execute a command
|
||||
const command = new AddNodeCommand(newNode, setNodes);
|
||||
execute(command);
|
||||
|
||||
// Undo/Redo
|
||||
if (canUndo) undo();
|
||||
if (canRedo) redo();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Extensible**: Easy to add new command types
|
||||
- **Testable**: Commands can be tested in isolation
|
||||
- **Reliable**: Guaranteed state consistency
|
||||
- **Debuggable**: Each command has a description
|
||||
|
||||
### 4. Auto-Save with Debouncing
|
||||
|
||||
The auto-save system prevents data loss while minimizing server requests:
|
||||
|
||||
**Generic Auto-Save Hook:**
|
||||
```typescript
|
||||
const { triggerSave, cancelSave } = useAutoSave(
|
||||
data,
|
||||
async (data) => {
|
||||
await apiClient.put(`/canvas/${canvasId}`, data);
|
||||
},
|
||||
{ delay: 2000 }
|
||||
);
|
||||
```
|
||||
|
||||
**Canvas-Specific Auto-Save:**
|
||||
```typescript
|
||||
const { triggerSave } = useCanvasAutoSave(nodes, edges, {
|
||||
canvasId: 'canvas-123',
|
||||
canvasType: 'asset',
|
||||
isLoaded: true,
|
||||
delay: 2000,
|
||||
onSaveComplete: () => console.log('Saved!'),
|
||||
onSaveError: (error) => console.error('Save failed:', error)
|
||||
});
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Debounced**: Waits 2 seconds after last change before saving
|
||||
- **Smart**: Only saves if data has actually changed
|
||||
- **Safe**: Prevents concurrent saves
|
||||
- **Flexible**: Can trigger immediate save or cancel pending save
|
||||
|
||||
### 5. Extracted Hooks
|
||||
|
||||
Common Canvas logic has been extracted into reusable hooks:
|
||||
|
||||
**useCanvasState:**
|
||||
- Manages Canvas store state (nodes, edges, history)
|
||||
- Provides project data (assets, storyboards, episodes)
|
||||
- Memoizes sorted storyboards for consistent navigation
|
||||
|
||||
**useCanvasNavigation:**
|
||||
- Handles next/prev canvas navigation
|
||||
- Supports both asset and storyboard navigation
|
||||
- Maintains context (same type assets, sorted storyboards)
|
||||
|
||||
**useCanvasContextMenu:**
|
||||
- Manages context menu state and actions
|
||||
- Handles node/edge/pane context menus
|
||||
- Provides file replacement functionality
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Using New Node Types
|
||||
|
||||
To use the new separated node types, update your node type registry:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
PromptInputNode,
|
||||
ImageGeneratorNode,
|
||||
VideoGeneratorNode,
|
||||
// ... other node types
|
||||
} from './nodes/types';
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
promptInput: PromptInputNode,
|
||||
imageGenerator: ImageGeneratorNode,
|
||||
videoGenerator: VideoGeneratorNode,
|
||||
// ... other node types
|
||||
};
|
||||
```
|
||||
|
||||
### Using Command-Based Undo/Redo
|
||||
|
||||
Replace the old history-based undo/redo with the command pattern:
|
||||
|
||||
**Old:**
|
||||
```typescript
|
||||
const { takeSnapshot, undo, redo } = useHistory(setNodes, setEdges);
|
||||
takeSnapshot(nodes, edges);
|
||||
undo(nodes, edges);
|
||||
```
|
||||
|
||||
**New:**
|
||||
```typescript
|
||||
const { execute, undo, redo } = useUndoRedo();
|
||||
|
||||
// When adding a node
|
||||
const command = new AddNodeCommand(newNode, setNodes);
|
||||
execute(command);
|
||||
|
||||
// Undo/Redo
|
||||
undo(); // No need to pass current state
|
||||
redo();
|
||||
```
|
||||
|
||||
### Using Auto-Save
|
||||
|
||||
Add auto-save to your Canvas:
|
||||
|
||||
```typescript
|
||||
const { triggerSave } = useCanvasAutoSave(nodes, edges, {
|
||||
canvasId: activeCanvasId,
|
||||
canvasType: activeCanvasType,
|
||||
isLoaded: isCanvasLoaded,
|
||||
onSaveStatusChange: (isSaving) => {
|
||||
// Update UI to show save status
|
||||
}
|
||||
});
|
||||
|
||||
// Manual save
|
||||
<button onClick={triggerSave}>Save Now</button>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Testing Commands
|
||||
|
||||
Commands can be tested in isolation:
|
||||
|
||||
```typescript
|
||||
describe('AddNodeCommand', () => {
|
||||
it('should add node on execute', () => {
|
||||
const nodes: AppNodeType[] = [];
|
||||
const setNodes = jest.fn((updater) => {
|
||||
nodes.push(...updater(nodes));
|
||||
});
|
||||
|
||||
const command = new AddNodeCommand(mockNode, setNodes);
|
||||
command.execute();
|
||||
|
||||
expect(nodes).toContain(mockNode);
|
||||
});
|
||||
|
||||
it('should remove node on undo', () => {
|
||||
// ... test undo
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Auto-Save
|
||||
|
||||
Test the debounce behavior:
|
||||
|
||||
```typescript
|
||||
describe('useAutoSave', () => {
|
||||
it('should debounce saves', async () => {
|
||||
const saveFn = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ data }) => useAutoSave(data, saveFn, { delay: 100 }),
|
||||
{ initialProps: { data: { value: 1 } } }
|
||||
);
|
||||
|
||||
// Change data multiple times
|
||||
rerender({ data: { value: 2 } });
|
||||
rerender({ data: { value: 3 } });
|
||||
|
||||
// Should only save once after delay
|
||||
await waitFor(() => expect(saveFn).toHaveBeenCalledTimes(1));
|
||||
expect(saveFn).toHaveBeenCalledWith({ value: 3 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Commands for State Changes**: Always use commands for operations that should be undoable
|
||||
2. **Keep Node Types Focused**: Each node type should handle only its specific rendering logic
|
||||
3. **Extract Common Logic**: If multiple node types share logic, extract it to BaseNode or a hook
|
||||
4. **Test Commands**: Write tests for each command to ensure correct execute/undo behavior
|
||||
5. **Monitor Auto-Save**: Provide visual feedback when auto-save is in progress
|
||||
6. **Handle Save Errors**: Always handle and display save errors to the user
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Batch Commands**: Implement composite commands for operations that involve multiple changes
|
||||
2. **Command Compression**: Merge similar consecutive commands (e.g., multiple move commands)
|
||||
3. **Persistent Undo History**: Save undo history to allow undo across sessions
|
||||
4. **Optimistic Updates**: Update UI immediately while save is in progress
|
||||
5. **Conflict Resolution**: Handle conflicts when multiple users edit the same canvas
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [React Flow Documentation](https://reactflow.dev/)
|
||||
- [Command Pattern](https://refactoring.guru/design-patterns/command)
|
||||
- [Debouncing in React](https://www.freecodecamp.org/news/debouncing-explained/)
|
||||
88
frontend/src/components/canvas/SaveFeedback.tsx
Normal file
88
frontend/src/components/canvas/SaveFeedback.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface SaveFeedbackProps {
|
||||
isSaving: boolean;
|
||||
lastSavedAt?: number;
|
||||
error: string | null;
|
||||
trigger?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function SaveFeedback({ isSaving, lastSavedAt, error, trigger = true, onComplete }: SaveFeedbackProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [type, setType] = useState<'success' | 'error' | 'saving'>('success');
|
||||
const prevSavedAt = useRef(lastSavedAt);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
// Update ref when onComplete changes
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isSaving) {
|
||||
// Only start showing if triggered (manual save)
|
||||
if (trigger) {
|
||||
setIsVisible(true);
|
||||
setType('saving');
|
||||
setMessage('保存中...');
|
||||
}
|
||||
} else if (error) {
|
||||
// Only show error if we were already showing feedback
|
||||
if (isVisible) {
|
||||
setType('error');
|
||||
setMessage('保存失败');
|
||||
timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onCompleteRef.current?.();
|
||||
}, 3000);
|
||||
}
|
||||
} else if (lastSavedAt && lastSavedAt !== prevSavedAt.current) {
|
||||
// Only show success if timestamp changed (actual save happened)
|
||||
// AND we were already showing feedback
|
||||
if (isVisible) {
|
||||
setType('success');
|
||||
setMessage('已保存');
|
||||
timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onCompleteRef.current?.();
|
||||
}, 2000);
|
||||
}
|
||||
// Always update ref even if not visible to prevent stale success messages later
|
||||
prevSavedAt.current = lastSavedAt;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isSaving, error, lastSavedAt, trigger, isVisible]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, x: '-50%' }}
|
||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||
exit={{ opacity: 0, y: -20, x: '-50%' }}
|
||||
className={cn(
|
||||
"absolute top-6 left-1/2 z-[100] flex items-center gap-2 px-4 py-2 rounded-full shadow-lg backdrop-blur-md border pointer-events-none select-none",
|
||||
type === 'saving' && "bg-background/80 border-border text-foreground",
|
||||
type === 'success' && "bg-green-500/10 border-green-500/20 text-green-500",
|
||||
type === 'error' && "bg-red-500/10 border-red-500/20 text-red-500"
|
||||
)}
|
||||
>
|
||||
{type === 'saving' && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{type === 'success' && <Check className="w-4 h-4" />}
|
||||
{type === 'error' && <AlertCircle className="w-4 h-4" />}
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
368
frontend/src/components/canvas/SaveStatusIndicator.tsx
Normal file
368
frontend/src/components/canvas/SaveStatusIndicator.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Save Status Indicator Component
|
||||
*
|
||||
* This component displays the current save status of the canvas.
|
||||
* It shows:
|
||||
* - Save status: "Saved" / "Saving..." / "Save Failed"
|
||||
* - Last saved time (relative, e.g., "2 minutes ago")
|
||||
* - Error message when save fails
|
||||
* - Manual "Save Now" button
|
||||
*
|
||||
* Requirements: REQ-5.7.4
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Save,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface SaveStatusIndicatorProps {
|
||||
/** Whether the canvas is currently saving */
|
||||
isSaving: boolean;
|
||||
|
||||
/** Whether there are unsaved changes */
|
||||
isDirty?: boolean;
|
||||
|
||||
/** Last save timestamp (Unix timestamp in milliseconds) */
|
||||
lastSavedAt?: number;
|
||||
|
||||
/** Error message if save failed */
|
||||
error?: string | null;
|
||||
|
||||
/** Callback to trigger manual save */
|
||||
onSaveNow?: () => void;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
|
||||
/** Whether to show the "Save Now" button (default: true) */
|
||||
showSaveButton?: boolean;
|
||||
|
||||
/** Compact mode - shows only icon and minimal text (default: false) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time from timestamp
|
||||
* @param timestamp Unix timestamp in milliseconds
|
||||
* @returns Relative time string (e.g., "2分钟前")
|
||||
*/
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 10) return '刚刚';
|
||||
if (seconds < 60) return `${seconds}秒前`;
|
||||
if (minutes === 1) return '1分钟前';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours === 1) return '1小时前';
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days === 1) return '1天前';
|
||||
return `${days}天前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Status Indicator Component
|
||||
*
|
||||
* Displays the current save status of the canvas with visual feedback.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage
|
||||
* <SaveStatusIndicator
|
||||
* isSaving={isSaving}
|
||||
* lastSavedAt={lastSavedAt}
|
||||
* onSaveNow={saveImmediately}
|
||||
* />
|
||||
*
|
||||
* // With error
|
||||
* <SaveStatusIndicator
|
||||
* isSaving={false}
|
||||
* error="Failed to save canvas"
|
||||
* onSaveNow={saveImmediately}
|
||||
* />
|
||||
*
|
||||
* // Compact mode
|
||||
* <SaveStatusIndicator
|
||||
* isSaving={isSaving}
|
||||
* lastSavedAt={lastSavedAt}
|
||||
* compact
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SaveStatusIndicator({
|
||||
isSaving,
|
||||
isDirty,
|
||||
lastSavedAt,
|
||||
error,
|
||||
onSaveNow,
|
||||
className,
|
||||
compact = false,
|
||||
showSaveButton = true,
|
||||
}: SaveStatusIndicatorProps) {
|
||||
// Determine status
|
||||
const status = error ? 'error' : isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved';
|
||||
|
||||
// Only render a simple button as requested
|
||||
if (!onSaveNow) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-2 text-xs gap-1.5 transition-colors",
|
||||
status === 'unsaved' && "text-amber-500 hover:text-amber-600 hover:bg-amber-500/10",
|
||||
status === 'error' && "text-destructive hover:text-destructive hover:bg-destructive/10",
|
||||
status === 'saved' && "text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={onSaveNow}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{status === 'saving' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : status === 'error' ? (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
<span>
|
||||
{status === 'saving' ? '保存中' : status === 'error' ? '重试' : '保存'}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar Save Status Indicator
|
||||
*
|
||||
* A minimal version designed for canvas toolbars.
|
||||
* Shows only the status icon with a tooltip.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ToolbarSaveStatus
|
||||
* isSaving={isSaving}
|
||||
* lastSavedAt={lastSavedAt}
|
||||
* onSaveNow={saveImmediately}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ToolbarSaveStatus({
|
||||
isSaving,
|
||||
isDirty,
|
||||
lastSavedAt,
|
||||
error,
|
||||
onSaveNow,
|
||||
className
|
||||
}: Omit<SaveStatusIndicatorProps, 'compact' | 'showSaveButton'>) {
|
||||
const [relativeTime, setRelativeTime] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastSavedAt) {
|
||||
setRelativeTime('');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTime = () => {
|
||||
setRelativeTime(formatRelativeTime(lastSavedAt));
|
||||
};
|
||||
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [lastSavedAt]);
|
||||
|
||||
const status = error ? 'error' : isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved';
|
||||
|
||||
// Build tooltip text
|
||||
let tooltipText = '';
|
||||
if (status === 'saving') {
|
||||
tooltipText = '正在保存更改...';
|
||||
} else if (status === 'unsaved') {
|
||||
tooltipText = '有未保存的更改';
|
||||
} else if (status === 'saved') {
|
||||
tooltipText = relativeTime ? `已保存 ${relativeTime}` : '所有更改已保存';
|
||||
} else if (status === 'error') {
|
||||
tooltipText = error || '保存更改失败';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent transition-colors"
|
||||
title={tooltipText}
|
||||
onClick={onSaveNow}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{status === 'saving' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
)}
|
||||
|
||||
{status === 'unsaved' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
)}
|
||||
|
||||
{status === 'saved' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{status === 'saving' && '保存中...'}
|
||||
{status === 'unsaved' && '未保存'}
|
||||
{status === 'saved' && '已保存'}
|
||||
{status === 'error' && '保存失败'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Save Now button for error or unsaved state */}
|
||||
{(status === 'error' || status === 'unsaved') && onSaveNow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={onSaveNow}
|
||||
>
|
||||
<Save className="h-3 w-3 mr-1" />
|
||||
{status === 'error' ? '重试' : '保存'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating Save Status Badge
|
||||
*
|
||||
* A floating badge that appears in the corner of the canvas.
|
||||
* Useful for non-intrusive save status indication.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FloatingSaveStatus
|
||||
* isSaving={isSaving}
|
||||
* lastSavedAt={lastSavedAt}
|
||||
* position="bottom-right"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FloatingSaveStatus({
|
||||
isSaving,
|
||||
lastSavedAt,
|
||||
error,
|
||||
onSaveNow,
|
||||
className,
|
||||
position = 'bottom-right'
|
||||
}: SaveStatusIndicatorProps & {
|
||||
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
}) {
|
||||
const [relativeTime, setRelativeTime] = useState<string>('');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastSavedAt) {
|
||||
setRelativeTime('');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTime = () => {
|
||||
setRelativeTime(formatRelativeTime(lastSavedAt));
|
||||
};
|
||||
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [lastSavedAt]);
|
||||
|
||||
const status = error ? 'error' : isSaving ? 'saving' : 'saved';
|
||||
|
||||
// Auto-expand on error
|
||||
useEffect(() => {
|
||||
if (status === 'error') {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Position classes
|
||||
const positionClasses = {
|
||||
'top-left': 'top-4 left-4',
|
||||
'top-right': 'top-4 right-4',
|
||||
'bottom-left': 'bottom-4 left-4',
|
||||
'bottom-right': 'bottom-4 right-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-40 transition-all',
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsExpanded(true)}
|
||||
onMouseLeave={() => status !== 'error' && setIsExpanded(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full border bg-background/95 backdrop-blur-sm shadow-lg transition-all',
|
||||
isExpanded ? 'px-3 py-2' : 'p-2'
|
||||
)}
|
||||
>
|
||||
{/* Status Icon */}
|
||||
{status === 'saving' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
)}
|
||||
|
||||
{status === 'saved' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{status === 'saving' && '保存中...'}
|
||||
{status === 'saved' && `已保存 ${relativeTime || ''}`}
|
||||
{status === 'error' && '保存失败'}
|
||||
</span>
|
||||
|
||||
{status === 'error' && onSaveNow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={onSaveNow}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/canvas/UserAvatar.test.tsx
Normal file
60
frontend/src/components/canvas/UserAvatar.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
// Mock the auth store
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/lib/api/auth', () => ({
|
||||
uploadAvatar: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
it('renders login button when not authenticated', () => {
|
||||
(useAuthStore as any).mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<UserAvatar />);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('renders user initials when authenticated without avatar', () => {
|
||||
(useAuthStore as any).mockReturnValue({
|
||||
user: { username: 'john', email: 'john@example.com', avatarUrl: undefined },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
logout: vi.fn(),
|
||||
fetchSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(<UserAvatar />);
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders avatar image when user has avatar', () => {
|
||||
(useAuthStore as any).mockReturnValue({
|
||||
user: {
|
||||
username: 'john',
|
||||
email: 'john@example.com',
|
||||
avatarUrl: 'https://example.com/avatar.png'
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
logout: vi.fn(),
|
||||
fetchSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(<UserAvatar />);
|
||||
const img = screen.getByAltText('john');
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.png');
|
||||
});
|
||||
});
|
||||
225
frontend/src/components/canvas/UserAvatar.tsx
Normal file
225
frontend/src/components/canvas/UserAvatar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { User, Settings, LogOut, CreditCard, LogIn, Camera, X, Shield } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getAccessToken } from '@/lib/auth/tokenManager';
|
||||
import { useUpload } from '@/hooks/useUpload';
|
||||
import { getAvatarUploadUrl } from '@/lib/api/urls';
|
||||
import { toast } from 'sonner';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
|
||||
export function UserAvatar() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, isLoading, logout, fetchSession, updateAvatar } = useAuthStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
|
||||
const { progress, isUploading, upload, cancel } = useUpload({
|
||||
onSuccess: (data: unknown) => {
|
||||
const payload = data && typeof data === 'object' && 'data' in data && data.data && typeof data.data === 'object' && 'avatarUrl' in data.data
|
||||
? (data.data as { avatarUrl?: string })
|
||||
: null;
|
||||
if (payload?.avatarUrl) updateAvatar(payload.avatarUrl);
|
||||
toast.success('头像更新成功');
|
||||
setShowProgress(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('头像上传失败');
|
||||
logger.error('Avatar upload error:', error);
|
||||
setShowProgress(false);
|
||||
},
|
||||
onCancel: () => {
|
||||
toast.info('上传已取消');
|
||||
setShowProgress(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch user info on mount if authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !user) {
|
||||
fetchSession();
|
||||
}
|
||||
}, [isAuthenticated, user, fetchSession]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
// Get user initials for avatar
|
||||
const getInitials = () => {
|
||||
if (user?.username) {
|
||||
return user.username.charAt(0).toUpperCase();
|
||||
}
|
||||
return 'U';
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('文件大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowProgress(true);
|
||||
const token = getAccessToken();
|
||||
if (!user?.id) {
|
||||
toast.error('用户未登录');
|
||||
return;
|
||||
}
|
||||
await upload(file, {
|
||||
endpoint: getAvatarUploadUrl(user.id),
|
||||
onProgress: (p) => console.log('[Upload]', p + '%'),
|
||||
});
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
cancel();
|
||||
setShowProgress(false);
|
||||
};
|
||||
|
||||
// Not authenticated - show login button
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
className="relative w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 border border-white/10 shadow-lg flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-white/20 transition-all outline-none group"
|
||||
>
|
||||
<LogIn className="h-4 w-4 text-white group-hover:scale-110 transition-transform" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="relative w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 border border-white/10 shadow-lg flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-white/20 transition-all outline-none group">
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.username}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-white group-hover:scale-110 transition-transform">
|
||||
{getInitials()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black/90 border-white/10 text-white backdrop-blur-xl" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none text-white">
|
||||
{user?.username || 'User'}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user?.email || ''}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-white/10" />
|
||||
<DropdownMenuGroup>
|
||||
{showProgress ? (
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-400">上传中...</span>
|
||||
<button
|
||||
onClick={handleCancelUpload}
|
||||
className="text-xs text-red-400 hover:text-red-300 flex items-center gap-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<Progress value={progress} size="sm" showLabel />
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="focus:bg-white/10 focus:text-white cursor-pointer"
|
||||
onClick={handleAvatarClick}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
<span>更换头像</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="focus:bg-white/10 focus:text-white cursor-pointer" disabled>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>个人资料</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:bg-white/10 focus:text-white cursor-pointer" disabled>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>计费</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="focus:bg-white/10 focus:text-white cursor-pointer"
|
||||
onClick={() => useUIStore.getState().openSettings()}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>设置</span>
|
||||
</DropdownMenuItem>
|
||||
{user?.isSuperuser && (
|
||||
<DropdownMenuItem
|
||||
className="focus:bg-white/10 focus:text-white cursor-pointer"
|
||||
onClick={() => router.push('/admin')}
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>管理后台</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="bg-white/10" />
|
||||
<DropdownMenuItem
|
||||
className="focus:bg-white/10 focus:text-white cursor-pointer text-red-400 focus:text-red-400"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoading ? '退出中...' : '退出登录'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/canvas/commands/AddEdgeCommand.ts
Normal file
30
frontend/src/components/canvas/commands/AddEdgeCommand.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Command } from './Command';
|
||||
import { Edge } from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* Command to add an edge to the canvas
|
||||
*/
|
||||
export class AddEdgeCommand implements Command {
|
||||
private edge: Edge;
|
||||
private setEdges: (updater: (edges: Edge[]) => Edge[]) => void;
|
||||
|
||||
constructor(
|
||||
edge: Edge,
|
||||
setEdges: (updater: (edges: Edge[]) => Edge[]) => void
|
||||
) {
|
||||
this.edge = edge;
|
||||
this.setEdges = setEdges;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.setEdges((edges) => [...edges, this.edge]);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.setEdges((edges) => edges.filter(e => e.id !== this.edge.id));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Add edge: ${this.edge.id}`;
|
||||
}
|
||||
}
|
||||
30
frontend/src/components/canvas/commands/AddNodeCommand.ts
Normal file
30
frontend/src/components/canvas/commands/AddNodeCommand.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Command } from './Command';
|
||||
import { AppNodeType } from '../types/node';
|
||||
|
||||
/**
|
||||
* Command to add a node to the canvas
|
||||
*/
|
||||
export class AddNodeCommand implements Command {
|
||||
private node: AppNodeType;
|
||||
private setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void;
|
||||
|
||||
constructor(
|
||||
node: AppNodeType,
|
||||
setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void
|
||||
) {
|
||||
this.node = node;
|
||||
this.setNodes = setNodes;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.setNodes((nodes) => [...nodes, this.node]);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.setNodes((nodes) => nodes.filter(n => n.id !== this.node.id));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Add node: ${this.node.id}`;
|
||||
}
|
||||
}
|
||||
20
frontend/src/components/canvas/commands/Command.ts
Normal file
20
frontend/src/components/canvas/commands/Command.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Command interface for implementing undo/redo functionality
|
||||
* Based on the Command Pattern
|
||||
*/
|
||||
export interface Command {
|
||||
/**
|
||||
* Execute the command
|
||||
*/
|
||||
execute(): void;
|
||||
|
||||
/**
|
||||
* Undo the command
|
||||
*/
|
||||
undo(): void;
|
||||
|
||||
/**
|
||||
* Optional: Get a description of the command for debugging
|
||||
*/
|
||||
getDescription?(): string;
|
||||
}
|
||||
101
frontend/src/components/canvas/commands/CommandManager.ts
Normal file
101
frontend/src/components/canvas/commands/CommandManager.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Command } from './Command';
|
||||
|
||||
/**
|
||||
* CommandManager manages the execution and history of commands
|
||||
* Provides undo/redo functionality
|
||||
*/
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
private currentIndex = -1;
|
||||
private maxHistorySize: number;
|
||||
|
||||
constructor(maxHistorySize: number = 50) {
|
||||
this.maxHistorySize = maxHistorySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and add it to history
|
||||
*/
|
||||
execute(command: Command): void {
|
||||
command.execute();
|
||||
|
||||
// Remove any commands after current index (redo history)
|
||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
||||
|
||||
// Add new command
|
||||
this.history.push(command);
|
||||
this.currentIndex++;
|
||||
|
||||
// Limit history size
|
||||
if (this.history.length > this.maxHistorySize) {
|
||||
this.history.shift();
|
||||
this.currentIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last command
|
||||
*/
|
||||
undo(): boolean {
|
||||
if (!this.canUndo()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = this.history[this.currentIndex];
|
||||
command.undo();
|
||||
this.currentIndex--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the next command
|
||||
*/
|
||||
redo(): boolean {
|
||||
if (!this.canRedo()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
const command = this.history[this.currentIndex];
|
||||
command.execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo is available
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.currentIndex >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo is available
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.currentIndex < this.history.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all history
|
||||
*/
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current history size
|
||||
*/
|
||||
getHistorySize(): number {
|
||||
return this.history.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current index in history
|
||||
*/
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
}
|
||||
38
frontend/src/components/canvas/commands/DeleteEdgeCommand.ts
Normal file
38
frontend/src/components/canvas/commands/DeleteEdgeCommand.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Command } from './Command';
|
||||
import { Edge } from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* Command to delete an edge from the canvas
|
||||
*/
|
||||
export class DeleteEdgeCommand implements Command {
|
||||
private edgeId: string;
|
||||
private deletedEdge: Edge | null = null;
|
||||
private setEdges: (updater: (edges: Edge[]) => Edge[]) => void;
|
||||
private getEdges: () => Edge[];
|
||||
|
||||
constructor(
|
||||
edgeId: string,
|
||||
setEdges: (updater: (edges: Edge[]) => Edge[]) => void,
|
||||
getEdges: () => Edge[]
|
||||
) {
|
||||
this.edgeId = edgeId;
|
||||
this.setEdges = setEdges;
|
||||
this.getEdges = getEdges;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const edges = this.getEdges();
|
||||
this.deletedEdge = edges.find(e => e.id === this.edgeId) || null;
|
||||
this.setEdges((edges) => edges.filter(e => e.id !== this.edgeId));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.deletedEdge) {
|
||||
this.setEdges((edges) => [...edges, this.deletedEdge!]);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Delete edge: ${this.edgeId}`;
|
||||
}
|
||||
}
|
||||
56
frontend/src/components/canvas/commands/DeleteNodeCommand.ts
Normal file
56
frontend/src/components/canvas/commands/DeleteNodeCommand.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Command } from './Command';
|
||||
import { AppNodeType } from '../types/node';
|
||||
import { Edge } from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* Command to delete a node from the canvas
|
||||
*/
|
||||
export class DeleteNodeCommand implements Command {
|
||||
private nodeId: string;
|
||||
private deletedNode: AppNodeType | null = null;
|
||||
private deletedEdges: Edge[] = [];
|
||||
private setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void;
|
||||
private setEdges: (updater: (edges: Edge[]) => Edge[]) => void;
|
||||
private getNodes: () => AppNodeType[];
|
||||
private getEdges: () => Edge[];
|
||||
|
||||
constructor(
|
||||
nodeId: string,
|
||||
setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void,
|
||||
setEdges: (updater: (edges: Edge[]) => Edge[]) => void,
|
||||
getNodes: () => AppNodeType[],
|
||||
getEdges: () => Edge[]
|
||||
) {
|
||||
this.nodeId = nodeId;
|
||||
this.setNodes = setNodes;
|
||||
this.setEdges = setEdges;
|
||||
this.getNodes = getNodes;
|
||||
this.getEdges = getEdges;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const nodes = this.getNodes();
|
||||
const edges = this.getEdges();
|
||||
|
||||
// Store the node and connected edges for undo
|
||||
this.deletedNode = nodes.find(n => n.id === this.nodeId) || null;
|
||||
this.deletedEdges = edges.filter(e => e.source === this.nodeId || e.target === this.nodeId);
|
||||
|
||||
// 删除 node and connected edges
|
||||
this.setNodes((nodes) => nodes.filter(n => n.id !== this.nodeId));
|
||||
this.setEdges((edges) => edges.filter(e => e.source !== this.nodeId && e.target !== this.nodeId));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.deletedNode) {
|
||||
this.setNodes((nodes) => [...nodes, this.deletedNode!]);
|
||||
}
|
||||
if (this.deletedEdges.length > 0) {
|
||||
this.setEdges((edges) => [...edges, ...this.deletedEdges]);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Delete node: ${this.nodeId}`;
|
||||
}
|
||||
}
|
||||
49
frontend/src/components/canvas/commands/MoveNodeCommand.ts
Normal file
49
frontend/src/components/canvas/commands/MoveNodeCommand.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Command } from './Command';
|
||||
import { AppNodeType } from '../types/node';
|
||||
import { XYPosition } from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* Command to move a node
|
||||
*/
|
||||
export class MoveNodeCommand implements Command {
|
||||
private nodeId: string;
|
||||
private oldPosition: XYPosition;
|
||||
private newPosition: XYPosition;
|
||||
private setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void;
|
||||
|
||||
constructor(
|
||||
nodeId: string,
|
||||
oldPosition: XYPosition,
|
||||
newPosition: XYPosition,
|
||||
setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void
|
||||
) {
|
||||
this.nodeId = nodeId;
|
||||
this.oldPosition = oldPosition;
|
||||
this.newPosition = newPosition;
|
||||
this.setNodes = setNodes;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.setNodes((nodes) =>
|
||||
nodes.map(n =>
|
||||
n.id === this.nodeId
|
||||
? { ...n, position: this.newPosition }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.setNodes((nodes) =>
|
||||
nodes.map(n =>
|
||||
n.id === this.nodeId
|
||||
? { ...n, position: this.oldPosition }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Move node: ${this.nodeId}`;
|
||||
}
|
||||
}
|
||||
57
frontend/src/components/canvas/commands/UpdateNodeCommand.ts
Normal file
57
frontend/src/components/canvas/commands/UpdateNodeCommand.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Command } from './Command';
|
||||
import { AppNodeType, AppNodeData } from '../types/node';
|
||||
|
||||
/**
|
||||
* Command to update a node's data
|
||||
*/
|
||||
export class UpdateNodeCommand implements Command {
|
||||
private nodeId: string;
|
||||
private oldData: Partial<AppNodeData>;
|
||||
private newData: Partial<AppNodeData>;
|
||||
private setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void;
|
||||
private getNodes: () => AppNodeType[];
|
||||
|
||||
constructor(
|
||||
nodeId: string,
|
||||
newData: Partial<AppNodeData>,
|
||||
setNodes: (updater: (nodes: AppNodeType[]) => AppNodeType[]) => void,
|
||||
getNodes: () => AppNodeType[]
|
||||
) {
|
||||
this.nodeId = nodeId;
|
||||
this.newData = newData;
|
||||
this.setNodes = setNodes;
|
||||
this.getNodes = getNodes;
|
||||
|
||||
// Store old data for undo
|
||||
const node = getNodes().find(n => n.id === nodeId);
|
||||
if (node && node.type === 'appNode') {
|
||||
this.oldData = { ...node.data };
|
||||
} else {
|
||||
this.oldData = {};
|
||||
}
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.setNodes((nodes) =>
|
||||
nodes.map(n =>
|
||||
n.id === this.nodeId && n.type === 'appNode'
|
||||
? { ...n, data: { ...n.data, ...this.newData } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.setNodes((nodes) =>
|
||||
nodes.map(n =>
|
||||
n.id === this.nodeId && n.type === 'appNode'
|
||||
? { ...n, data: { ...n.data, ...this.oldData } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Update node: ${this.nodeId}`;
|
||||
}
|
||||
}
|
||||
8
frontend/src/components/canvas/commands/index.ts
Normal file
8
frontend/src/components/canvas/commands/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { Command } from './Command';
|
||||
export { CommandManager } from './CommandManager';
|
||||
export { AddNodeCommand } from './AddNodeCommand';
|
||||
export { DeleteNodeCommand } from './DeleteNodeCommand';
|
||||
export { UpdateNodeCommand } from './UpdateNodeCommand';
|
||||
export { MoveNodeCommand } from './MoveNodeCommand';
|
||||
export { AddEdgeCommand } from './AddEdgeCommand';
|
||||
export { DeleteEdgeCommand } from './DeleteEdgeCommand';
|
||||
23
frontend/src/components/canvas/components/CanvasEdges.tsx
Normal file
23
frontend/src/components/canvas/components/CanvasEdges.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Edge, EdgeTypes } from '@xyflow/react';
|
||||
import { GradientEdge } from '../edges/GradientEdge';
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
gradient: GradientEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
|
||||
interface CanvasEdgesProps {
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
function CanvasEdgesComponent({ edges }: CanvasEdgesProps) {
|
||||
// This component is used to define edge types
|
||||
// Actual edge rendering is handled by ReactFlow in CanvasNodes
|
||||
return null;
|
||||
}
|
||||
|
||||
export { edgeTypes };
|
||||
export const CanvasEdges = memo(CanvasEdgesComponent);
|
||||
98
frontend/src/components/canvas/components/CanvasNodes.tsx
Normal file
98
frontend/src/components/canvas/components/CanvasNodes.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { ReactFlow, Background, BackgroundVariant, MiniMap, NodeTypes } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { AppNode } from '../nodes/AppNode';
|
||||
import { AppNodeType } from '../types/node';
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
appNode: AppNode,
|
||||
};
|
||||
|
||||
interface CanvasNodesProps {
|
||||
nodes: AppNodeType[];
|
||||
edges: any[];
|
||||
onNodesChange: (changes: any[]) => void;
|
||||
onEdgesChange: (changes: any[]) => void;
|
||||
onConnect: (params: any) => void;
|
||||
isValidConnection?: (connection: any) => boolean;
|
||||
onNodeDrag?: (event: any, node: any, nodes: any[]) => void;
|
||||
onNodeDragStart?: (event: any, node: any) => void;
|
||||
onNodeContextMenu?: (event: React.MouseEvent, node: AppNodeType) => void;
|
||||
onEdgeContextMenu?: (event: React.MouseEvent, edge: any) => void;
|
||||
onPaneContextMenu?: (event: React.MouseEvent | MouseEvent) => void;
|
||||
onPaneClick?: () => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
defaultEdgeOptions?: any;
|
||||
isMiniMapOpen?: boolean;
|
||||
}
|
||||
|
||||
function CanvasNodesComponent({
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
isValidConnection,
|
||||
onNodeDrag,
|
||||
onNodeDragStart,
|
||||
onNodeContextMenu,
|
||||
onEdgeContextMenu,
|
||||
onPaneContextMenu,
|
||||
onPaneClick,
|
||||
onDoubleClick,
|
||||
defaultEdgeOptions,
|
||||
isMiniMapOpen = false,
|
||||
}: CanvasNodesProps) {
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes.filter((n) => n.type === 'appNode')}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onPaneClick={onPaneClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.75 }}
|
||||
className="bg-black"
|
||||
colorMode="dark"
|
||||
minZoom={0.1}
|
||||
maxZoom={4}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
connectionLineStyle={{ stroke: 'rgba(255,255,255,0.8)', strokeWidth: 2 }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={32}
|
||||
size={1}
|
||||
color="#aaa"
|
||||
className="opacity-[0.06]"
|
||||
/>
|
||||
{isMiniMapOpen && (
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
style={{ width: 240, height: 140 }}
|
||||
className="!left-6 !bottom-20 !m-0 bg-black/40 backdrop-blur-md border border-white/10 rounded-xl overflow-hidden shadow-xl z-50 animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
nodeColor={() => '#3b82f6'}
|
||||
maskColor="rgba(0, 0, 0, 0.6)"
|
||||
nodeStrokeColor="transparent"
|
||||
nodeBorderRadius={4}
|
||||
zoomable
|
||||
pannable
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export const CanvasNodes = memo(CanvasNodesComponent);
|
||||
2
frontend/src/components/canvas/components/index.ts
Normal file
2
frontend/src/components/canvas/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CanvasNodes } from './CanvasNodes';
|
||||
export { CanvasEdges, edgeTypes } from './CanvasEdges';
|
||||
36
frontend/src/components/canvas/constants.ts
Normal file
36
frontend/src/components/canvas/constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const IMAGE_ASPECT_RATIOS = ['1:1', '3:4', '4:3', '9:16', '16:9'];
|
||||
export const VIDEO_ASPECT_RATIOS = ['1:1', '3:4', '4:3', '9:16', '16:9'];
|
||||
export const VIDEO_RESOLUTIONS = ['720P', '1080P'];
|
||||
export const IMAGE_RESOLUTIONS = ['1K', '2K', '4K'];
|
||||
export const VIDEO_DURATIONS = ['2s', '3s', '4s', '5s', '6s', '7s', '8s', '10s'];
|
||||
export const IMAGE_COUNTS = [1, 2, 3, 4];
|
||||
export const VIDEO_COUNTS = [1, 2, 3, 4];
|
||||
|
||||
export const IMAGE_TEMPLATES = [
|
||||
{ value: 'general', label: '通用' },
|
||||
{ value: 'character_white_bg', label: '角色白底图' },
|
||||
{ value: 'character_three_view', label: '角色三视图' },
|
||||
{ value: 'storyboard_integrated', label: '分镜出图' }
|
||||
];
|
||||
|
||||
export const VIDEO_TEMPLATES = [
|
||||
{ value: 'general', label: '通用' },
|
||||
{ value: 'asset_360', label: '360度' },
|
||||
{ value: 'storyboard_video_integrated', label: '分镜视频' }
|
||||
];
|
||||
|
||||
export const IMAGE_STYLES = [
|
||||
{ value: '', label: '无风格' },
|
||||
{ value: 'Cinematic, Photorealistic, 8k, highly detailed', label: '写实摄影' },
|
||||
{ value: 'Anime style, vibrant colors, clean lines, high quality', label: '动漫' },
|
||||
{ value: 'Cyberpunk, neon lights, futuristic, high tech, sci-fi', label: '赛博朋克' },
|
||||
{ value: 'Oil painting, textured, brush strokes, artistic, masterpiece', label: '油画' },
|
||||
{ value: 'Watercolor, soft edges, artistic, dreamy, pastel colors', label: '水彩' },
|
||||
{ value: '3D render, unreal engine 5, octane render, highly detailed, CGI', label: '3D渲染' },
|
||||
{ value: 'Pencil sketch, black and white, artistic, detailed', label: '素描' },
|
||||
{ value: 'Concept art, digital painting, fantasy, magical, detailed', label: '概念艺术' },
|
||||
{ value: 'Chinese traditional painting, ink wash, artistic, elegance', label: '水墨画' },
|
||||
{ value: 'Pixel art, 16-bit, retro game style', label: '像素风' }
|
||||
];
|
||||
|
||||
export const GLASS_PANEL = "bg-muted/95 backdrop-blur-2xl border border-white/10 shadow-2xl";
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Layers,
|
||||
Play,
|
||||
X,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { useCanvasStore } from '@/lib/store/canvasStore';
|
||||
import { BatchService } from '@/lib/api';
|
||||
import { AppNodeType, NodeType } from '../types/node';
|
||||
|
||||
interface BatchGenerationToolbarProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BatchGenerationToolbar({ onClose }: BatchGenerationToolbarProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
taskCount?: number;
|
||||
} | null>(null);
|
||||
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const selectedNodes = nodes.filter(n => n.selected);
|
||||
|
||||
// 获取可生成的节点(有生成类型的节点)
|
||||
const generatableNodes = selectedNodes.filter(node => {
|
||||
if (node.type !== 'appNode') return false;
|
||||
const nodeData = node.data;
|
||||
return nodeData.type === NodeType.IMAGE_GENERATOR ||
|
||||
nodeData.type === NodeType.VIDEO_GENERATOR ||
|
||||
nodeData.type === NodeType.AUDIO_GENERATOR;
|
||||
});
|
||||
|
||||
const handleBatchGenerate = useCallback(async () => {
|
||||
if (generatableNodes.length === 0) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
// 构建批量生成请求
|
||||
const items = generatableNodes.map(node => {
|
||||
const data = node.data;
|
||||
// 将 NodeType 映射为 API 类型
|
||||
const typeMap: Record<string, string> = {
|
||||
'IMAGE_GENERATOR': 'image',
|
||||
'VIDEO_GENERATOR': 'video',
|
||||
'AUDIO_GENERATOR': 'audio'
|
||||
};
|
||||
const type = data.outputType || typeMap[data.type] || 'image';
|
||||
|
||||
return {
|
||||
type: type,
|
||||
prompt: data.prompt || data.title || '',
|
||||
model: data.model,
|
||||
aspect_ratio: data.aspectRatio || '16:9',
|
||||
resolution: data.resolution || '1K',
|
||||
duration: data.duration,
|
||||
voice: data.voice,
|
||||
image_inputs: data.imageInputs || [],
|
||||
extra_params: data.extraParams || {},
|
||||
source: 'canvas',
|
||||
source_id: node.id
|
||||
};
|
||||
});
|
||||
|
||||
const job = await BatchService.createJob(items);
|
||||
|
||||
setResult({
|
||||
success: job.status !== 'failed',
|
||||
message: `成功创建批量任务 (ID: ${job.id})`,
|
||||
taskCount: job.total
|
||||
});
|
||||
|
||||
// 3秒后自动关闭
|
||||
if (job.status !== 'failed') {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Batch generation failed:', error);
|
||||
setResult({
|
||||
success: false,
|
||||
message: '批量生成失败,请重试'
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [generatableNodes, onClose]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-4 min-w-[400px]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<Layers className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">批量生成</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {generatableNodes.length} 个生成节点
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 节点列表预览 */}
|
||||
{generatableNodes.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-2 mb-4">
|
||||
{generatableNodes.map((node, idx) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground w-6">{idx + 1}</span>
|
||||
<span className="text-sm text-white truncate flex-1">
|
||||
{node.data.title || node.data.prompt || '未命名节点'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
node.data.outputType === 'image' || node.data.type === NodeType.IMAGE_GENERATOR
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: node.data.outputType === 'video' || node.data.type === NodeType.VIDEO_GENERATOR
|
||||
? "bg-purple-500/20 text-purple-400"
|
||||
: node.data.outputType === 'audio' || node.data.type === NodeType.AUDIO_GENERATOR
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
)}>
|
||||
{node.data.outputType ? String(node.data.outputType) : String(node.data.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">没有选择可生成的节点</p>
|
||||
<p className="text-xs opacity-60">请选择包含生成配置的节点</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果提示 */}
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={cn(
|
||||
"p-3 rounded-lg mb-4 flex items-center gap-2",
|
||||
result.success
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-red-500/10 border border-red-500/30"
|
||||
)}
|
||||
>
|
||||
{result.success ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
result.success ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{result.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm text-muted-foreground hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchGenerate}
|
||||
disabled={generatableNodes.length === 0 || isGenerating}
|
||||
className={cn(
|
||||
"flex-[2] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-xl transition-all",
|
||||
generatableNodes.length === 0 || isGenerating
|
||||
? "bg-white/5 text-muted-foreground cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90"
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
开始批量生成 ({generatableNodes.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/canvas/controls/CanvasContextMenu.tsx
Normal file
195
frontend/src/components/canvas/controls/CanvasContextMenu.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2, Copy, RefreshCw, Clipboard, Unplug, Music2, Folder, Ungroup
|
||||
} from 'lucide-react';
|
||||
import { NodeType, AppNodeType, AppNodeData, ContextMenuState, ContextMenuTarget } from '../types/node';
|
||||
import { getNodeIcon, getNodeNameCN } from '../utils';
|
||||
|
||||
interface CanvasContextMenuProps {
|
||||
contextMenu: ContextMenuState;
|
||||
target: ContextMenuTarget;
|
||||
nodes: AppNodeType[];
|
||||
clipboard: AppNodeType | null;
|
||||
|
||||
onClose: () => void;
|
||||
|
||||
// Actions
|
||||
onCreateNode: (type: NodeType, data?: Partial<AppNodeData>) => void;
|
||||
onCreateAndConnect: (type: NodeType, data?: Partial<AppNodeData>) => void;
|
||||
|
||||
// Node Actions
|
||||
onCopyNode: (nodeId: string) => void;
|
||||
onReplaceMedia: (nodeId: string, mediaType: 'image' | 'video') => void;
|
||||
onDeleteNode: (nodeId: string) => void;
|
||||
|
||||
// Global Actions
|
||||
onPaste: () => void;
|
||||
|
||||
// Connection Actions
|
||||
onDeleteEdge: (edgeId: string) => void;
|
||||
|
||||
// Group Actions
|
||||
onCreateGroup?: () => void;
|
||||
onUngroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
}
|
||||
|
||||
export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = ({
|
||||
contextMenu,
|
||||
target,
|
||||
nodes,
|
||||
clipboard,
|
||||
onClose,
|
||||
onCreateNode,
|
||||
onCreateAndConnect,
|
||||
onCopyNode,
|
||||
onReplaceMedia,
|
||||
onDeleteNode,
|
||||
onPaste,
|
||||
onDeleteEdge,
|
||||
onCreateGroup,
|
||||
onUngroup,
|
||||
onDeleteGroup
|
||||
}) => {
|
||||
const createNodeOptions: Array<{
|
||||
key: string;
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
data?: Partial<AppNodeData>;
|
||||
}> = [
|
||||
{ key: NodeType.PROMPT_INPUT, type: NodeType.PROMPT_INPUT, label: getNodeNameCN(NodeType.PROMPT_INPUT), icon: getNodeIcon(NodeType.PROMPT_INPUT) },
|
||||
{ key: NodeType.LYRICS_GENERATOR, type: NodeType.LYRICS_GENERATOR, label: getNodeNameCN(NodeType.LYRICS_GENERATOR), icon: getNodeIcon(NodeType.LYRICS_GENERATOR), data: { title: '歌词生成' } },
|
||||
{ key: NodeType.PROMPT_GENERATOR, type: NodeType.PROMPT_GENERATOR, label: getNodeNameCN(NodeType.PROMPT_GENERATOR), icon: getNodeIcon(NodeType.PROMPT_GENERATOR) },
|
||||
{ key: NodeType.IMAGE_GENERATOR, type: NodeType.IMAGE_GENERATOR, label: getNodeNameCN(NodeType.IMAGE_GENERATOR), icon: getNodeIcon(NodeType.IMAGE_GENERATOR) },
|
||||
{ key: NodeType.VIDEO_GENERATOR, type: NodeType.VIDEO_GENERATOR, label: getNodeNameCN(NodeType.VIDEO_GENERATOR), icon: getNodeIcon(NodeType.VIDEO_GENERATOR) },
|
||||
{ key: NodeType.AUDIO_GENERATOR, type: NodeType.AUDIO_GENERATOR, label: getNodeNameCN(NodeType.AUDIO_GENERATOR), icon: getNodeIcon(NodeType.AUDIO_GENERATOR), data: { generationType: 'audio' } },
|
||||
{ key: `${NodeType.AUDIO_GENERATOR}-music`, type: NodeType.AUDIO_GENERATOR, label: '音乐生成', icon: Music2, data: { generationType: 'music', title: '音乐生成' } },
|
||||
{ key: NodeType.VIDEO_ANALYZER, type: NodeType.VIDEO_ANALYZER, label: getNodeNameCN(NodeType.VIDEO_ANALYZER), icon: getNodeIcon(NodeType.VIDEO_ANALYZER) },
|
||||
{ key: NodeType.IMAGE_EDITOR, type: NodeType.IMAGE_EDITOR, label: getNodeNameCN(NodeType.IMAGE_EDITOR), icon: getNodeIcon(NodeType.IMAGE_EDITOR) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[100] bg-card/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-1.5 min-w-[160px] animate-in fade-in zoom-in-95 duration-200 origin-top-left"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{target.type === 'handle-create' && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">添加并连接</div>
|
||||
{createNodeOptions.map((option) => {
|
||||
const ItemIcon = option.icon;
|
||||
return (
|
||||
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => { onCreateAndConnect(option.type, option.data); onClose(); }}>
|
||||
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{target.type === 'node' && target.id && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onCopyNode(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Copy size={12} /> 复制节点
|
||||
</button>
|
||||
{(() => {
|
||||
const targetNode = nodes.find(n => n.id === target.id);
|
||||
if (targetNode && targetNode.type === 'appNode') {
|
||||
const data = targetNode.data as AppNodeData;
|
||||
const isVideo = data.type === NodeType.VIDEO_GENERATOR || data.type === NodeType.VIDEO_ANALYZER;
|
||||
const isImage = data.type === NodeType.IMAGE_GENERATOR || data.type === NodeType.IMAGE_EDITOR;
|
||||
if (isVideo || isImage) {
|
||||
return (
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-purple-500/20 hover:text-purple-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onReplaceMedia(target.id!, isVideo ? 'video' : 'image');
|
||||
onClose();
|
||||
}}>
|
||||
<RefreshCw size={12} /> 替换素材
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
|
||||
onDeleteNode(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Trash2 size={12} /> 删除节点
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(target.type === 'create' || target.type === 'pane') && (
|
||||
<>
|
||||
{clipboard && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
|
||||
onPaste();
|
||||
onClose();
|
||||
}}>
|
||||
<Clipboard size={12} className="text-cyan-400" /> 粘贴节点
|
||||
</button>
|
||||
<div className="w-full h-px bg-white/10 my-1"></div>
|
||||
</>
|
||||
)}
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">创建新节点</div>
|
||||
{createNodeOptions.map((option) => {
|
||||
const ItemIcon = option.icon;
|
||||
return (
|
||||
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
|
||||
onCreateNode(option.type, option.data);
|
||||
onClose();
|
||||
}}>
|
||||
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{target.type === 'connection' && target.id && (
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onDeleteEdge(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Unplug size={12} /> 删除连接线
|
||||
</button>
|
||||
)}
|
||||
|
||||
{target.type === 'group' && target.id && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onUngroup?.(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Ungroup size={12} /> 解散分组
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
|
||||
onDeleteGroup?.(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Trash2 size={12} /> 删除分组
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(target.type === 'create' || target.type === 'pane') && onCreateGroup && (
|
||||
<>
|
||||
<div className="w-full h-px bg-white/10 my-1"></div>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onCreateGroup();
|
||||
onClose();
|
||||
}}>
|
||||
<Folder size={12} /> 创建分组
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
frontend/src/components/canvas/controls/CanvasShortcuts.tsx
Normal file
78
frontend/src/components/canvas/controls/CanvasShortcuts.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export function CanvasShortcuts() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const shortcuts = [
|
||||
{ key: '⌘/Ctrl + S', desc: '保存' },
|
||||
{ key: '⌘/Ctrl + Z', desc: '撤销' },
|
||||
{ key: '⌘/Ctrl + ⇧ + Z', desc: '重做' },
|
||||
{ key: '⌘/Ctrl + A', desc: '全选' },
|
||||
{ key: '⌘/Ctrl + D', desc: '复制选中' },
|
||||
{ key: '⌘/Ctrl + G', desc: '创建分组' },
|
||||
{ key: '⌘/Ctrl + Q', desc: '生成队列' },
|
||||
{ key: '⌘/Ctrl + B', desc: '批量生成' },
|
||||
{ key: '⌘/Ctrl + T', desc: '提示词模板库' },
|
||||
{ key: '⌘/Ctrl + C', desc: '复制' },
|
||||
{ key: '⌘/Ctrl + V', desc: '粘贴' },
|
||||
{ key: 'Del / Backspace', desc: '删除' },
|
||||
{ key: 'Esc', desc: '取消选择' },
|
||||
{ key: 'Alt + ← / →', desc: '切换画布' },
|
||||
{ key: '⌘/Ctrl + 0', desc: '适应视图' },
|
||||
{ key: '⌘/Ctrl + +/-', desc: '缩放' },
|
||||
{ key: 'F11', desc: '全屏' },
|
||||
{ key: 'Space + 拖拽', desc: '平移画布' },
|
||||
{ key: 'Scroll', desc: '缩放画布' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"origin-bottom-right transition-all duration-300 ease-spring",
|
||||
isOpen
|
||||
? "opacity-100 scale-100 translate-y-0 mb-2"
|
||||
: "opacity-0 scale-90 translate-y-2 pointer-events-none absolute bottom-full right-0 mb-0"
|
||||
)}
|
||||
>
|
||||
<div className="w-64 p-4 bg-black/40 backdrop-blur-md border border-white/10 rounded-2xl shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-white/10">
|
||||
<span className="text-xs font-medium text-white/90">快捷键列表</span>
|
||||
<span className="text-[10px] text-muted-foreground">Keyboard Shortcuts</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-xs group">
|
||||
<span className="text-muted-foreground group-hover:text-white/80 transition-colors">{item.desc}</span>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 group-hover:bg-white/10 rounded border border-white/5 text-white/90 font-mono text-[10px] min-w-[20px] text-center transition-colors">
|
||||
{item.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"group relative p-2 rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95",
|
||||
isOpen
|
||||
? "bg-white text-black border-transparent"
|
||||
: "bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 text-muted-foreground hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={18} strokeWidth={2} />
|
||||
|
||||
{/* Tooltip */}
|
||||
{!isOpen && (
|
||||
<div className="absolute right-full mr-3 top-1/2 -translate-y-1/2 px-2 py-1 bg-black/90 backdrop-blur-md rounded-md border border-white/10 text-[10px] text-white font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
快捷键
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useReactFlow, useViewport } from '@xyflow/react';
|
||||
import { Plus, Minus, Scan, Map, List } from 'lucide-react';
|
||||
|
||||
interface CanvasViewportControlsProps {
|
||||
isMiniMapOpen?: boolean;
|
||||
onToggleMiniMap?: () => void;
|
||||
onToggleQueuePanel?: () => void;
|
||||
isQueuePanelOpen?: boolean;
|
||||
}
|
||||
|
||||
export function CanvasViewportControls({
|
||||
isMiniMapOpen = false,
|
||||
onToggleMiniMap,
|
||||
onToggleQueuePanel,
|
||||
isQueuePanelOpen = false
|
||||
}: CanvasViewportControlsProps) {
|
||||
const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 left-6 flex items-center justify-between w-[240px] px-3 py-1.5 bg-black/40 backdrop-blur-md border border-white/10 rounded-full shadow-xl z-50 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<button
|
||||
onClick={() => zoomOut({ duration: 300 })}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
>
|
||||
<Minus size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 justify-center px-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="4"
|
||||
step="0.1"
|
||||
value={zoom}
|
||||
onChange={(e) => zoomTo(parseFloat(e.target.value), { duration: 0 })}
|
||||
className="w-16 h-1 bg-white/20 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-lg hover:[&::-webkit-slider-thumb]:scale-125 transition-all shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-bold text-muted-foreground w-8 text-center tabular-nums cursor-pointer hover:text-foreground shrink-0"
|
||||
onClick={() => zoomTo(1, { duration: 300 })}
|
||||
title="Reset Zoom"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => zoomIn({ duration: 300 })}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
>
|
||||
<Plus size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
|
||||
<button
|
||||
onClick={() => fitView({
|
||||
duration: 300,
|
||||
padding: {
|
||||
top: 0.1,
|
||||
right: 0.15,
|
||||
bottom: 0.15,
|
||||
left: 0.12
|
||||
},
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2
|
||||
})}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
title="适配视图"
|
||||
>
|
||||
<Scan size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
{onToggleMiniMap && (
|
||||
<>
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
<button
|
||||
onClick={onToggleMiniMap}
|
||||
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
|
||||
isMiniMapOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
|
||||
}`}
|
||||
title={isMiniMapOpen ? "隐藏小地图" : "显示小地图"}
|
||||
>
|
||||
<Map size={12} strokeWidth={3} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleQueuePanel && (
|
||||
<>
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
<button
|
||||
onClick={onToggleQueuePanel}
|
||||
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
|
||||
isQueuePanelOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
|
||||
}`}
|
||||
title={isQueuePanelOpen ? "隐藏生成队列" : "显示生成队列"}
|
||||
>
|
||||
<List size={12} strokeWidth={3} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/canvas/controls/ConnectionMenu.tsx
Normal file
83
frontend/src/components/canvas/controls/ConnectionMenu.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Video, Type, Image as ImageIcon,
|
||||
Edit, Mic2, Info, FileSearch, Sparkles, ScrollText
|
||||
} from 'lucide-react';
|
||||
import { NodeType } from '../types/node';
|
||||
import { cn } from '@/utils';
|
||||
import { VALID_CONNECTIONS } from '../hooks/useSocketInteraction';
|
||||
|
||||
interface ConnectionMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
sourceType?: string;
|
||||
onSelect: (type: NodeType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NODE_OPTIONS = [
|
||||
{ type: NodeType.PROMPT_INPUT, label: 'Prompt Input', icon: Type, color: 'text-amber-400' },
|
||||
{ type: NodeType.LYRICS_GENERATOR, label: 'Lyrics Generator', icon: ScrollText, color: 'text-indigo-400' },
|
||||
{ type: NodeType.PROMPT_GENERATOR, label: 'Prompt Optimizer', icon: Sparkles, color: 'text-fuchsia-400' },
|
||||
{ type: NodeType.IMAGE_GENERATOR, label: 'Image Generator', icon: ImageIcon, color: 'text-cyan-400' },
|
||||
{ type: NodeType.VIDEO_GENERATOR, label: 'Video Generator', icon: Video, color: 'text-purple-400' },
|
||||
{ type: NodeType.AUDIO_GENERATOR, label: 'Audio Generator', icon: Mic2, color: 'text-pink-400' },
|
||||
{ type: NodeType.VIDEO_ANALYZER, label: 'Video Analyzer', icon: FileSearch, color: 'text-emerald-400' },
|
||||
{ type: NodeType.IMAGE_EDITOR, label: 'Image Editor', icon: Edit, color: 'text-rose-400' },
|
||||
{ type: NodeType.INFO_DISPLAY, label: 'Info Display', icon: Info, color: 'text-blue-400' },
|
||||
];
|
||||
|
||||
export const ConnectionMenu = ({ x, y, sourceType, onSelect, onClose }: ConnectionMenuProps) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter options based on source type validity
|
||||
const options = React.useMemo(() => {
|
||||
if (!sourceType) return NODE_OPTIONS;
|
||||
const validTargets = VALID_CONNECTIONS[sourceType] || [];
|
||||
return NODE_OPTIONS.filter(opt => validTargets.includes(opt.type));
|
||||
}, [sourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-black/80 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl p-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-200"
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold text-muted-foreground px-2 py-1 mb-1 uppercase tracking-wider">
|
||||
Add Node
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onSelect(option.type)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors text-left group"
|
||||
>
|
||||
<option.icon size={16} className={cn(option.color, "group-hover:scale-110 transition-transform")} />
|
||||
<span className="text-gray-200 font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
434
frontend/src/components/canvas/controls/ImageCropper.tsx
Normal file
434
frontend/src/components/canvas/controls/ImageCropper.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Check, Crop, Move } from 'lucide-react';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { useCanvasEvents } from '../hooks/useCanvasEvents';
|
||||
import { cropImage, type CropRect } from '@/utils/mediaUtils';
|
||||
|
||||
const RATIOS = [
|
||||
{ label: '自由', value: null },
|
||||
{ label: '16:9', value: 16/9 },
|
||||
{ label: '9:16', value: 9/16 },
|
||||
{ label: '4:3', value: 4/3 },
|
||||
{ label: '3:4', value: 3/4 },
|
||||
{ label: '1:1', value: 1 },
|
||||
];
|
||||
|
||||
type InteractionType = 'create' | 'move' | 'resize';
|
||||
type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
export const ImageCropper: React.FC = () => {
|
||||
const { cropRequest, setCropRequest } = useCanvasEvents();
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const imageSrc = cropRequest?.src || '';
|
||||
|
||||
const onConfirm = (croppedBase64: string) => {
|
||||
if (!cropRequest) return;
|
||||
setNodes((nds) => nds.map(n => n.id === cropRequest.nodeId && n.type === 'appNode' ? { ...n, data: { ...n.data, croppedFrame: croppedBase64 } } : n));
|
||||
setCropRequest(null);
|
||||
};
|
||||
|
||||
const onCancel = () => setCropRequest(null);
|
||||
|
||||
const [crop, setCrop] = useState<CropRect | null>(null);
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null); // null means free
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Interaction State
|
||||
const [interaction, setInteraction] = useState<{
|
||||
type: InteractionType;
|
||||
handle?: ResizeHandle;
|
||||
startPos: { x: number; y: number };
|
||||
startCrop: CropRect | null;
|
||||
}>({ type: 'create', startPos: { x: 0, y: 0 }, startCrop: null });
|
||||
|
||||
const getRelativePos = (e: React.MouseEvent | MouseEvent) => {
|
||||
if (!imgRef.current) return { x: 0, y: 0 };
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
rawX: e.clientX,
|
||||
rawY: e.clientY
|
||||
};
|
||||
};
|
||||
|
||||
// Helper: Constrain a rectangle within image bounds (maxW, maxH)
|
||||
// Ensures x, y >= 0 and x+w <= maxW, y+h <= maxH
|
||||
const clampRect = (rect: CropRect, maxW: number, maxH: number): CropRect => {
|
||||
let { x, y, width, height } = rect;
|
||||
|
||||
// Basic clamping
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
if (width > maxW) width = maxW;
|
||||
if (height > maxH) height = maxH;
|
||||
|
||||
if (x + width > maxW) x = maxW - width;
|
||||
if (y + height > maxH) y = maxH - height;
|
||||
|
||||
return { x, y, width, height };
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, type: InteractionType, handle?: ResizeHandle) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const pos = getRelativePos(e);
|
||||
|
||||
// If starting a NEW creation, clear the old crop unless we clicked on handles or existing crop
|
||||
let startCrop = crop;
|
||||
if (type === 'create') {
|
||||
startCrop = { x: pos.x, y: pos.y, width: 0, height: 0 };
|
||||
setCrop(startCrop);
|
||||
}
|
||||
|
||||
setInteraction({
|
||||
type,
|
||||
handle,
|
||||
startPos: { x: pos.x, y: pos.y },
|
||||
startCrop: startCrop ? { ...startCrop } : null
|
||||
});
|
||||
};
|
||||
|
||||
const handleGlobalMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!imgRef.current || !interaction.startCrop) return;
|
||||
|
||||
// Only process if mouse button is down (safety check)
|
||||
if (e.buttons === 0) {
|
||||
setInteraction(prev => ({ ...prev, type: 'create' })); // Reset to default
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to get relative pos inside callback
|
||||
const getPos = (evt: MouseEvent) => {
|
||||
if (!imgRef.current) return { x: 0, y: 0 };
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: evt.clientX - rect.left,
|
||||
y: evt.clientY - rect.top
|
||||
};
|
||||
};
|
||||
|
||||
const pos = getPos(e);
|
||||
const maxW = imgRef.current.width;
|
||||
const maxH = imgRef.current.height;
|
||||
const { startPos, startCrop } = interaction;
|
||||
|
||||
if (interaction.type === 'move') {
|
||||
const dx = pos.x - startPos.x;
|
||||
const dy = pos.y - startPos.y;
|
||||
|
||||
const newRect = {
|
||||
...startCrop,
|
||||
x: startCrop.x + dx,
|
||||
y: startCrop.y + dy
|
||||
};
|
||||
|
||||
setCrop(clampRect(newRect, maxW, maxH));
|
||||
}
|
||||
else if (interaction.type === 'create') {
|
||||
let currentX = Math.max(0, Math.min(pos.x, maxW));
|
||||
let currentY = Math.max(0, Math.min(pos.y, maxH));
|
||||
|
||||
// Use startCrop.x/y as anchor (which was set to mouseDown pos)
|
||||
const anchorX = startCrop.x;
|
||||
const anchorY = startCrop.y;
|
||||
|
||||
let width = Math.abs(currentX - anchorX);
|
||||
let height = Math.abs(currentY - anchorY);
|
||||
|
||||
// Apply Aspect Ratio
|
||||
if (aspectRatio) {
|
||||
if (width / height > aspectRatio) {
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
const dirX = currentX >= anchorX ? 1 : -1;
|
||||
const dirY = currentY >= anchorY ? 1 : -1;
|
||||
|
||||
let x = anchorX + (dirX === -1 ? -width : 0);
|
||||
let y = anchorY + (dirY === -1 ? -height : 0);
|
||||
|
||||
// Boundary Check for Create
|
||||
if (x < 0) { x = 0; if (aspectRatio) height = width/aspectRatio; }
|
||||
if (y < 0) { y = 0; if (aspectRatio) width = height*aspectRatio; }
|
||||
if (x + width > maxW) {
|
||||
// Simple clamp by shifting x if possible, or reducing size
|
||||
if (dirX === 1) width = maxW - x;
|
||||
else x = maxW - width;
|
||||
if (aspectRatio) height = width / aspectRatio;
|
||||
}
|
||||
if (y + height > maxH) {
|
||||
if (dirY === 1) height = maxH - y;
|
||||
else y = maxH - height;
|
||||
if (aspectRatio) width = height * aspectRatio;
|
||||
}
|
||||
|
||||
setCrop({ x, y, width, height });
|
||||
}
|
||||
else if (interaction.type === 'resize' && interaction.handle) {
|
||||
// Resizing logic
|
||||
// 1. Determine Anchor Point (Opposite to handle)
|
||||
let anchorX = 0, anchorY = 0;
|
||||
switch (interaction.handle) {
|
||||
case 'nw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y + startCrop.height; break;
|
||||
case 'ne': anchorX = startCrop.x; anchorY = startCrop.y + startCrop.height; break;
|
||||
case 'sw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y; break;
|
||||
case 'se': anchorX = startCrop.x; anchorY = startCrop.y; break;
|
||||
}
|
||||
|
||||
// 2. Calculate raw new dimensions based on mouse pos relative to anchor
|
||||
// We do NOT clamp mouse pos here strictly yet, we calculate desired rect then fit.
|
||||
const currentX = Math.max(0, Math.min(pos.x, maxW));
|
||||
const currentY = Math.max(0, Math.min(pos.y, maxH));
|
||||
|
||||
let newW = Math.abs(currentX - anchorX);
|
||||
let newH = Math.abs(currentY - anchorY);
|
||||
|
||||
// 3. Apply Aspect Ratio
|
||||
if (aspectRatio) {
|
||||
// Standard projection: take the larger dimension change or just prefer width?
|
||||
// Let's rely on the handle direction.
|
||||
// For corners, usually we pick the dimension that results in a larger box?
|
||||
// Or typically width drives height for stability.
|
||||
// Let's use width to drive height for consistent feel.
|
||||
newH = newW / aspectRatio;
|
||||
|
||||
// Check if this height causes Y to go out of bounds?
|
||||
// If dragging SE, Y must be <= maxH.
|
||||
// If dragging NE, Y must be >= 0.
|
||||
const isNorth = interaction.handle.includes('n');
|
||||
const projectedY = isNorth ? anchorY - newH : anchorY + newH;
|
||||
|
||||
if (projectedY < 0 || projectedY > maxH) {
|
||||
// Width-based height failed bounds, try Height-based width
|
||||
newH = Math.abs(currentY - anchorY); // Revert to raw Y
|
||||
newW = newH * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reconstruct Rect
|
||||
let newX = interaction.handle.includes('w') ? anchorX - newW : anchorX;
|
||||
let newY = interaction.handle.includes('n') ? anchorY - newH : anchorY;
|
||||
|
||||
// 5. Final Clamp (Double safety)
|
||||
if (newX < 0) newX = 0;
|
||||
if (newY < 0) newY = 0;
|
||||
if (newX + newW > maxW) newW = maxW - newX;
|
||||
if (newY + newH > maxH) newH = maxH - newY;
|
||||
|
||||
// If clamp broke aspect ratio, strict re-calc?
|
||||
// For cropping tool, slight drift is annoying, but hard clamp is better than broken UI.
|
||||
// We'll leave it as is, usually user corrects mouse.
|
||||
|
||||
setCrop({ x: newX, y: newY, width: newW, height: newH });
|
||||
}
|
||||
}, [interaction, aspectRatio]);
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
// Just reset to create/none state
|
||||
setInteraction(prev => ({ ...prev, type: 'create', startCrop: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [interaction, handleGlobalMouseMove]);
|
||||
|
||||
// Adjust existing crop when ratio changes
|
||||
useEffect(() => {
|
||||
if (crop && aspectRatio && crop.width > 0 && crop.height > 0) {
|
||||
// Keep center, adjust size
|
||||
const centerX = crop.x + crop.width / 2;
|
||||
const centerY = crop.y + crop.height / 2;
|
||||
|
||||
let newW = crop.width;
|
||||
let newH = newW / aspectRatio;
|
||||
|
||||
if (newH > (imgRef.current?.height || 0)) {
|
||||
newH = imgRef.current?.height || 0;
|
||||
newW = newH * aspectRatio;
|
||||
}
|
||||
|
||||
let newX = centerX - newW / 2;
|
||||
let newY = centerY - newH / 2;
|
||||
|
||||
if (imgRef.current) {
|
||||
const rect = clampRect({ x: newX, y: newY, width: newW, height: newH }, imgRef.current.width, imgRef.current.height);
|
||||
setCrop(rect);
|
||||
}
|
||||
}
|
||||
}, [aspectRatio, crop]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!imgRef.current || !crop || crop.width === 0) { onConfirm(imageSrc); return; }
|
||||
const canvas = document.createElement('canvas');
|
||||
const sx = imgRef.current.naturalWidth / imgRef.current.width;
|
||||
const sy = imgRef.current.naturalHeight / imgRef.current.height;
|
||||
|
||||
canvas.width = crop.width * sx;
|
||||
canvas.height = crop.height * sy;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(imgRef.current, crop.x * sx, crop.y * sy, crop.width * sx, crop.height * sy, 0, 0, crop.width * sx, crop.height * sy);
|
||||
onConfirm(canvas.toDataURL('image/png'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!cropRequest) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center animate-in fade-in duration-300">
|
||||
|
||||
{/* Top Bar: Title */}
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
|
||||
<div className="bg-muted/90 backdrop-blur-md px-6 py-2.5 rounded-full border border-white/10 text-muted-foreground text-xs font-medium flex items-center gap-2 shadow-2xl">
|
||||
<Crop size={14} className="text-cyan-400" />
|
||||
<span>局部分镜截取</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">拖拽四角调整 • 按住中间移动</span>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative max-w-[85vw] max-h-[65vh] border border-white/10 shadow-2xl rounded-lg overflow-hidden select-none bg-black/50 group"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'create')}
|
||||
>
|
||||
<SecureImage ref={imgRef} src={imageSrc} className="max-w-full max-h-[65vh] object-contain block opacity-50" draggable={false} crossOrigin="anonymous" />
|
||||
|
||||
{/* Active Crop Area */}
|
||||
{crop && crop.width > 0 && (
|
||||
<div className="absolute" style={{ left: crop.x, top: crop.y, width: crop.width, height: crop.height }}>
|
||||
{/* 1. Clear Image View Inside */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<SecureImage
|
||||
src={imageSrc}
|
||||
className="absolute max-w-none"
|
||||
style={{
|
||||
width: imgRef.current?.width,
|
||||
height: imgRef.current?.height,
|
||||
left: -crop.x,
|
||||
top: -crop.y,
|
||||
opacity: 1
|
||||
}}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Dark Overlay Outline (Outside shadow trick) */}
|
||||
<div className="absolute inset-0 shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] pointer-events-none" />
|
||||
|
||||
{/* 3. Grid & Border */}
|
||||
<div className="absolute inset-0 border-2 border-cyan-400 z-10 pointer-events-none">
|
||||
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 opacity-40">
|
||||
<div className="border-r border-white/50"/><div className="border-r border-white/50"/><div className="col-span-3 border-b border-white/50 -mt-[33%]"/><div className="col-span-3 border-b border-white/50 mt-[33%]"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Move Handler (Invisible Center) */}
|
||||
<div
|
||||
className="absolute inset-0 z-20 cursor-move group/move"
|
||||
onMouseDown={(e) => handleMouseDown(e, 'move')}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/move:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/50 p-2 rounded-full backdrop-blur-sm">
|
||||
<Move size={16} className="text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Resize Handles (Corners) */}
|
||||
{['nw', 'ne', 'sw', 'se'].map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className={`
|
||||
absolute w-4 h-4 bg-white border-2 border-cyan-500 rounded-full z-30 shadow-sm
|
||||
hover:scale-125 transition-transform
|
||||
`}
|
||||
style={{
|
||||
cursor: `${h}-resize`,
|
||||
left: h.includes('w') ? -8 : 'auto',
|
||||
right: h.includes('e') ? -8 : 'auto',
|
||||
top: h.includes('n') ? -8 : 'auto',
|
||||
bottom: h.includes('s') ? -8 : 'auto',
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'resize', h as ResizeHandle)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Size Label */}
|
||||
<div className="absolute -top-7 left-0 flex gap-2 z-20 pointer-events-none">
|
||||
<div className="bg-cyan-500 text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
|
||||
{Math.round(crop.width)} × {Math.round(crop.height)}
|
||||
</div>
|
||||
{aspectRatio && (
|
||||
<div className="bg-black/60 text-cyan-400 border border-cyan-500/30 text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
|
||||
{RATIOS.find(r => r.value === aspectRatio)?.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar: Aspect Ratios & Actions */}
|
||||
<div className="flex flex-col items-center gap-6 mt-8 w-full max-w-2xl px-4">
|
||||
|
||||
{/* Aspect Ratio Selector */}
|
||||
<div className="flex items-center gap-2 p-1 bg-card border border-white/10 rounded-xl shadow-lg overflow-x-auto custom-scrollbar max-w-full">
|
||||
{RATIOS.map(ratio => (
|
||||
<button
|
||||
key={ratio.label}
|
||||
onClick={() => setAspectRatio(ratio.value)}
|
||||
className={`
|
||||
relative px-4 py-2 rounded-lg text-xs font-bold transition-all whitespace-nowrap
|
||||
${aspectRatio === ratio.value
|
||||
? 'bg-cyan-500 text-black shadow-md scale-105 z-10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{ratio.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<button onClick={onCancel} className="px-6 py-2.5 rounded-full bg-white/5 hover:bg-white/10 text-white text-xs font-medium transition-colors border border-white/5">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!crop || crop.width === 0}
|
||||
className={`
|
||||
px-8 py-2.5 rounded-full text-xs font-bold shadow-lg transition-all flex items-center gap-2
|
||||
${(!crop || crop.width === 0)
|
||||
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
|
||||
: 'bg-cyan-500 hover:bg-cyan-400 text-black hover:scale-105 shadow-cyan-500/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Check size={14}/> 确认裁剪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
321
frontend/src/components/canvas/controls/LoadingProgressBar.tsx
Normal file
321
frontend/src/components/canvas/controls/LoadingProgressBar.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Loading Progress Bar Component
|
||||
*
|
||||
* Displays a progress bar during canvas loading with stage indicators.
|
||||
* Shows the current loading stage and progress percentage.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LoadingStage } from '../managers/useProgressiveLoading';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export interface LoadingProgressBarProps {
|
||||
/** Current loading stage */
|
||||
stage: LoadingStage;
|
||||
/** Loading progress (0-100) */
|
||||
progress: number;
|
||||
/** Whether loading is in progress */
|
||||
isLoading: boolean;
|
||||
/** Error message if loading failed */
|
||||
error?: string;
|
||||
/** Optional className for styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for each loading stage
|
||||
*/
|
||||
function getStageMessage(stage: LoadingStage): string {
|
||||
switch (stage) {
|
||||
case 'idle':
|
||||
return '初始化中...';
|
||||
case 'structure':
|
||||
return '加载画布结构...';
|
||||
case 'content':
|
||||
return '加载图片和视频...';
|
||||
case 'complete':
|
||||
return '加载完成!';
|
||||
case 'error':
|
||||
return '加载失败';
|
||||
default:
|
||||
return '加载中...';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stage index for progress indicator
|
||||
*/
|
||||
function getStageIndex(stage: LoadingStage): number {
|
||||
switch (stage) {
|
||||
case 'idle':
|
||||
return 0;
|
||||
case 'structure':
|
||||
return 1;
|
||||
case 'content':
|
||||
return 2;
|
||||
case 'complete':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading Progress Bar Component
|
||||
*/
|
||||
export function LoadingProgressBar({
|
||||
stage,
|
||||
progress,
|
||||
isLoading,
|
||||
error,
|
||||
className = '',
|
||||
}: LoadingProgressBarProps) {
|
||||
// Don't show if not loading and no error
|
||||
if (!isLoading && !error && stage === 'complete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentStageIndex = getStageIndex(stage);
|
||||
const stageMessage = getStageMessage(stage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm ${className}`}
|
||||
>
|
||||
<div className="w-full max-w-md mx-4 p-6 bg-card/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl">
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-red-500">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
加载失败
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{!error && (
|
||||
<>
|
||||
{/* Stage Indicators */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Stage 1: Structure */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 1
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex > 1 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">1</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 1
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
结构
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 2
|
||||
? 'bg-cyan-500'
|
||||
: 'bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Stage 2: Content */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 2
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex > 2 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">2</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 2
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
内容
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 3
|
||||
? 'bg-cyan-500'
|
||||
: 'bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Stage 3: Complete */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 3
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex >= 3 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">3</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 3
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
完成
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && (
|
||||
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{stageMessage}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white tabular-nums">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Loading Progress Bar (for in-canvas display)
|
||||
*/
|
||||
export function CompactLoadingProgressBar({
|
||||
stage,
|
||||
progress,
|
||||
isLoading,
|
||||
className = '',
|
||||
}: Omit<LoadingProgressBarProps, 'error'>) {
|
||||
// Don't show if not loading
|
||||
if (!isLoading || stage === 'complete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stageMessage = getStageMessage(stage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 left-1/2 -translate-x-1/2 z-50 ${className}`}
|
||||
>
|
||||
<div className="px-4 py-2 bg-card/95 backdrop-blur-xl border border-white/10 rounded-full shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stageMessage}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-white tabular-nums">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
frontend/src/components/canvas/controls/OnboardingTour.tsx
Normal file
270
frontend/src/components/canvas/controls/OnboardingTour.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronRight, ChevronLeft, Sparkles, MousePointer, Keyboard, Zap, Check } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
target?: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
const onboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: '欢迎使用 Pixel',
|
||||
description: 'Pixel 是一个 AI 驱动的漫画和视频创作平台。让我们花几分钟了解一下核心功能。',
|
||||
icon: <Sparkles className="w-6 h-6 text-cyan-400" />,
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
id: 'canvas',
|
||||
title: '画布工作区',
|
||||
description: '这是你的创作画布。你可以在这里添加节点、连接它们来构建创作流程。拖拽左侧的节点到画布上开始创作。',
|
||||
icon: <MousePointer className="w-6 h-6 text-purple-400" />,
|
||||
target: '.react-flow__pane',
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
title: '左侧工具栏',
|
||||
description: '这里提供了各种 AI 生成节点:图像生成、视频生成、剧本处理等。点击或拖拽到画布上使用。',
|
||||
icon: <Zap className="w-6 h-6 text-yellow-400" />,
|
||||
target: '[data-panel="left-dock"]',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: '快捷键',
|
||||
description: '使用快捷键可以提高创作效率。Ctrl+S 保存,Ctrl+Z 撤销,Ctrl+A 全选。点击右下角的键盘图标查看全部快捷键。',
|
||||
icon: <Keyboard className="w-6 h-6 text-green-400" />,
|
||||
target: '[data-shortcut-button]',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: '准备开始创作',
|
||||
description: '你已经了解了基本操作。开始你的第一个 AI 创作项目吧!如有问题,随时查看帮助文档。',
|
||||
icon: <Check className="w-6 h-6 text-emerald-400" />,
|
||||
position: 'center'
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'pixel-onboarding-completed';
|
||||
|
||||
export function OnboardingTour() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [hasCompleted, setHasCompleted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has completed onboarding
|
||||
const completed = localStorage.getItem(STORAGE_KEY);
|
||||
if (!completed) {
|
||||
// Show onboarding after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setHasCompleted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setHasCompleted(true);
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < onboardingSteps.length - 1) {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
}, [currentStep, handleComplete]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setHasCompleted(true);
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setHasCompleted(false);
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const step = onboardingSteps[currentStep];
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === onboardingSteps.length - 1;
|
||||
const progress = ((currentStep + 1) / onboardingSteps.length) * 100;
|
||||
|
||||
if (!isOpen) {
|
||||
// Show restart button if user has completed but wants to see it again
|
||||
if (hasCompleted) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="fixed bottom-6 left-6 z-50 p-2 bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 rounded-full text-muted-foreground hover:text-white transition-all"
|
||||
title="重新查看新手引导"
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Onboarding Card */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={cn(
|
||||
"fixed z-[9999] w-[400px] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden",
|
||||
step.position === 'center' && "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
step.position === 'top' && "bottom-24 right-6",
|
||||
step.position === 'bottom' && "top-24 left-1/2 -translate-x-1/2",
|
||||
step.position === 'left' && "top-1/2 right-6 -translate-y-1/2",
|
||||
step.position === 'right' && "top-1/2 left-6 -translate-y-1/2"
|
||||
)}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-3 right-3 p-1.5 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Icon */}
|
||||
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-1.5 mb-6">
|
||||
{onboardingSteps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
index === currentStep
|
||||
? "bg-cyan-400"
|
||||
: index < currentStep
|
||||
? "bg-white/40"
|
||||
: "bg-white/10"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={isFirstStep}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-2 text-sm rounded-lg transition-colors",
|
||||
isFirstStep
|
||||
? "text-muted-foreground/50 cursor-not-allowed"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
上一步
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isLastStep && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-3 py-2 text-sm text-muted-foreground hover:text-white transition-colors"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
{isLastStep ? '完成' : '下一步'}
|
||||
{!isLastStep && <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetOnboardingButton() {
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-muted-foreground hover:text-white underline"
|
||||
>
|
||||
重新显示新手引导
|
||||
</button>
|
||||
);
|
||||
}
|
||||
519
frontend/src/components/canvas/controls/SketchEditor.tsx
Normal file
519
frontend/src/components/canvas/controls/SketchEditor.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
X, Brush, Eraser, Palette, Undo, Trash2,
|
||||
Download, Play, Image as ImageIcon,
|
||||
Activity, Wand2, Loader2, ChevronDown, Layers
|
||||
} from 'lucide-react';
|
||||
import { generateImage, generateVideo } from '../services/canvasService';
|
||||
import { useModelStore } from '@lib/store/modelStore';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { handleError } from '@/lib/services/errorHandler';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
|
||||
const log = logger.namespace('SketchEditor');
|
||||
|
||||
interface SketchEditorProps {
|
||||
onClose: () => void;
|
||||
onGenerate: (type: 'image' | 'video', result: string, prompt: string) => void;
|
||||
}
|
||||
|
||||
type Tool = 'brush' | 'eraser';
|
||||
type Mode = 'video' | 'image' | 'pose';
|
||||
|
||||
// Colors for the palette
|
||||
export const PRESET_COLORS = [
|
||||
'#000000', '#ffffff', '#ff3b30', '#ff9500',
|
||||
'#ffcc00', '#4cd964', '#5ac8fa', '#007aff',
|
||||
'#5856d6', '#ff2d55', '#8e8e93'
|
||||
];
|
||||
|
||||
export const SketchEditor: React.FC<SketchEditorProps> = ({ onClose, onGenerate }) => {
|
||||
// Models
|
||||
const { imageModels, videoModels } = useModelStore();
|
||||
const [selectedImageModel, setSelectedImageModel] = useState<string>('');
|
||||
const [selectedVideoModel, setSelectedVideoModel] = useState<string>('');
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImageModel && Object.keys(imageModels).length > 0) {
|
||||
setSelectedImageModel(Object.keys(imageModels)[0]);
|
||||
}
|
||||
}, [imageModels, selectedImageModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVideoModel && Object.keys(videoModels).length > 0) {
|
||||
setSelectedVideoModel(Object.keys(videoModels)[0]);
|
||||
}
|
||||
}, [videoModels, selectedVideoModel]);
|
||||
|
||||
// Canvas & Drawing State
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [tool, setTool] = useState<Tool>('brush');
|
||||
const [brushColor, setBrushColor] = useState('#000000');
|
||||
const [brushSize] = useState(5);
|
||||
const [eraserSize] = useState(30);
|
||||
const [canvasHistory, setCanvasHistory] = useState<ImageData[]>([]);
|
||||
|
||||
// Background Image State
|
||||
const [backgroundImage, setBackgroundImage] = useState<HTMLImageElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// UI State
|
||||
const [activeMode, setActiveMode] = useState<Mode>('video');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
|
||||
// Init Canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Handle High DPI
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr);
|
||||
// Initialize transparent
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
saveHistory(); // Save initial blank state
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveHistory = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
setCanvasHistory(prev => [...prev.slice(-10), data]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (canvasHistory.length <= 1) return;
|
||||
const newHistory = [...canvasHistory];
|
||||
newHistory.pop(); // Remove current state
|
||||
const prevState = newHistory[newHistory.length - 1];
|
||||
setCanvasHistory(newHistory);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx && prevState) {
|
||||
ctx.putImageData(prevState, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
saveHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBackground = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => setBackgroundImage(img);
|
||||
img.src = ev.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Drawing Handlers
|
||||
const getPos = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
setIsDrawing(true);
|
||||
const { x, y } = getPos(e);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
if (tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.lineWidth = eraserSize;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = brushColor;
|
||||
ctx.lineWidth = brushSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDrawing) return;
|
||||
const { x, y } = getPos(e);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
ctx?.closePath();
|
||||
// Reset composite operation to default just in case
|
||||
if (ctx) ctx.globalCompositeOperation = 'source-over';
|
||||
saveHistory();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Composite Logic (Merge Background + Sketch) ---
|
||||
const getCompositeDataURL = (): string => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return '';
|
||||
|
||||
// Create an off-screen canvas for composition
|
||||
const osc = document.createElement('canvas');
|
||||
osc.width = canvas.width;
|
||||
osc.height = canvas.height;
|
||||
const ctx = osc.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
// 1. Fill White Background (Base)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, osc.width, osc.height);
|
||||
|
||||
// 2. Draw Background Image (Scaled to Fit/Cover logic matching UI)
|
||||
if (backgroundImage) {
|
||||
// Calculate "contain" aspect ratio to match UI
|
||||
const scale = Math.min(osc.width / backgroundImage.width, osc.height / backgroundImage.height);
|
||||
const w = backgroundImage.width * scale;
|
||||
const h = backgroundImage.height * scale;
|
||||
const x = (osc.width - w) / 2;
|
||||
const y = (osc.height - h) / 2;
|
||||
ctx.drawImage(backgroundImage, x, y, w, h);
|
||||
}
|
||||
|
||||
// 3. Draw User Sketch
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
|
||||
return osc.toDataURL('image/png');
|
||||
};
|
||||
|
||||
// Generation Logic
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
if (activeMode === 'pose') {
|
||||
// --- Pose Generator Mode: Draw TO Canvas ---
|
||||
// 1. Generate Line Art from Gemini 2.5
|
||||
const posePrompt = `
|
||||
Generate a simple, high-contrast black line art sketch on a white background.
|
||||
Subject: ${prompt}.
|
||||
Style: Minimalist stick figure or outline drawing, clear lines, no shading.
|
||||
`;
|
||||
|
||||
const res = await generateImage(posePrompt, selectedImageModel, [], { aspectRatio: '16:9', count: 1 });
|
||||
const imgUrl = res[0];
|
||||
|
||||
// 2. Draw Result onto Canvas
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
// We want to draw this opaque, but keep it editable.
|
||||
// Since current canvas history logic is pixel-based, drawing it is destructive but fine.
|
||||
// We draw it 'source-over'.
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Scale to fit
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (canvas.width - w) / 2;
|
||||
const y = (canvas.height - h) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
saveHistory();
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
throw new Error("Failed to load generated pose image");
|
||||
};
|
||||
img.src = imgUrl;
|
||||
|
||||
} else {
|
||||
// --- Video/Image Mode: Generate FROM Canvas ---
|
||||
const compositeBase64 = getCompositeDataURL();
|
||||
|
||||
if (activeMode === 'video') {
|
||||
const res = await generateVideo(
|
||||
prompt,
|
||||
selectedVideoModel,
|
||||
{ aspectRatio: '16:9' },
|
||||
compositeBase64
|
||||
);
|
||||
onGenerate('video', res.uri, prompt);
|
||||
} else {
|
||||
// Image (Sketch-to-Image)
|
||||
const res = await generateImage(
|
||||
prompt,
|
||||
selectedImageModel,
|
||||
[compositeBase64],
|
||||
{ aspectRatio: '16:9', count: 1 }
|
||||
);
|
||||
onGenerate('image', res[0], prompt);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'SketchGenerate', {
|
||||
customMessage: '生成失败,请重试'
|
||||
});
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeModel = activeMode === 'video'
|
||||
? videoModels[selectedVideoModel]
|
||||
: imageModels[selectedImageModel];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex flex-col animate-in fade-in duration-300">
|
||||
{/* 1. Top Navigation Bar */}
|
||||
<div className="h-14 border-b border-white/10 flex items-center justify-between px-6 bg-card">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute left-6 p-2 rounded-full bg-white/5 hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="flex bg-black/30 p-1 rounded-lg">
|
||||
{[
|
||||
{ id: 'video', label: '涂鸦生视频', icon: Play },
|
||||
{ id: 'image', label: '涂鸦生图', icon: ImageIcon },
|
||||
{ id: 'pose', label: '姿势生成器 (Pose)', icon: Activity }
|
||||
].map(mode => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setActiveMode(mode.id as Mode)}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-1.5 rounded-md text-xs font-bold transition-all
|
||||
${activeMode === mode.id
|
||||
? 'bg-white/10 text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-muted-foreground'}
|
||||
`}
|
||||
>
|
||||
<mode.icon size={12} />
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Main Canvas Area */}
|
||||
<div className="flex-1 relative bg-background flex items-center justify-center p-8 overflow-hidden">
|
||||
|
||||
{/* Floating Toolbar */}
|
||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 p-1.5 bg-muted/90 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl">
|
||||
<button
|
||||
onClick={() => setTool('brush')}
|
||||
className={`p-2.5 rounded-full transition-colors ${tool === 'brush' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
|
||||
title="画笔"
|
||||
>
|
||||
<Brush size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTool('eraser')}
|
||||
className={`p-2.5 rounded-full transition-colors ${tool === 'eraser' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
|
||||
title="橡皮擦"
|
||||
>
|
||||
<Eraser size={16} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPalette(!showPalette)}
|
||||
className="p-2.5 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-white/5 relative"
|
||||
title="调色板"
|
||||
>
|
||||
<Palette size={16} style={{ color: tool === 'brush' ? brushColor : undefined }} />
|
||||
<div className="absolute bottom-1 right-1 w-2 h-2 rounded-full border border-border" style={{ backgroundColor: brushColor }} />
|
||||
</button>
|
||||
|
||||
{showPalette && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-3 p-3 bg-card border border-white/10 rounded-xl shadow-xl grid grid-cols-4 gap-2 w-48 z-30">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { setBrushColor(c); setTool('brush'); setShowPalette(false); }}
|
||||
className={`w-8 h-8 rounded-full border-2 ${brushColor === c ? 'border-white' : 'border-transparent hover:scale-110'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button onClick={handleUndo} className="p-2.5 rounded-full text-muted-foreground hover:text-white hover:bg-white/5">
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleClear} className="p-2.5 rounded-full text-red-400 hover:bg-red-500/10">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The Canvas Wrapper */}
|
||||
<div className="relative shadow-2xl rounded-lg overflow-hidden border border-white/5 bg-white select-none" style={{ aspectRatio: '16/9', height: '100%', maxHeight: '800px' }}>
|
||||
{/* Background Image Layer */}
|
||||
{backgroundImage && (
|
||||
<SecureImage
|
||||
src={backgroundImage.src}
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none opacity-50"
|
||||
alt="Reference"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full cursor-crosshair touch-none"
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={startDrawing}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDrawing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Bottom Control Bar */}
|
||||
<div className="h-20 bg-card border-t border-white/10 flex items-center px-8 gap-4">
|
||||
{/* Tools (Left) */}
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
{/* Import Background Button */}
|
||||
<div
|
||||
className="relative p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-foreground border border-white/5 cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="导入底图"
|
||||
>
|
||||
<Layers size={16} />
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImportBackground} />
|
||||
</div>
|
||||
<button onClick={() => { if(canvasRef.current){ const a = document.createElement('a'); a.href = getCompositeDataURL(); a.download='sketch.png'; a.click(); } }} className="p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-white border border-white/5" title="下载当前画布">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={activeMode === 'pose' ? "描述姿势 (e.g. A stick figure running fast)..." : "描述画面内容 (e.g. Milk splash around the bottle)..."}
|
||||
className="w-full h-11 bg-black/30 border border-white/10 rounded-xl px-4 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500/50 transition-colors"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings & Generate */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
||||
className="h-11 px-4 flex items-center gap-2 bg-black/30 border border-white/10 rounded-xl text-xs text-muted-foreground font-medium hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{activeModel?.icon && <activeModel.icon size={14} />}
|
||||
<span>{activeModel?.name || 'Select Model'}</span>
|
||||
<ChevronDown size={12} className="text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{showModelDropdown && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-48 bg-card border border-white/10 rounded-xl shadow-xl overflow-hidden z-50">
|
||||
{activeMode === 'video' ? (
|
||||
Object.values(videoModels).map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => { setSelectedVideoModel(m.id); setShowModelDropdown(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedVideoModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{m.icon && <m.icon size={14} />}
|
||||
{m.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
Object.values(imageModels).map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => { setSelectedImageModel(m.id); setShowModelDropdown(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedImageModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{m.icon && <m.icon size={14} />}
|
||||
{m.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className={`
|
||||
h-11 px-6 rounded-xl flex items-center gap-2 font-bold text-sm transition-all
|
||||
${isGenerating || !prompt.trim()
|
||||
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
|
||||
: activeMode === 'pose'
|
||||
? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-foreground hover:scale-105'
|
||||
: 'bg-gradient-to-r from-cyan-600 to-blue-600 text-foreground hover:scale-105'}
|
||||
`}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
||||
<span>{activeMode === 'pose' ? '生成姿势' : '生成作品'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Library, Music, Plus, UserCircle } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/utils';
|
||||
import { InputAsset } from '../../types/node';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { SecureVideo } from '../../nodes/components/SecureVideo';
|
||||
|
||||
export const AssetSelector = memo(({ isExpanded, selectedAssets, onToggleAsset, inputAssets = [] }: {
|
||||
isExpanded: boolean,
|
||||
selectedAssets: string[],
|
||||
onToggleAsset: (id: string) => void,
|
||||
inputAssets?: InputAsset[]
|
||||
}) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-3 flex items-center gap-2 pointer-events-auto"
|
||||
>
|
||||
<div className="bg-black/40 backdrop-blur-md rounded-full p-1.5 flex items-center gap-1 border border-white/10 shadow-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<Library className="w-4 h-4 text-foreground/70" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-foreground/70 px-2">素材</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center -space-x-2 overflow-visible hover:space-x-1 transition-all duration-300">
|
||||
{/* Input Assets */}
|
||||
{inputAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full border-2 border-cyan-500/50 relative overflow-hidden group bg-black/50 transition-all duration-200 z-20",
|
||||
"hover:scale-110 hover:border-cyan-400 hover:z-30 shadow-[0_0_10px_rgba(6,182,212,0.3)]"
|
||||
)}
|
||||
title={asset.label || undefined}
|
||||
>
|
||||
{asset.type === 'video' ? (
|
||||
<SecureVideo
|
||||
src={asset.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : asset.type === 'audio' ? (
|
||||
<div className={cn(
|
||||
"w-full h-full flex items-center justify-center",
|
||||
asset.gender ? "bg-black/40" : "bg-cyan-500/20"
|
||||
)}>
|
||||
{asset.gender ? (
|
||||
<UserCircle
|
||||
size={20}
|
||||
className={cn(
|
||||
asset.gender === '男' || asset.gender === 'male' ? 'text-blue-400' : 'text-pink-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-5 h-5 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SecureImage
|
||||
src={asset.src}
|
||||
alt="Input Asset"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 rounded-full border-2 border-white/10 border-dashed bg-black/20 hover:bg-white/10 flex items-center justify-center transition-colors p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-foreground/50" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
AssetSelector.displayName = 'AssetSelector';
|
||||
@@ -0,0 +1,556 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { InputAsset } from '../../types/node';
|
||||
import { cn } from '@/utils';
|
||||
import { Music, UserCircle } from 'lucide-react';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { SecureVideo } from '../../nodes/components/SecureVideo';
|
||||
import {
|
||||
createMentionChipElement,
|
||||
filterMentionCandidates,
|
||||
parseMentionTokens,
|
||||
} from './mentionUtils';
|
||||
|
||||
interface AutoResizeTextareaProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onEnter: () => void;
|
||||
isExpanded?: boolean;
|
||||
placeholder?: string;
|
||||
/** 可 @ 的素材列表,用于在输入时弹出候选 */
|
||||
inputAssets?: InputAsset[];
|
||||
}
|
||||
|
||||
export const AutoResizeTextarea = ({
|
||||
value,
|
||||
onChange,
|
||||
onEnter,
|
||||
isExpanded,
|
||||
placeholder,
|
||||
inputAssets
|
||||
}: AutoResizeTextareaProps) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
||||
const [mentionPosition, setMentionPosition] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
const closeMention = useCallback(() => {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
setMentionStart(null);
|
||||
setMentionPosition(null);
|
||||
}, []);
|
||||
|
||||
// Focus when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded && editorRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
// Move cursor to end
|
||||
const selection = window.getSelection();
|
||||
if (selection && editorRef.current) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// 从 DOM 读取纯文本内容(chip span 的 data-mention-text 就是 @图片1)
|
||||
const getTextFromDOM = useCallback((): string => {
|
||||
if (!editorRef.current) return '';
|
||||
|
||||
let text = '';
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || '';
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
// chip span 的 data-mention-text 就是 @图片1
|
||||
text += el.getAttribute('data-mention-text') || '';
|
||||
} else if (el.tagName === 'BR') {
|
||||
text += '\n';
|
||||
} else {
|
||||
node.childNodes.forEach(walk);
|
||||
}
|
||||
}
|
||||
};
|
||||
editorRef.current.childNodes.forEach(walk);
|
||||
return text;
|
||||
}, []);
|
||||
|
||||
// 光标偏移与恢复(纯 DOM 工具,稳定引用)
|
||||
const getCursorOffset = useCallback((editor: HTMLElement, range: Range): number => {
|
||||
let offset = 0;
|
||||
const walk = (node: Node): boolean => {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
offset += (node.textContent || '').length;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
offset += (el.getAttribute('data-mention-text') || el.innerText || '').length;
|
||||
} else {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
if (walk(node.childNodes[i])) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let i = 0; i < editor.childNodes.length; i++) {
|
||||
if (walk(editor.childNodes[i])) break;
|
||||
}
|
||||
return offset;
|
||||
}, []);
|
||||
|
||||
const restoreCursor = useCallback((editor: HTMLElement, offset: number) => {
|
||||
let currentOffset = 0;
|
||||
let found = false;
|
||||
const walk = (node: Node): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const len = (node.textContent || '').length;
|
||||
if (currentOffset + len >= offset) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, Math.min(offset - currentOffset, len));
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
currentOffset += len;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
const len = (el.getAttribute('data-mention-text') || el.innerText || '').length;
|
||||
if (currentOffset + len >= offset) {
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(el);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
currentOffset += len;
|
||||
} else {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
if (walk(node.childNodes[i])) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let i = 0; i < editor.childNodes.length; i++) {
|
||||
if (walk(editor.childNodes[i])) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据纯文本 value 渲染 DOM(文本节点 + chip span)
|
||||
// 只在外部 value 变化时调用,用户输入时不调用
|
||||
const renderContent = useCallback((newValue: string, preserveCursor: boolean = true) => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
|
||||
// 保存当前光标位置
|
||||
const selection = window.getSelection();
|
||||
let cursorOffset = 0;
|
||||
if (preserveCursor && selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
cursorOffset = getCursorOffset(editor, range);
|
||||
}
|
||||
|
||||
// 清空并重新渲染
|
||||
editor.innerHTML = '';
|
||||
|
||||
if (!newValue) {
|
||||
if (preserveCursor) {
|
||||
// 确保光标在编辑器内
|
||||
editor.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 value,切分为文本段和 @别名段
|
||||
const tokens = parseMentionTokens(newValue, inputAssets);
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'text') {
|
||||
editor.appendChild(document.createTextNode(token.text));
|
||||
} else {
|
||||
const chip = createMentionChipElement(token.asset, isExpanded);
|
||||
editor.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复光标位置
|
||||
if (preserveCursor) {
|
||||
restoreCursor(editor, cursorOffset);
|
||||
}
|
||||
}, [inputAssets, isExpanded, getCursorOffset, restoreCursor]);
|
||||
|
||||
// 初始渲染和外部 value 变化时渲染
|
||||
const prevValueRef = useRef<string>(value);
|
||||
const isUserInputRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只在外部 value 变化时重新渲染(不是用户输入导致的变化)
|
||||
if (isUserInputRef.current) {
|
||||
// 用户输入导致的变化,不重新渲染
|
||||
isUserInputRef.current = false;
|
||||
prevValueRef.current = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = getTextFromDOM();
|
||||
if (value !== prevValueRef.current && value !== currentText) {
|
||||
renderContent(value, false);
|
||||
prevValueRef.current = value;
|
||||
}
|
||||
}, [value, getTextFromDOM, renderContent]);
|
||||
|
||||
// 更新候选浮层位置
|
||||
const updateMentionPosition = useCallback(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
setMentionPosition({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: 200,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 处理输入
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposingRef.current) return;
|
||||
|
||||
const text = getTextFromDOM();
|
||||
|
||||
// 标记这是用户输入
|
||||
isUserInputRef.current = true;
|
||||
onChange(text);
|
||||
|
||||
// 检查是否输入了 @
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorOffset = getCursorOffset(editor, range);
|
||||
const charBefore = text[cursorOffset - 1];
|
||||
|
||||
if (charBefore === '@') {
|
||||
setMentionOpen(true);
|
||||
setMentionQuery('');
|
||||
setMentionStart(cursorOffset - 1);
|
||||
updateMentionPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mentionOpen && mentionStart !== null) {
|
||||
const query = text.slice(mentionStart + 1, cursorOffset);
|
||||
setMentionQuery(query);
|
||||
if (!query || /\s/.test(query)) {
|
||||
closeMention();
|
||||
} else {
|
||||
updateMentionPosition();
|
||||
}
|
||||
}
|
||||
}, [getTextFromDOM, onChange, mentionOpen, mentionStart, updateMentionPosition, closeMention, getCursorOffset]);
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
// 阻止 Backspace 和 Delete 键冒泡到画布层(避免删除画布节点)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Enter 提交(折叠模式下不支持换行)
|
||||
if (e.key === 'Enter') {
|
||||
if (!isExpanded || !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
return;
|
||||
}
|
||||
// 展开模式下,Shift+Enter 允许换行(浏览器默认行为)
|
||||
}
|
||||
|
||||
// Backspace 删除 chip
|
||||
if (e.key === 'Backspace' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// 如果有选区,让浏览器默认处理
|
||||
if (!range.collapsed) return;
|
||||
|
||||
// 检查光标前面是否是 chip
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// 如果光标在文本节点开头,检查前一个兄弟节点
|
||||
if (container.nodeType === Node.TEXT_NODE && offset === 0) {
|
||||
const prevSibling = container.previousSibling;
|
||||
if (prevSibling && prevSibling.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = prevSibling as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果光标在编辑器开头,检查第一个子节点
|
||||
if (container === editor && offset === 0) {
|
||||
const firstChild = editor.firstChild;
|
||||
if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = firstChild as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果光标在元素节点内,检查前一个子节点
|
||||
if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
||||
const prevChild = container.childNodes[offset - 1];
|
||||
if (prevChild && prevChild.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = prevChild as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [getTextFromDOM, onChange, onEnter, isExpanded]);
|
||||
|
||||
// 候选列表
|
||||
const candidates = useMemo(() => {
|
||||
return filterMentionCandidates(inputAssets, mentionQuery);
|
||||
}, [inputAssets, mentionQuery]);
|
||||
|
||||
// 插入 mention
|
||||
const applyMention = useCallback((asset: InputAsset) => {
|
||||
if (mentionStart === null || !editorRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
const text = getTextFromDOM();
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
|
||||
// 获取当前光标位置
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorOffset = getCursorOffset(editor, range);
|
||||
|
||||
// 构建新文本:删除 @ 和查询,插入 @别名 + 空格
|
||||
const before = text.slice(0, mentionStart);
|
||||
const after = text.slice(cursorOffset);
|
||||
const inserted = `@${alias} `;
|
||||
const newText = before + inserted + after;
|
||||
|
||||
// 标记为用户输入,但需要重新渲染(因为要插入 chip)
|
||||
isUserInputRef.current = false; // 这次需要重新渲染
|
||||
onChange(newText);
|
||||
closeMention();
|
||||
|
||||
// 光标移到插入内容后面
|
||||
const newCursorOffset = before.length + inserted.length;
|
||||
requestAnimationFrame(() => {
|
||||
renderContent(newText, false);
|
||||
requestAnimationFrame(() => {
|
||||
restoreCursor(editor, newCursorOffset);
|
||||
editor.focus();
|
||||
});
|
||||
});
|
||||
}, [mentionStart, getTextFromDOM, onChange, closeMention, getCursorOffset, restoreCursor, renderContent]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex items-center">
|
||||
{/* contentEditable 编辑器 */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => {
|
||||
// 确保点击时聚焦
|
||||
e.stopPropagation();
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
// 如果没有内容,将光标移到末尾
|
||||
if (!editorRef.current.textContent) {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
handleInput();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
closeMention();
|
||||
}, 150);
|
||||
}}
|
||||
data-placeholder={placeholder || (isExpanded ? "输入你的创意" : "有什么可以帮您?")}
|
||||
className={cn(
|
||||
"w-full bg-transparent border-none outline-none text-foreground resize-none text-[13px] custom-scrollbar empty:before:content-[attr(data-placeholder)] empty:before:text-white/30 cursor-text",
|
||||
isExpanded
|
||||
? "px-2 min-h-[40px] leading-relaxed"
|
||||
: "px-0 leading-normal"
|
||||
)}
|
||||
style={isExpanded ? {
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
} : {
|
||||
overflowY: 'hidden',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
wordWrap: 'normal'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 候选浮层 */}
|
||||
{mentionOpen && candidates.length > 0 && mentionPosition &&
|
||||
typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] rounded-xl bg-black/85 border border-white/10 shadow-xl backdrop-blur-md text-xs text-foreground interaction-model-popover"
|
||||
style={{
|
||||
left: mentionPosition.left,
|
||||
top: mentionPosition.top,
|
||||
transform: 'translateY(-100%) translateY(-8px)',
|
||||
maxWidth: 260,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
{candidates.map(asset => {
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applyMention(asset);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-white/10 flex items-center gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full overflow-hidden border border-white/15 flex items-center justify-center bg-black/40 shrink-0'
|
||||
)}>
|
||||
{asset.type === 'video' ? (
|
||||
<SecureVideo
|
||||
src={asset.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : asset.type === 'audio' ? (
|
||||
<div className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
asset.gender ? 'bg-black/40' : 'bg-cyan-500/20'
|
||||
)}>
|
||||
{asset.gender ? (
|
||||
<UserCircle
|
||||
size={18}
|
||||
className={cn(
|
||||
asset.gender === '男' || asset.gender === 'male'
|
||||
? 'text-blue-400'
|
||||
: 'text-pink-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-4 h-4 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SecureImage
|
||||
src={asset.src}
|
||||
alt={alias}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-[12px]">{alias}</span>
|
||||
<span className="text-[10px] text-foreground/50">
|
||||
{asset.type === 'image' ? '图片素材' : asset.type === 'video' ? '视频素材' : '音频素材'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user