feat: Phase 3.5 AI App Management - lifecycle, templates, versioning, analytics
This commit is contained in:
1 parent
e7d1c39c18
commit
6583bd2a62
7 files changed
+2185
-5
No files matched your search
@@ -76,7 +76,8 @@ import AdminKnowledge from '@/pages/admin/Knowledge';
|
||||
import AdminStorage from '@/pages/admin/Storage';
|
||||
import AdminMcp from '@/pages/admin/Mcp';
|
||||
import AdminAutomation from '@/pages/admin/Automation';
|
||||
import AdminDeveloper from '@/pages/admin/Developer';;
|
||||
import AdminDeveloper from '@/pages/admin/Developer';
|
||||
import AdminApps from '@/pages/admin/Apps';;
|
||||
|
||||
|
||||
|
||||
@@ -238,6 +239,7 @@ const App: React.FC = () => {
|
||||
<Route path="storage" element={<AdminStorage />} />
|
||||
<Route path="automation" element={<AdminAutomation />} />
|
||||
<Route path="developer" element={<AdminDeveloper />} />
|
||||
<Route path="apps" element={<AdminApps />} />
|
||||
</Route>
|
||||
|
||||
|
||||
|
||||
@@ -575,6 +575,21 @@ export const adminAPI = {
|
||||
},
|
||||
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
|
||||
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
|
||||
// AI App Management
|
||||
getAIAppStats: () => get<any>('/admin/apps/stats/overview'),
|
||||
getAIApps: (params?: string) => get<any>('/admin/apps/apps' + (params ? '?' + params : '')),
|
||||
getAIApp: (id: string) => get<any>('/admin/apps/apps/' + id),
|
||||
createAIApp: (data: any) => post<any>('/admin/apps/apps', data),
|
||||
updateAIApp: (id: string, data: any) => put<any>('/admin/apps/apps/' + id, data),
|
||||
deleteAIApp: (id: string) => del<any>('/admin/apps/apps/' + id),
|
||||
aiAppLifecycle: (id: string, action: string) => post<any>('/admin/apps/apps/' + id + '/' + action, {}),
|
||||
getAIAppVersions: (id: string) => get<any>('/admin/apps/apps/' + id + '/versions'),
|
||||
getAIAppAnalytics: (id: string) => get<any>('/admin/apps/apps/' + id + '/analytics'),
|
||||
getAIAppTemplates: () => get<any>('/admin/apps/templates'),
|
||||
createAIAppTemplate: (data: any) => post<any>('/admin/apps/templates', data),
|
||||
createAIAppFromTemplate: (templateId: string, data: any) => post<any>('/admin/apps/apps/from-template/' + templateId, data),
|
||||
getAIAppCategories: () => get<any>('/admin/apps/categories')
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -38,9 +38,9 @@ const adminNavItems = [
|
||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },
|
||||
{ path: '/admin/developer', icon: 'developer', label: '开发者API' },];
|
||||
|
||||
|
||||
{ path: '/admin/developer', icon: 'developer', label: '开发者API' },
|
||||
{ path: '/admin/apps', icon: 'apps', label: 'AI应用管理' }
|
||||
];
|
||||
|
||||
const adminIconPaths: Record<string, JSX.Element> = {
|
||||
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
.admin-apps-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-apps-page .admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-apps-page .admin-page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-btn:not(.active) .tab-badge {
|
||||
background: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-input, .filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-input:focus, .filter-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary, .btn-secondary, .btn-sm, .btn-danger, .btn-success, .btn-block {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--color-border); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-sm:hover { background: var(--color-border); }
|
||||
|
||||
.btn-success {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover { opacity: 0.9; }
|
||||
|
||||
.btn-danger {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-block:hover { opacity: 0.9; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Templates Grid */
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.template-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Analytics */
|
||||
.analytics-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.analytics-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.analytics-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover { color: var(--color-text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 20px 0 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Config Section */
|
||||
.config-section {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.version-num {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.version-log {
|
||||
flex: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import './Apps.css';
|
||||
|
||||
interface AIApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
category: string;
|
||||
tags: string | null;
|
||||
icon: string | null;
|
||||
status: string;
|
||||
version: number;
|
||||
is_template: boolean;
|
||||
is_public: boolean;
|
||||
config: any;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
published_at: string | null;
|
||||
analytics_7d?: any;
|
||||
versions?: any[];
|
||||
}
|
||||
|
||||
interface AppStats {
|
||||
total: number;
|
||||
published: number;
|
||||
draft: number;
|
||||
paused: number;
|
||||
archived: number;
|
||||
templates: number;
|
||||
by_type: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
recent_7d: number;
|
||||
total_versions: number;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
category: string;
|
||||
icon: string | null;
|
||||
config: any;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_NAMES: Record<string, string> = {
|
||||
customer_service: '客服助手',
|
||||
data_analysis: '数据分析',
|
||||
coding: '编程辅助',
|
||||
writing: '内容创作',
|
||||
education: '教育培训',
|
||||
marketing: '营销推广',
|
||||
hr: '人力资源',
|
||||
finance: '财务法务',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
const TYPE_NAMES: Record<string, string> = {
|
||||
chatbot: '聊天机器人',
|
||||
assistant: 'AI助手',
|
||||
workflow: '工作流',
|
||||
api_service: 'API服务',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: '#f59e0b',
|
||||
published: '#10b981',
|
||||
paused: '#6b7280',
|
||||
archived: '#9ca3af',
|
||||
};
|
||||
|
||||
const STATUS_NAMES: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
paused: '已暂停',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
export default function AdminApps() {
|
||||
const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps');
|
||||
const [stats, setStats] = useState<AppStats | null>(null);
|
||||
const [apps, setApps] = useState<AIApp[]>([]);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editApp, setEditApp] = useState<AIApp | null>(null);
|
||||
const [detailApp, setDetailApp] = useState<AIApp | null>(null);
|
||||
const [detailAnalytics, setDetailAnalytics] = useState<any>(null);
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAIAppStats();
|
||||
if (res.code === 0) setStats(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
const fetchApps = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', '20');
|
||||
if (search) params.set('search', search);
|
||||
if (filterType) params.set('app_type', filterType);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (filterCategory) params.set('category', filterCategory);
|
||||
const res = await adminAPI.getAIApps(params.toString());
|
||||
if (res.code === 0) {
|
||||
setApps(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
setTotalPages(res.data.total_pages || 1);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, search, filterType, filterStatus, filterCategory]);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAIAppTemplates();
|
||||
if (res.code === 0) setTemplates(res.data.items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
const fetchDetail = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await adminAPI.getAIApp(id);
|
||||
if (res.code === 0) {
|
||||
setDetailApp(res.data);
|
||||
// Fetch analytics
|
||||
try {
|
||||
const aRes = await adminAPI.getAIAppAnalytics(id);
|
||||
if (aRes.code === 0) setDetailAnalytics(aRes.data);
|
||||
} catch (e) { /* analytics may be empty */ }
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'apps') fetchApps();
|
||||
if (activeTab === 'templates') fetchTemplates();
|
||||
}, [activeTab, fetchApps, fetchTemplates]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIApp(data);
|
||||
if (res.code === 0) {
|
||||
setShowCreateModal(false);
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: any) => {
|
||||
if (!editApp) return;
|
||||
try {
|
||||
const res = await adminAPI.updateAIApp(editApp.id, data);
|
||||
if (res.code === 0) {
|
||||
setEditApp(null);
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此应用吗?此操作不可逆。')) return;
|
||||
try {
|
||||
const res = await adminAPI.deleteAIApp(id);
|
||||
if (res.code === 0) {
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleLifecycle = async (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => {
|
||||
try {
|
||||
const res = await adminAPI.aiAppLifecycle(id, action);
|
||||
if (res.code === 0) {
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
if (detailApp?.id === id) fetchDetail(id);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleCreateTemplate = async (data: any) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIAppTemplate(data);
|
||||
if (res.code === 0) {
|
||||
setShowTemplateModal(false);
|
||||
fetchTemplates();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleFromTemplate = async (templateId: string, name: string) => {
|
||||
try {
|
||||
const res = await adminAPI.createAIAppFromTemplate(templateId, { name });
|
||||
if (res.code === 0) {
|
||||
setActiveTab('apps');
|
||||
fetchApps();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const formatDate = (dt: string | null) => {
|
||||
if (!dt) return '-';
|
||||
try {
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dt; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-apps-page">
|
||||
<div className="admin-page-header">
|
||||
<h1>AI应用管理</h1>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{activeTab === 'apps' && (
|
||||
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>+ 创建应用</button>
|
||||
)}
|
||||
{activeTab === 'templates' && (
|
||||
<button className="btn-primary" onClick={() => setShowTemplateModal(true)}>+ 创建模板</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">应用总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10b981' }}>{stats.published}</div>
|
||||
<div className="stat-label">已发布</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#f59e0b' }}>{stats.draft}</div>
|
||||
<div className="stat-label">草稿</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.templates}</div>
|
||||
<div className="stat-label">应用模板</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#3b82f6' }}>{stats.recent_7d}</div>
|
||||
<div className="stat-label">近7天新增</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs-bar">
|
||||
{([
|
||||
{ key: 'apps' as const, label: '应用列表', count: stats?.total },
|
||||
{ key: 'templates' as const, label: '应用模板', count: stats?.templates },
|
||||
{ key: 'analytics' as const, label: '分析概览', count: undefined as number | undefined },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className="tab-badge">{tab.count}</span>
|
||||
)} </button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Apps Tab */}
|
||||
{activeTab === 'apps' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索应用名称..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="filter-input"
|
||||
/>
|
||||
<select value={filterType} onChange={e => { setFilterType(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STATUS_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} className="filter-select">
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>应用名称</th>
|
||||
<th>类型</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>加载中...</td></tr>
|
||||
) : apps.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)' }}>暂无应用</td></tr>
|
||||
) : apps.map(app => (
|
||||
<tr key={app.id} style={{ cursor: 'pointer' }} onClick={() => fetchDetail(app.id)}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{app.name}</div>
|
||||
{app.description && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{app.description}</div>}
|
||||
</td>
|
||||
<td><span className="badge">{TYPE_NAMES[app.type] || app.type}</span></td>
|
||||
<td>{CATEGORY_NAMES[app.category] || app.category}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
|
||||
{STATUS_NAMES[app.status] || app.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>v{app.version}</td>
|
||||
<td style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{formatDate(app.created_at)}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
<button className="btn-sm" onClick={() => setEditApp(app)}>编辑</button>
|
||||
{app.status === 'draft' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>发布</button>}
|
||||
{app.status === 'published' && <button className="btn-sm" onClick={() => handleLifecycle(app.id, 'pause')}>暂停</button>}
|
||||
{app.status === 'paused' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>恢复</button>}
|
||||
<button className="btn-sm" onClick={() => handleLifecycle(app.id, 'duplicate')}>复制</button>
|
||||
<button className="btn-sm btn-danger" onClick={() => handleDelete(app.id)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span>第 {page} / {totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="templates-grid">
|
||||
{templates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
|
||||
暂无模板,点击右上角"创建模板"添加
|
||||
</div>
|
||||
) : templates.map(tpl => (
|
||||
<div key={tpl.id} className="template-card">
|
||||
<div className="template-icon">{tpl.icon || '📦'}</div>
|
||||
<div className="template-name">{tpl.name}</div>
|
||||
<div className="template-desc">{tpl.description || '暂无描述'}</div>
|
||||
<div className="template-meta">
|
||||
<span className="badge">{TYPE_NAMES[tpl.type] || tpl.type}</span>
|
||||
<span className="badge">{CATEGORY_NAMES[tpl.category] || tpl.category}</span>
|
||||
</div>
|
||||
<button className="btn-primary btn-block" onClick={() => {
|
||||
const name = prompt('请输入应用名称', tpl.name + ' (Instance)');
|
||||
if (name) handleFromTemplate(tpl.id, name);
|
||||
}}>从模板创建</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && stats && (
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '16px' }}>应用类型分布</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '32px' }}>
|
||||
{Object.entries(stats.by_type).map(([type, count]) => (
|
||||
<div key={type} className="analytics-card">
|
||||
<div className="analytics-value">{count}</div>
|
||||
<div className="analytics-label">{TYPE_NAMES[type] || type}</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(stats.by_type).length === 0 && (
|
||||
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '16px' }}>分类分布</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => (
|
||||
<div key={cat} className="analytics-card">
|
||||
<div className="analytics-value">{count}</div>
|
||||
<div className="analytics-label">{CATEGORY_NAMES[cat] || cat}</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(stats.by_category).length === 0 && (
|
||||
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: '32px', marginBottom: '16px' }}>版本统计</h3>
|
||||
<div className="analytics-card" style={{ display: 'inline-block' }}>
|
||||
<div className="analytics-value">{stats.total_versions}</div>
|
||||
<div className="analytics-label">总版本数</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(showCreateModal || editApp) && (
|
||||
<AppFormModal
|
||||
app={editApp}
|
||||
onClose={() => { setShowCreateModal(false); setEditApp(null); }}
|
||||
onSubmit={editApp ? handleUpdate : handleCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Modal */}
|
||||
{showTemplateModal && (
|
||||
<TemplateFormModal
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSubmit={handleCreateTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{detailApp && (
|
||||
<AppDetailDrawer
|
||||
app={detailApp}
|
||||
analytics={detailAnalytics}
|
||||
onClose={() => { setDetailApp(null); setDetailAnalytics(null); }}
|
||||
onAction={handleLifecycle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// App Form Modal
|
||||
// ============================================================
|
||||
|
||||
function AppFormModal({ app, onClose, onSubmit }: {
|
||||
app: AIApp | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(app?.name || '');
|
||||
const [description, setDescription] = useState(app?.description || '');
|
||||
const [type, setType] = useState(app?.type || 'chatbot');
|
||||
const [category, setCategory] = useState(app?.category || 'custom');
|
||||
const [tags, setTags] = useState(app?.tags || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(app?.config?.system_prompt || '');
|
||||
const [temperature, setTemperature] = useState(app?.config?.temperature ?? 0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(app?.config?.max_tokens ?? 2000);
|
||||
const [welcomeMessage, setWelcomeMessage] = useState(app?.config?.welcome_message || '');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) { alert('请输入应用名称'); return; }
|
||||
onSubmit({
|
||||
name, description, type, category, tags,
|
||||
config: {
|
||||
...(app?.config || {}),
|
||||
system_prompt: systemPrompt,
|
||||
temperature: Number(temperature),
|
||||
max_tokens: Number(maxTokens),
|
||||
welcome_message: welcomeMessage,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{app ? '编辑应用' : '创建应用'}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>应用名称 *</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="输入应用名称" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)}>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="应用描述" rows={2} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>标签(逗号分隔)</label>
|
||||
<input type="text" value={tags} onChange={e => setTags(e.target.value)} placeholder="如:客服,电商,自动回复" />
|
||||
</div>
|
||||
<div className="form-section-title">AI配置</div>
|
||||
<div className="form-group">
|
||||
<label>系统提示词 (System Prompt)</label>
|
||||
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="定义AI的角色和行为..." rows={4} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>温度 (Temperature): {temperature}</label>
|
||||
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>最大Token数</label>
|
||||
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min="100" max="32000" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>欢迎消息</label>
|
||||
<input type="text" value={welcomeMessage} onChange={e => setWelcomeMessage(e.target.value)} placeholder="用户打开应用时显示的欢迎语" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>取消</button>
|
||||
<button className="btn-primary" onClick={handleSubmit}>{app ? '保存' : '创建'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Template Form Modal
|
||||
// ============================================================
|
||||
|
||||
function TemplateFormModal({ onClose, onSubmit }: {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState('chatbot');
|
||||
const [category, setCategory] = useState('custom');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [maxTokens, setMaxTokens] = useState(2000);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) { alert('请输入模板名称'); return; }
|
||||
onSubmit({
|
||||
name, description, type, category,
|
||||
config: {
|
||||
system_prompt: systemPrompt,
|
||||
temperature: Number(temperature),
|
||||
max_tokens: Number(maxTokens),
|
||||
skill_ids: [],
|
||||
knowledge_base_ids: [],
|
||||
tools: [],
|
||||
welcome_message: ''
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>创建应用模板</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>模板名称 *</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="如:电商客服机器人" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)}>
|
||||
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}>
|
||||
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="模板描述" rows={2} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>系统提示词</label>
|
||||
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="预置的系统提示词..." rows={4} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>温度: {temperature}</label>
|
||||
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>最大Token数</label>
|
||||
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>取消</button>
|
||||
<button className="btn-primary" onClick={handleSubmit}>创建模板</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// App Detail Drawer
|
||||
// ============================================================
|
||||
|
||||
function AppDetailDrawer({ app, analytics, onClose, onAction }: {
|
||||
app: AIApp;
|
||||
analytics: any;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => void;
|
||||
}) {
|
||||
const a7 = app.analytics_7d as any;
|
||||
const summary = analytics?.summary;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} style={{ justifyContent: 'flex-end' }}>
|
||||
<div className="modal-content" style={{ maxWidth: '560px', height: '100vh', borderRadius: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{app.name}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ overflow: 'auto' }}>
|
||||
{/* Status & Meta */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
|
||||
{STATUS_NAMES[app.status] || app.status}
|
||||
</span>
|
||||
<span className="badge">{TYPE_NAMES[app.type] || app.type}</span>
|
||||
<span className="badge">{CATEGORY_NAMES[app.category] || app.category}</span>
|
||||
<span className="badge">v{app.version}</span>
|
||||
</div>
|
||||
|
||||
{app.description && <p style={{ color: 'var(--color-text-secondary)', marginBottom: '16px' }}>{app.description}</p>}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: '24px' }}>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.conversations || 0}</div>
|
||||
<div className="stat-label">7天对话</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.tokens || 0}</div>
|
||||
<div className="stat-label">7天Token</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.errors || 0}</div>
|
||||
<div className="stat-label">7天错误</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<h3 style={{ marginBottom: '12px' }}>应用配置</h3>
|
||||
<div className="config-section">
|
||||
{app.config?.system_prompt && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">系统提示词</span>
|
||||
<div className="config-value">{app.config.system_prompt}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="config-item">
|
||||
<span className="config-label">温度</span>
|
||||
<div className="config-value">{app.config?.temperature ?? 'N/A'}</div>
|
||||
</div>
|
||||
<div className="config-item">
|
||||
<span className="config-label">最大Token</span>
|
||||
<div className="config-value">{app.config?.max_tokens ?? 'N/A'}</div>
|
||||
</div>
|
||||
{app.config?.skill_ids?.length > 0 && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">绑定技能</span>
|
||||
<div className="config-value">{app.config.skill_ids.length} 个技能</div>
|
||||
</div>
|
||||
)}
|
||||
{app.config?.knowledge_base_ids?.length > 0 && (
|
||||
<div className="config-item">
|
||||
<span className="config-label">绑定知识库</span>
|
||||
<div className="config-value">{app.config.knowledge_base_ids.length} 个知识库</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Versions */}
|
||||
{app.versions && app.versions.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>版本历史</h3>
|
||||
<div className="version-list">
|
||||
{app.versions.map((v: any) => (
|
||||
<div key={v.id} className="version-item">
|
||||
<span className="version-num">v{v.version}</span>
|
||||
<span className="version-log">{v.changelog}</span>
|
||||
<span className="version-date">{v.created_at ? new Date(v.created_at).toLocaleDateString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analytics Detail */}
|
||||
{summary && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>分析数据 ({analytics.days}天)</h3>
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)', marginBottom: '16px' }}>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.conversations}</div>
|
||||
<div className="stat-label">总对话数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.messages}</div>
|
||||
<div className="stat-label">总消息数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.tokens_input + summary.tokens_output}</div>
|
||||
<div className="stat-label">总Token</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.active_users}</div>
|
||||
<div className="stat-label">活跃用户</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.avg_response_ms}ms</div>
|
||||
<div className="stat-label">平均响应</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ padding: '12px' }}>
|
||||
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.error_rate}%</div>
|
||||
<div className="stat-label">错误率</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
|
||||
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
|
||||
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
|
||||
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="modal-footer" style={{ justifyContent: 'flex-start' }}>
|
||||
{app.status === 'draft' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>发布</button>}
|
||||
{app.status === 'published' && <button className="btn-secondary" onClick={() => onAction(app.id, 'pause')}>暂停</button>}
|
||||
{app.status === 'paused' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>恢复发布</button>}
|
||||
<button className="btn-secondary" onClick={() => onAction(app.id, 'duplicate')}>复制应用</button>
|
||||
<button className="btn-secondary" onClick={() => onAction(app.id, 'archive')}>归档</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user