Initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
97
App.tsx
Normal file
97
App.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { HashRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
|
||||
import { Bot, Phone, Book, User, LayoutDashboard, Cpu, Mic2, Video } from 'lucide-react';
|
||||
|
||||
import { AssistantsPage } from './pages/Assistants';
|
||||
import { KnowledgeBasePage } from './pages/KnowledgeBase';
|
||||
import { CallLogsPage } from './pages/CallLogs';
|
||||
import { ProfilePage } from './pages/Profile';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { VoiceLibraryPage } from './pages/VoiceLibrary';
|
||||
|
||||
const SidebarItem: React.FC<{ to: string; icon: React.ReactNode; label: string; active: boolean }> = ({ to, icon, label, active }) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-md transition-all duration-200 ${active ? 'bg-primary/20 text-primary border-r-2 border-primary' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页', icon: <LayoutDashboard className="h-5 w-5" /> },
|
||||
{ path: '/assistants', label: '小助手', icon: <Bot className="h-5 w-5" /> },
|
||||
{ path: '/voices', label: '声音库', icon: <Mic2 className="h-5 w-5" /> },
|
||||
{ path: '/call-logs', label: '视频通话记录', icon: <Phone className="h-5 w-5" /> },
|
||||
{ path: '/knowledge', label: '知识库', icon: <Book className="h-5 w-5" /> },
|
||||
{ path: '/profile', label: '个人中心', icon: <User className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar with Glass effect */}
|
||||
<aside className="w-64 border-r border-border/40 bg-card/30 backdrop-blur-md hidden md:flex flex-col">
|
||||
<div className="p-6 flex items-center space-x-3 border-b border-border/40 overflow-hidden">
|
||||
{/* Cool Logo */}
|
||||
<div className="h-10 w-10 shrink-0 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-xl flex items-center justify-center shadow-[0_0_20px_rgba(6,182,212,0.5)] border border-white/10">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{/* No Wrap Title */}
|
||||
<span className="text-lg font-bold tracking-wide whitespace-nowrap bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||
AI VideoAssistant
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{navItems.map(item => (
|
||||
<SidebarItem
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
active={item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-border/40 text-xs text-muted-foreground text-center font-mono">
|
||||
SYSTEM v2.0
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<header className="h-16 border-b border-border/40 flex items-center px-6 bg-card/30 backdrop-blur-md md:hidden space-x-3">
|
||||
<div className="h-8 w-8 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-lg flex items-center justify-center shadow-lg">
|
||||
<Video className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-lg whitespace-nowrap">AI VideoAssistant</span>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto p-6 md:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Router>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/assistants" element={<AssistantsPage />} />
|
||||
<Route path="/voices" element={<VoiceLibraryPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgeBasePage />} />
|
||||
<Route path="/call-logs" element={<CallLogsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1GwAKXIF6lVjo1AZPHjVDL6w3hXkG_zFQ
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
152
components/UI.tsx
Normal file
152
components/UI.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
// Button
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 active:scale-95";
|
||||
|
||||
const variants = {
|
||||
// Primary: Glow effect
|
||||
primary: "bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.5)] hover:bg-primary/90 hover:shadow-[0_0_15px_rgba(6,182,212,0.6)]",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/50",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-9 px-4 py-2",
|
||||
lg: "h-10 px-8",
|
||||
icon: "h-9 w-9",
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Input - Removed border, added subtle background
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({ className = '', ...props }) => {
|
||||
return (
|
||||
<input
|
||||
className={`flex h-9 w-full rounded-md bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Card - Glassmorphism style, very subtle border
|
||||
export const Card: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
|
||||
<div className={`rounded-xl border border-white/5 bg-card/40 backdrop-blur-md text-card-foreground shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Badge
|
||||
export const Badge: React.FC<{ children: React.ReactNode; variant?: 'default' | 'success' | 'warning' | 'outline' }> = ({ children, variant = 'default' }) => {
|
||||
const styles = {
|
||||
default: "border-transparent bg-primary/20 text-primary hover:bg-primary/30 border border-primary/20",
|
||||
success: "border-transparent bg-green-500/20 text-green-400 border border-green-500/20",
|
||||
warning: "border-transparent bg-yellow-500/20 text-yellow-400 border border-yellow-500/20",
|
||||
outline: "text-foreground border border-white/10 hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
return (
|
||||
<div className={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${styles[variant]}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Table - Subtle borders
|
||||
export const TableHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => <thead className="[&_tr]:border-b [&_tr]:border-white/5">{children}</thead>;
|
||||
export const TableRow: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => <tr className={`border-b border-white/5 transition-colors hover:bg-white/5 data-[state=selected]:bg-muted ${className}`}>{children}</tr>;
|
||||
export const TableHead: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => <th className={`h-10 px-4 text-left align-middle text-sm font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 ${className}`}>{children}</th>;
|
||||
export const TableCell: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => <td className={`p-4 align-middle text-sm [&:has([role=checkbox])]:pr-0 ${className}`}>{children}</td>;
|
||||
|
||||
// Drawer (Side Sheet)
|
||||
interface DrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={onClose} />
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="relative ml-auto flex h-full w-full max-w-md flex-col overflow-y-auto bg-background/95 border-l border-white/10 p-6 shadow-2xl animate-in slide-in-from-right sm:max-w-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Dialog (Modal)
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ isOpen, onClose, title, children, footer }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm transition-opacity animate-in fade-in" onClick={onClose} />
|
||||
<div className="relative z-50 w-full max-w-lg rounded-xl border border-white/10 bg-card p-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<div className="flex flex-col space-y-1.5 text-center sm:text-left mb-4">
|
||||
<h2 className="text-lg font-semibold leading-none tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
index.html
Normal file
137
index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI VideoAssistant</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
/* Tech/Dark Theme Palette */
|
||||
--background: 224 71% 4%; /* Very Dark Blue #020817 */
|
||||
--foreground: 210 40% 98%; /* Light Gray/White */
|
||||
|
||||
--card: 224 71% 5%; /* Slightly lighter than bg, will use transparency */
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 196 100% 50%; /* Cyan/Electric Blue for high tech feel */
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 196 100% 50%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
/* Subtle Grid Pattern */
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@^7.13.0",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.39.0",
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
Normal file
15
index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
8
metadata.json
Normal file
8
metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "AI VideoAssistant",
|
||||
"description": "A minimalist AI Assistant management system featuring agent configuration, knowledge base management, call logs, and a debugging suite.",
|
||||
"requestFramePermissions": [
|
||||
"microphone",
|
||||
"camera"
|
||||
]
|
||||
}
|
||||
2777
package-lock.json
generated
Normal file
2777
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ai-videoassistant",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"@google/genai": "^1.39.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
703
pages/Assistants.tsx
Normal file
703
pages/Assistants.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||||
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
|
||||
import { Assistant, TabValue } from '../types';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
export const AssistantsPage: React.FC = () => {
|
||||
const [assistants, setAssistants] = useState<Assistant[]>(mockAssistants);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
|
||||
const [debugOpen, setDebugOpen] = useState(false);
|
||||
const [hotwordInput, setHotwordInput] = useState('');
|
||||
|
||||
// State for delete confirmation dialog
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
|
||||
|
||||
const filteredAssistants = assistants.filter(a =>
|
||||
a.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
const newId = Date.now().toString();
|
||||
const newAssistant: Assistant = {
|
||||
id: newId,
|
||||
name: 'New Assistant',
|
||||
callCount: 0,
|
||||
opener: '',
|
||||
prompt: '',
|
||||
knowledgeBaseId: '',
|
||||
language: 'zh',
|
||||
voice: 'default',
|
||||
speed: 1,
|
||||
hotwords: []
|
||||
};
|
||||
setAssistants([...assistants, newAssistant]);
|
||||
setSelectedId(newId);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
|
||||
e.stopPropagation();
|
||||
const newAssistant = { ...assistant, id: Date.now().toString(), name: `${assistant.name} (Copy)` };
|
||||
setAssistants([...assistants, newAssistant]);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
setAssistants(prev => prev.filter(a => a.id !== deleteId));
|
||||
if (selectedId === deleteId) setSelectedId(null);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAssistant = (field: keyof Assistant, value: any) => {
|
||||
if (!selectedId) return;
|
||||
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
|
||||
};
|
||||
|
||||
const addHotword = () => {
|
||||
if (hotwordInput.trim() && selectedAssistant) {
|
||||
updateAssistant('hotwords', [...selectedAssistant.hotwords, hotwordInput.trim()]);
|
||||
setHotwordInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeHotword = (word: string) => {
|
||||
if (selectedAssistant) {
|
||||
updateAssistant('hotwords', selectedAssistant.hotwords.filter(w => w !== word));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6rem)] gap-6 animate-in fade-in">
|
||||
{/* LEFT COLUMN: List */}
|
||||
<div className="w-80 flex flex-col gap-4 shrink-0">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xl font-bold tracking-tight">小助手列表</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
className="pl-9 bg-card/50 border-white/5"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="icon" onClick={handleCreate} title="新建小助手">
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{filteredAssistants.map(assistant => (
|
||||
<div
|
||||
key={assistant.id}
|
||||
onClick={() => setSelectedId(assistant.id)}
|
||||
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
selectedId === assistant.id
|
||||
? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.15)]'
|
||||
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={`font-semibold truncate pr-6 ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
|
||||
{assistant.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Phone className="h-3 w-3 mr-1.5 opacity-70" />
|
||||
<span>{assistant.callCount} 次通话</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Actions */}
|
||||
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, assistant)} title="复制">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, assistant.id)} title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredAssistants.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
未找到小助手
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Config Panel */}
|
||||
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
|
||||
{selectedAssistant ? (
|
||||
<>
|
||||
{/* Header Area */}
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-4">
|
||||
{/* Row 1: Name and Actions - Aligned with items-end */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground font-mono mb-2 block ml-1">ASSISTANT NAME</label>
|
||||
<Input
|
||||
value={selectedAssistant.name}
|
||||
onChange={(e) => updateAssistant('name', e.target.value)}
|
||||
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDebugOpen(true)}
|
||||
className="border border-primary/20 hover:border-primary/50 text-primary hover:text-primary hover:bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)]"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> 调试
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => alert("发布成功!")}
|
||||
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
|
||||
>
|
||||
<Rocket className="mr-2 h-4 w-4" /> 发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Tabs */}
|
||||
<div className="flex bg-white/5 p-1 rounded-lg w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab(TabValue.GLOBAL)}
|
||||
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.GLOBAL ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
全局配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab(TabValue.VOICE)}
|
||||
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
语音配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Scroll Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
|
||||
{activeTab === TabValue.GLOBAL ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground flex items-center">
|
||||
<MessageSquare className="w-4 h-4 mr-2 text-primary"/> 开场白 (Opener)
|
||||
</label>
|
||||
<Input
|
||||
value={selectedAssistant.opener}
|
||||
onChange={(e) => updateAssistant('opener', e.target.value)}
|
||||
placeholder="例如:您好,我是您的专属AI助手..."
|
||||
className="bg-white/5 border-white/10 focus:border-primary/50"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">接通通话后的第一句话。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground flex items-center">
|
||||
<BotIcon className="w-4 h-4 mr-2 text-primary"/> 提示词 (Prompt)
|
||||
</label>
|
||||
<textarea
|
||||
className="flex min-h-[200px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y"
|
||||
value={selectedAssistant.prompt}
|
||||
onChange={(e) => updateAssistant('prompt', e.target.value)}
|
||||
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">知识库绑定</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={selectedAssistant.knowledgeBaseId}
|
||||
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
|
||||
>
|
||||
<option value="">如果不选择,则使用通用大模型知识</option>
|
||||
{mockKnowledgeBases.map(kb => (
|
||||
<option key={kb.id} value={kb.id}>{kb.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">语言 (Language)</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={selectedAssistant.language}
|
||||
onChange={(e) => updateAssistant('language', e.target.value)}
|
||||
>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">音色 (Voice)</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={selectedAssistant.voice}
|
||||
onChange={(e) => updateAssistant('voice', e.target.value)}
|
||||
>
|
||||
<option value="default">默认 (Default)</option>
|
||||
<option value="alloy">Alloy</option>
|
||||
<option value="echo">Echo</option>
|
||||
<option value="fable">Fable</option>
|
||||
<option value="onyx">Onyx</option>
|
||||
<option value="nova">Nova</option>
|
||||
<option value="shimmer">Shimmer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 rounded-xl border border-white/5 bg-white/[0.02]">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-medium text-foreground">语速 (Speed)</label>
|
||||
<span className="text-sm font-mono text-primary bg-primary/10 px-2 py-0.5 rounded">{selectedAssistant.speed}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={selectedAssistant.speed}
|
||||
onChange={(e) => updateAssistant('speed', parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0.5x (Slow)</span>
|
||||
<span>1.0x (Normal)</span>
|
||||
<span>2.0x (Fast)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground flex items-center">
|
||||
<Mic className="w-4 h-4 mr-2 text-primary"/> ASR 热词优化 (Hotwords)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={hotwordInput}
|
||||
onChange={(e) => setHotwordInput(e.target.value)}
|
||||
placeholder="输入专有名词或高频词汇..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && addHotword()}
|
||||
className="bg-white/5 border-white/10"
|
||||
/>
|
||||
<Button variant="secondary" onClick={addHotword}>添加</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-lg border border-dashed border-white/10">
|
||||
{selectedAssistant.hotwords.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground py-1">暂无热词</span>
|
||||
)}
|
||||
{selectedAssistant.hotwords.map((word, idx) => (
|
||||
<Badge key={idx} variant="outline">
|
||||
{word}
|
||||
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors">×</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">添加热词可以提高语音识别特定词汇的准确率。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<BotIcon className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">请选择一个小助手</p>
|
||||
<p className="text-sm opacity-60">从左侧列表选择或创建一个新的小助手以开始配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAssistant && (
|
||||
<DebugDrawer
|
||||
isOpen={debugOpen}
|
||||
onClose={() => setDebugOpen(false)}
|
||||
assistant={selectedAssistant}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
title="确认删除"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>确认删除</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-destructive/10 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
您确定要删除此小助手吗?此操作无法撤销。
|
||||
</p>
|
||||
{deleteId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
将要删除: {assistants.find(a => a.id === deleteId)?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Icon helper
|
||||
const BotIcon = ({className}: {className?: string}) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- Debug Drawer Component ---
|
||||
|
||||
const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
|
||||
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
|
||||
const [messages, setMessages] = useState<{role: 'user' | 'model', text: string}[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Media State
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>('');
|
||||
const [selectedMic, setSelectedMic] = useState<string>('');
|
||||
const [isSwapped, setIsSwapped] = useState(false); // False: AI is Big, Local is Small. True: Local is Big, AI is Small.
|
||||
|
||||
// Initialize with opener
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||
} else {
|
||||
// Reset and stop media when closed
|
||||
setMode('text');
|
||||
stopMedia();
|
||||
setIsSwapped(false);
|
||||
}
|
||||
}, [isOpen, assistant]);
|
||||
|
||||
// Auto-scroll logic
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, mode]);
|
||||
|
||||
// Fetch Devices
|
||||
useEffect(() => {
|
||||
if (isOpen && mode === 'video') {
|
||||
const getDevices = async () => {
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
const dev = await navigator.mediaDevices.enumerateDevices();
|
||||
setDevices(dev);
|
||||
|
||||
const cams = dev.filter(d => d.kind === 'videoinput');
|
||||
const mics = dev.filter(d => d.kind === 'audioinput');
|
||||
|
||||
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
|
||||
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error enumerating devices", e);
|
||||
}
|
||||
};
|
||||
getDevices();
|
||||
}
|
||||
}, [isOpen, mode]);
|
||||
|
||||
// Handle Video/Media stream
|
||||
const stopMedia = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleStream = async () => {
|
||||
if (isOpen && mode === 'video') {
|
||||
try {
|
||||
stopMedia();
|
||||
const constraints = {
|
||||
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
|
||||
audio: selectedMic ? { deviceId: { exact: selectedMic } } : true
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to access camera/mic:", err);
|
||||
}
|
||||
} else {
|
||||
stopMedia();
|
||||
}
|
||||
};
|
||||
|
||||
handleStream();
|
||||
return () => stopMedia();
|
||||
}, [mode, isOpen, selectedCamera, selectedMic]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputText.trim()) return;
|
||||
const userMsg = inputText;
|
||||
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (process.env.API_KEY) {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const chat = ai.chats.create({
|
||||
model: "gemini-3-flash-preview",
|
||||
config: { systemInstruction: assistant.prompt },
|
||||
history: messages.map(m => ({ role: m.role, parts: [{ text: m.text }] }))
|
||||
});
|
||||
const result = await chat.sendMessage({ message: userMsg });
|
||||
setMessages(prev => [...prev, { role: 'model', text: result.text || '' }]);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setMessages(prev => [...prev, { role: 'model', text: `[Mock Response]: Received "${userMsg}"` }]);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
stopMedia();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Reusable Messages List Component
|
||||
const TranscriptionLog = () => (
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
|
||||
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-card border border-white/10 shadow-sm text-foreground'}`}>
|
||||
<span className="text-[10px] opacity-70 block mb-0.5 uppercase tracking-wider">{m.role === 'user' ? 'Me' : 'AI'}</span>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper to render the Local Video Element
|
||||
const renderLocalVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover transform scale-x-[-1]"
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">
|
||||
Me
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper to render the "Remote" AI Video Element (Simulated)
|
||||
const renderRemoteVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary/40 animate-ping ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
|
||||
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
|
||||
</div>
|
||||
</div>
|
||||
{!isSmall && (
|
||||
<div className="mt-4 font-mono text-primary animate-pulse text-sm">
|
||||
{assistant.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} onClose={handleClose} title={`调试: ${assistant.name}`}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'text' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('text')}
|
||||
>
|
||||
<MessageSquare className="inline w-4 h-4 mr-1"/> 文本
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'voice' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('voice')}
|
||||
>
|
||||
<Mic className="inline w-4 h-4 mr-1"/> 语音
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'video' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('video')}
|
||||
>
|
||||
<Video className="inline w-4 h-4 mr-1"/> 视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4 gap-2">
|
||||
{mode === 'text' && <TranscriptionLog />}
|
||||
|
||||
{mode === 'voice' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Visualizer Area */}
|
||||
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20 pointer-events-none"></div>
|
||||
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
||||
<Mic className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm relative z-10">通话中...</p>
|
||||
</div>
|
||||
{/* Transcript */}
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'video' && (
|
||||
<div className="flex flex-col h-full space-y-2">
|
||||
{/* Video Area (Top) */}
|
||||
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
||||
{/* Device Settings Bar */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedCamera}
|
||||
onChange={(e) => setSelectedCamera(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'videoinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Camera...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedMic}
|
||||
onChange={(e) => setSelectedMic(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'audioinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Mic...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Container (PiP) */}
|
||||
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
||||
{/* Main Window */}
|
||||
<div className="absolute inset-0">
|
||||
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
|
||||
</div>
|
||||
{/* Small Window */}
|
||||
<div className="absolute bottom-2 right-2 w-24 h-36 z-10 transition-all duration-300">
|
||||
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
|
||||
</div>
|
||||
{/* Swap Button */}
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full h-7 w-7 bg-black/50 hover:bg-primary/80 backdrop-blur text-white border border-white/20"
|
||||
onClick={() => setIsSwapped(!isSwapped)}
|
||||
title="切换窗口"
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript Area (Bottom) */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions - Unified Input */}
|
||||
<div className="shrink-0 space-y-2">
|
||||
{(mode === 'voice' || mode === 'video') && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" size="sm" className="w-full shadow-red-500/20 shadow-lg" onClick={handleClose}>
|
||||
<PhoneOff className="mr-2 h-3 w-3" /> 挂断通话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder={mode === 'text' ? "输入消息..." : "输入文字模拟语音/Input to simulate speech..."}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="icon" onClick={handleSend} disabled={isLoading}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
132
pages/CallLogs.tsx
Normal file
132
pages/CallLogs.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Search, Calendar, Filter } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
|
||||
import { mockCallLogs } from '../services/mockData';
|
||||
|
||||
export const CallLogsPage: React.FC = () => {
|
||||
const [logs] = useState(mockCallLogs);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || log.status === statusFilter;
|
||||
const matchesSource = sourceFilter === 'all' || log.source === sourceFilter;
|
||||
return matchesSearch && matchesStatus && matchesSource;
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
// Generate CSV content
|
||||
const headers = ['ID', 'Agent', 'Source', 'Status', 'Start Time', 'Duration'];
|
||||
const rows = filteredLogs.map(log => [
|
||||
log.id,
|
||||
log.agentName,
|
||||
log.source,
|
||||
log.status,
|
||||
log.startTime,
|
||||
log.duration
|
||||
].join(','));
|
||||
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...rows].join('\n');
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "call_logs.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">视频通话记录</h1>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="mr-2 h-4 w-4" /> 导出记录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索代理小助手..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有来源</option>
|
||||
<option value="debug">调试 (Debug)</option>
|
||||
<option value="external">外部测试 (External)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="connected">已接通</option>
|
||||
<option value="missed">未接通</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="date" className="pl-9 border-0 bg-white/5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编号</TableHead>
|
||||
<TableHead>代理小助手</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>接听状态</TableHead>
|
||||
<TableHead>通话接通时间</TableHead>
|
||||
<TableHead>通话时长</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredLogs.map(log => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">#{log.id}</TableCell>
|
||||
<TableCell className="font-medium">{log.agentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.source === 'debug' ? '调试' : '外部'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.status === 'connected' ? 'success' : 'warning'}>
|
||||
{log.status === 'connected' ? '已接通' : '未接通'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{log.startTime}</TableCell>
|
||||
<TableCell>{log.duration}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLogs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-6 text-muted-foreground">暂无记录</TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
187
pages/Dashboard.tsx
Normal file
187
pages/Dashboard.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter } from 'lucide-react';
|
||||
import { Card, Button } from '../components/UI';
|
||||
import { mockAssistants, getDashboardStats } from '../services/mockData';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
|
||||
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('all');
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getDashboardStats(timeRange, selectedAssistantId);
|
||||
}, [timeRange, selectedAssistantId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">首页概览</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 bg-card/40 backdrop-blur-md p-2 rounded-lg border border-border/50">
|
||||
<div className="flex items-center px-2">
|
||||
<Filter className="h-4 w-4 text-primary mr-2" />
|
||||
<select
|
||||
className="bg-transparent text-sm font-medium focus:outline-none text-foreground [&>option]:bg-background"
|
||||
value={selectedAssistantId}
|
||||
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
||||
>
|
||||
<option value="all">所有小助手</option>
|
||||
{mockAssistants.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border/50 hidden sm:block"></div>
|
||||
<div className="flex bg-muted/50 rounded-md p-1">
|
||||
{(['week', 'month', 'year'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setTimeRange(r)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-sm transition-all ${timeRange === r ? 'bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.3)]' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
{r === 'week' ? '近一周' : r === 'month' ? '近一个月' : '近一年'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="通话数量"
|
||||
value={stats.totalCalls.toString()}
|
||||
icon={<Phone className="h-4 w-4 text-primary" />}
|
||||
trend="+12.5% 较上期"
|
||||
/>
|
||||
<StatCard
|
||||
title="接通率"
|
||||
value={`${stats.answerRate}%`}
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-400" />}
|
||||
trend="+2.1% 较上期"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均通话时长"
|
||||
value={stats.avgDuration}
|
||||
icon={<Clock className="h-4 w-4 text-blue-400" />}
|
||||
trend="-0.5% 较上期"
|
||||
/>
|
||||
<StatCard
|
||||
title="转人工数量"
|
||||
value={stats.humanTransferCount.toString()}
|
||||
icon={<UserCheck className="h-4 w-4 text-purple-400" />}
|
||||
trend="+5% 较上期"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<Card className="p-6 border-primary/20 bg-card/30">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium leading-none flex items-center">
|
||||
<Activity className="h-5 w-5 text-primary mr-2" />
|
||||
通话趋势图
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">展示选定时间范围内的通话量变化</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] w-full">
|
||||
<SimpleAreaChart data={stats.trend} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub Components ---
|
||||
|
||||
const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode; trend?: string }> = ({ title, value, icon, trend }) => (
|
||||
<Card className="p-6 border-border/40 hover:border-primary/50 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="content-end">
|
||||
<div className="text-2xl font-bold tracking-tight text-foreground">{value}</div>
|
||||
{trend && <p className="text-xs text-muted-foreground mt-1">{trend}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> = ({ data }) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const height = 250;
|
||||
const width = 1000;
|
||||
const padding = 20;
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.value)) * 1.2;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * (width - padding * 2) + padding;
|
||||
const y = height - (d.value / maxValue) * (height - padding * 2) - padding;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
const firstPoint = points.split(' ')[0];
|
||||
const lastPoint = points.split(' ')[points.split(' ').length - 1];
|
||||
const fillPath = `${points} ${lastPoint.split(',')[0]},${height} ${firstPoint.split(',')[0]},${height}`;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible" preserveAspectRatio="none">
|
||||
{/* Tech Grid Lines */}
|
||||
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="hsl(var(--border))" strokeWidth="1" />
|
||||
<line x1={padding} y1={padding} x2={width - padding} y2={padding} stroke="hsl(var(--border))" strokeWidth="1" strokeDasharray="4 4" opacity="0.3" />
|
||||
|
||||
{/* Area Fill Gradient */}
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
{/* Glow Filter */}
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Fill Area */}
|
||||
<polygon points={fillPath} fill="url(#chartGradient)" />
|
||||
|
||||
{/* Main Line with Glow */}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#glow)"
|
||||
className="drop-shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Data Points */}
|
||||
{data.length < 20 && data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * (width - padding * 2) + padding;
|
||||
const y = height - (d.value / maxValue) * (height - padding * 2) - padding;
|
||||
return (
|
||||
<circle key={i} cx={x} cy={y} r="4" fill="hsl(var(--background))" stroke="hsl(var(--primary))" strokeWidth="2" />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* X-Axis Labels */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-[2%] text-xs text-muted-foreground pointer-events-none font-mono">
|
||||
{data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map((d, i) => (
|
||||
<span key={i}>{d.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
pages/KnowledgeBase.tsx
Normal file
248
pages/KnowledgeBase.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
|
||||
import { mockKnowledgeBases } from '../services/mockData';
|
||||
import { KnowledgeBase } from '../types';
|
||||
|
||||
export const KnowledgeBasePage: React.FC = () => {
|
||||
const [view, setView] = useState<'list' | 'detail'>('list');
|
||||
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [kbs, setKbs] = useState(mockKnowledgeBases);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const handleSelect = (kb: KnowledgeBase) => {
|
||||
setSelectedKb(kb);
|
||||
setView('detail');
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
setIsUploadOpen(true);
|
||||
};
|
||||
|
||||
if (view === 'detail' && selectedKb) {
|
||||
return (
|
||||
<>
|
||||
<KnowledgeBaseDetail
|
||||
kb={selectedKb}
|
||||
onBack={() => setView('list')}
|
||||
onImport={handleImportClick}
|
||||
/>
|
||||
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">知识库</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 bg-card/50 p-2 rounded-lg border border-white/5 shadow-sm w-full md:w-1/3">
|
||||
<Search className="h-4 w-4 text-muted-foreground ml-2" />
|
||||
<Input
|
||||
placeholder="搜索知识库名称..."
|
||||
className="border-0 shadow-none bg-transparent focus-visible:ring-0"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredKbs.map(kb => (
|
||||
<Card
|
||||
key={kb.id}
|
||||
className="p-6 hover:border-primary/50 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div onClick={() => handleSelect(kb)}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">{kb.name}</h3>
|
||||
<div className="mt-4 space-y-1 text-sm text-muted-foreground">
|
||||
<p>文档数量: {kb.documents.length}</p>
|
||||
<p>创建人: {kb.creator}</p>
|
||||
<p>创建时间: {kb.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Add New Placeholer */}
|
||||
<div className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]">
|
||||
<Plus className="h-8 w-8 mb-2 opacity-50" />
|
||||
<span>新建知识库</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KnowledgeBaseDetail: React.FC<{
|
||||
kb: KnowledgeBase;
|
||||
onBack: () => void;
|
||||
onImport: () => void;
|
||||
}> = ({ kb, onBack, onImport }) => {
|
||||
const [docSearch, setDocSearch] = useState('');
|
||||
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in slide-in-from-right-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{kb.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">创建于 {kb.createdAt} · by {kb.creator}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onImport}>
|
||||
<Upload className="mr-2 h-4 w-4" /> 新增知识 (导入)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-white/5">
|
||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
|
||||
<h3 className="font-medium">文档列表</h3>
|
||||
<div className="w-64">
|
||||
<Input
|
||||
placeholder="搜索文档..."
|
||||
value={docSearch}
|
||||
onChange={(e) => setDocSearch(e.target.value)}
|
||||
className="bg-black/20 border-transparent focus:bg-black/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文档名称</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>上传时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredDocs.length > 0 ? filteredDocs.map(doc => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell className="font-medium flex items-center">
|
||||
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name}
|
||||
</TableCell>
|
||||
<TableCell>{doc.size}</TableCell>
|
||||
<TableCell>{doc.uploadDate}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80">删除</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) : (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-8 text-muted-foreground">暂无文档</TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
// Add new files to existing state
|
||||
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFiles(prev => [...prev, ...Array.from(e.target.files || [])]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="上传知识文档"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={() => { alert('Upload Started!'); onClose(); setFiles([]); }}>确认上传</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-colors ${dragActive ? "border-primary bg-primary/10" : "border-white/10 bg-white/5 hover:bg-white/10"}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
/>
|
||||
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<span className="font-semibold text-primary">点击上传</span> 或将文件拖拽到此处
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">支持 PDF, DOCX, TXT (Max 10MB)</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4 space-y-2 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<FileIcon className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm truncate max-w-[200px]">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</div>
|
||||
<button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
56
pages/Profile.tsx
Normal file
56
pages/Profile.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { User, Globe, LogOut, Settings } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../components/UI';
|
||||
|
||||
export const ProfilePage: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-in fade-in pt-10">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-20 w-20 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-3xl font-bold">
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin User</h1>
|
||||
<p className="text-muted-foreground">admin@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<Card className="p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center"><User className="mr-2 h-5 w-5"/> 账户信息</h2>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">用户名</label>
|
||||
<Input defaultValue="Admin User" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">邮箱</label>
|
||||
<Input defaultValue="admin@example.com" disabled className="bg-muted"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button>保存更改</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center"><Settings className="mr-2 h-5 w-5"/> 系统设置</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<span>语言选择 / Language</span>
|
||||
</div>
|
||||
<select className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
<option>中文</option>
|
||||
<option>English</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button variant="destructive" className="w-full">
|
||||
<LogOut className="mr-2 h-4 w-4" /> 退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
260
pages/VoiceLibrary.tsx
Normal file
260
pages/VoiceLibrary.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Search, Mic2, Play, Pause, Upload, X, Filter } from 'lucide-react';
|
||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||||
import { mockVoices } from '../services/mockData';
|
||||
import { Voice } from '../types';
|
||||
|
||||
export const VoiceLibraryPage: React.FC = () => {
|
||||
const [voices, setVoices] = useState<Voice[]>(mockVoices);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax'>('all');
|
||||
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
|
||||
const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all');
|
||||
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
|
||||
const filteredVoices = voices.filter(voice => {
|
||||
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesVendor = vendorFilter === 'all' || voice.vendor === vendorFilter;
|
||||
const matchesGender = genderFilter === 'all' || voice.gender === genderFilter;
|
||||
const matchesLang = langFilter === 'all' || voice.language === langFilter;
|
||||
return matchesSearch && matchesVendor && matchesGender && matchesLang;
|
||||
});
|
||||
|
||||
const handlePlayToggle = (id: string) => {
|
||||
if (playingVoiceId === id) {
|
||||
setPlayingVoiceId(null);
|
||||
} else {
|
||||
setPlayingVoiceId(id);
|
||||
// Mock auto-stop after 3 seconds
|
||||
setTimeout(() => {
|
||||
setPlayingVoiceId((current) => current === id ? null : current);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneSuccess = (newVoice: Voice) => {
|
||||
setVoices([newVoice, ...voices]);
|
||||
setIsCloneModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">声音库</h1>
|
||||
<Button onClick={() => setIsCloneModalOpen(true)}>
|
||||
<Mic2 className="mr-2 h-4 w-4" /> 克隆声音
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索声音名称..."
|
||||
className="pl-9 border-0 bg-white/5"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={vendorFilter}
|
||||
onChange={(e) => setVendorFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有厂商</option>
|
||||
<option value="Ali">阿里 (Ali)</option>
|
||||
<option value="Volcano">火山 (Volcano)</option>
|
||||
<option value="Minimax">Minimax</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={genderFilter}
|
||||
onChange={(e) => setGenderFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有性别</option>
|
||||
<option value="Male">男 (Male)</option>
|
||||
<option value="Female">女 (Female)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
|
||||
value={langFilter}
|
||||
onChange={(e) => setLangFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">所有语言</option>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>声音名称</TableHead>
|
||||
<TableHead>厂商</TableHead>
|
||||
<TableHead>性别</TableHead>
|
||||
<TableHead>语言</TableHead>
|
||||
<TableHead className="text-right">试听</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
{filteredVoices.map(voice => (
|
||||
<TableRow key={voice.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span>{voice.name}</span>
|
||||
{voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{voice.vendor}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{voice.gender === 'Male' ? '男' : '女'}</TableCell>
|
||||
<TableCell>{voice.language === 'zh' ? '中文' : 'English'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePlayToggle(voice.id)}
|
||||
className={playingVoiceId === voice.id ? "text-primary animate-pulse" : ""}
|
||||
>
|
||||
{playingVoiceId === voice.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredVoices.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CloneVoiceModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CloneVoiceModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (voice: Voice) => void
|
||||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name || !file) {
|
||||
alert("请填写名称并上传音频文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock creation
|
||||
const newVoice: Voice = {
|
||||
id: `v-${Date.now()}`,
|
||||
name: name,
|
||||
vendor: 'Volcano', // Default for cloned voices
|
||||
gender: 'Female', // Mock default
|
||||
language: 'zh',
|
||||
description: description || 'User cloned voice'
|
||||
};
|
||||
|
||||
onSuccess(newVoice);
|
||||
|
||||
// Reset
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="克隆声音"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit}>开始克隆</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">语音名称</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="给新声音起个名字"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">上传音频文件 (参考音频)</label>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="flex items-center space-x-2 text-primary">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{file.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">点击上传 WAV/MP3 文件</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">语音描述</label>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="描述声音特点(如:年轻、沉稳...)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
130
services/mockData.ts
Normal file
130
services/mockData.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
import { Assistant, CallLog, KnowledgeBase, Voice } from '../types';
|
||||
|
||||
export const mockAssistants: Assistant[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Customer Support Bot',
|
||||
callCount: 154,
|
||||
opener: 'Hello, how can I help you today?',
|
||||
prompt: 'You are a helpful customer service agent.',
|
||||
knowledgeBaseId: 'kb1',
|
||||
language: 'en',
|
||||
voice: 'alloy',
|
||||
speed: 1.0,
|
||||
hotwords: ['refund', 'order'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Sales Agent',
|
||||
callCount: 89,
|
||||
opener: 'Hi! Are you interested in our new product?',
|
||||
prompt: 'You are an energetic sales representative.',
|
||||
knowledgeBaseId: 'kb2',
|
||||
language: 'zh',
|
||||
voice: 'echo',
|
||||
speed: 1.1,
|
||||
hotwords: ['price', 'discount'],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockKnowledgeBases: KnowledgeBase[] = [
|
||||
{
|
||||
id: 'kb1',
|
||||
name: 'Product Manuals',
|
||||
creator: 'Admin',
|
||||
createdAt: '2023-10-15',
|
||||
documents: [
|
||||
{ id: 'd1', name: 'User Guide v1.pdf', size: '2.4 MB', uploadDate: '2023-10-15' },
|
||||
{ id: 'd2', name: 'Warranty Info.docx', size: '1.1 MB', uploadDate: '2023-10-16' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kb2',
|
||||
name: 'Sales Scripts',
|
||||
creator: 'Sales Lead',
|
||||
createdAt: '2023-11-01',
|
||||
documents: [
|
||||
{ id: 'd3', name: 'Objection Handling.pdf', size: '500 KB', uploadDate: '2023-11-01' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockCallLogs: CallLog[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
source: 'external',
|
||||
status: 'connected',
|
||||
startTime: '2023-11-20 10:30:00',
|
||||
duration: '5m 23s',
|
||||
agentName: 'Customer Support Bot',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
source: 'debug',
|
||||
status: 'connected',
|
||||
startTime: '2023-11-20 11:15:00',
|
||||
duration: '1m 10s',
|
||||
agentName: 'Sales Agent',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
source: 'external',
|
||||
status: 'missed',
|
||||
startTime: '2023-11-20 12:00:00',
|
||||
duration: '0s',
|
||||
agentName: 'Customer Support Bot',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockVoices: Voice[] = [
|
||||
{ id: 'v1', name: 'Xiaoyun', vendor: 'Ali', gender: 'Female', language: 'zh', description: 'Gentle and professional.' },
|
||||
{ id: 'v2', name: 'Kevin', vendor: 'Volcano', gender: 'Male', language: 'en', description: 'Deep and authoritative.' },
|
||||
{ id: 'v3', name: 'Abby', vendor: 'Minimax', gender: 'Female', language: 'en', description: 'Cheerful and lively.' },
|
||||
{ id: 'v4', name: 'Guang', vendor: 'Ali', gender: 'Male', language: 'zh', description: 'Standard newscast style.' },
|
||||
{ id: 'v5', name: 'Doubao', vendor: 'Volcano', gender: 'Female', language: 'zh', description: 'Cute and young.' },
|
||||
];
|
||||
|
||||
// --- Dashboard Mock Data Helpers ---
|
||||
|
||||
export interface DashboardStats {
|
||||
totalCalls: number;
|
||||
answerRate: number;
|
||||
avgDuration: string;
|
||||
humanTransferCount: number;
|
||||
trend: { label: string; value: number }[];
|
||||
}
|
||||
|
||||
export const getDashboardStats = (timeRange: 'week' | 'month' | 'year', assistantId: string): DashboardStats => {
|
||||
// Simulate data variation based on inputs
|
||||
const multiplier = assistantId === 'all' ? 1 : (assistantId === '1' ? 0.6 : 0.4);
|
||||
const rangeMultiplier = timeRange === 'week' ? 1 : (timeRange === 'month' ? 4 : 52);
|
||||
|
||||
const baseCalls = Math.floor(100 * rangeMultiplier * multiplier);
|
||||
const transfers = Math.floor(baseCalls * 0.15); // 15% transfer rate
|
||||
|
||||
// Generate Trend Data
|
||||
let points = 7;
|
||||
if (timeRange === 'month') points = 30;
|
||||
if (timeRange === 'year') points = 12;
|
||||
|
||||
const trend = Array.from({ length: points }, (_, i) => {
|
||||
let label = `Day ${i + 1}`;
|
||||
if (timeRange === 'year') {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
label = months[i];
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: Math.floor(Math.random() * 50 * multiplier) + 10
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalCalls: baseCalls,
|
||||
answerRate: 85 + Math.floor(Math.random() * 10), // 85-95%
|
||||
avgDuration: `${Math.floor(2 + Math.random() * 3)}m ${Math.floor(Math.random() * 60)}s`,
|
||||
humanTransferCount: transfers,
|
||||
trend
|
||||
};
|
||||
};
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
59
types.ts
Normal file
59
types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
export interface Assistant {
|
||||
id: string;
|
||||
name: string;
|
||||
callCount: number;
|
||||
opener: string;
|
||||
prompt: string;
|
||||
knowledgeBaseId: string;
|
||||
language: 'zh' | 'en';
|
||||
voice: string;
|
||||
speed: number;
|
||||
hotwords: string[];
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
documents: KnowledgeDocument[];
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
uploadDate: string;
|
||||
}
|
||||
|
||||
export interface CallLog {
|
||||
id: string;
|
||||
source: 'debug' | 'external';
|
||||
status: 'connected' | 'missed';
|
||||
startTime: string;
|
||||
duration: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
language: 'zh' | 'en';
|
||||
}
|
||||
|
||||
export interface Voice {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: 'Ali' | 'Volcano' | 'Minimax';
|
||||
gender: 'Male' | 'Female';
|
||||
language: 'zh' | 'en';
|
||||
description?: string;
|
||||
previewUrl?: string; // Mock url
|
||||
}
|
||||
|
||||
export enum TabValue {
|
||||
GLOBAL = 'global',
|
||||
VOICE = 'voice'
|
||||
}
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user