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:
张鹏
2026-04-29 01:20:12 +08:00
commit f9f4560459
808 changed files with 151724 additions and 0 deletions

37
frontend/Dockerfile Normal file
View 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
View 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"
}

View 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
View 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
View 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
View 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"
}
}
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
storeDir: /Users/cillin/Library/pnpm/store/v10

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/dashboard');
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
&quot;{user.username}&quot;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
)}
</>
);
}

View 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;
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>`

View 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';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export { StatsCards } from './StatsCards';
export { ActivityChart } from './ActivityChart';
export { RecentActivity } from './RecentActivity';

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';

View 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 />
</>
);
}

View 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 />
</>
);
}

View 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>
);
}

View 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 */}
</>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
&quot;{user.username}&quot;
</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>
);
}

View 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;

View 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>
);
}

View 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);

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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
};
}

View 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);

View 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/)

View 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>
);
}

View 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>
);
}

View 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');
});
});

View 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>
</>
);
}

View 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}`;
}
}

View 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}`;
}
}

View 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;
}

View 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;
}
}

View 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}`;
}
}

View 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}`;
}
}

View 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}`;
}
}

View 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}`;
}
}

View 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';

View 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);

View 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);

View File

@@ -0,0 +1,2 @@
export { CanvasNodes } from './CanvasNodes';
export { CanvasEdges, edgeTypes } from './CanvasEdges';

View 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";

View File

@@ -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>
);
}

View 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>
);
};

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -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';

View File

@@ -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