feat: support membership and sub account
parent
e33a1c3741
commit
2a4dc72059
13
src/App.tsx
13
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button, Drawer, Spin } from 'antd';
|
import { Button, Drawer, Spin } from 'antd';
|
||||||
import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
|
import { MenuOutlined, SearchOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import CommandPalette from './components/CommandPalette';
|
import CommandPalette from './components/CommandPalette';
|
||||||
import AgentList from './pages/AgentList';
|
import AgentList from './pages/AgentList';
|
||||||
|
|
@ -13,6 +13,8 @@ import PointsMallPage from './pages/PointsMallPage';
|
||||||
import TeamsPage from './pages/TeamsPage';
|
import TeamsPage from './pages/TeamsPage';
|
||||||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||||
import StatsPage from './pages/StatsPage';
|
import StatsPage from './pages/StatsPage';
|
||||||
|
import ProfilePage from './pages/ProfilePage';
|
||||||
|
import PricingPage from './pages/PricingPage';
|
||||||
import SharedSessionPage from './pages/SharedSessionPage';
|
import SharedSessionPage from './pages/SharedSessionPage';
|
||||||
import WorkflowsPage from './pages/WorkflowsPage';
|
import WorkflowsPage from './pages/WorkflowsPage';
|
||||||
import { useAuth } from './store/auth';
|
import { useAuth } from './store/auth';
|
||||||
|
|
@ -29,6 +31,7 @@ export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// 全局快捷键 Ctrl/⌘ + K
|
// 全局快捷键 Ctrl/⌘ + K
|
||||||
|
|
@ -58,6 +61,8 @@ export default function App() {
|
||||||
<Route path="/teams" element={<TeamsPage />} />
|
<Route path="/teams" element={<TeamsPage />} />
|
||||||
<Route path="/prompts" element={<PromptLibraryPage />} />
|
<Route path="/prompts" element={<PromptLibraryPage />} />
|
||||||
<Route path="/stats" element={<StatsPage />} />
|
<Route path="/stats" element={<StatsPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -79,7 +84,11 @@ export default function App() {
|
||||||
<div className="layout-shell">
|
<div className="layout-shell">
|
||||||
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
|
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
|
||||||
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
||||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
|
<Sidebar
|
||||||
|
onOpenPalette={() => setPaletteOpen(true)}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { clearUserStorage } from '../utils/storage';
|
import { clearUserStorage } from '../utils/storage';
|
||||||
|
|
||||||
export const API_BASE_URL = 'https://www.tianchaoai.cc/api/v1/';
|
export const API_BASE_URL = import.meta.env.DEV ? '/api/' : 'https://www.tianchaoai.cc/api/v1/';
|
||||||
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||||
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;
|
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,5 @@ export * from './stats';
|
||||||
export * from './llmProviders';
|
export * from './llmProviders';
|
||||||
export * from './streamChat';
|
export * from './streamChat';
|
||||||
export * from './workflows';
|
export * from './workflows';
|
||||||
|
export * from './membership';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用接口响应包装
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页列表包装
|
||||||
|
*/
|
||||||
|
export interface ApiListData<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员信息接口定义 (匹配后端最新数据结构)
|
||||||
|
*/
|
||||||
|
export interface MembershipInfo {
|
||||||
|
tier: 'trial' | 'pro' | 'ultra' | 'ent_basic' | 'ent_standard' | 'custom';
|
||||||
|
tierName: string;
|
||||||
|
expireAt: number;
|
||||||
|
status: string;
|
||||||
|
isSub: boolean;
|
||||||
|
limits: {
|
||||||
|
tier: string;
|
||||||
|
name: string;
|
||||||
|
maxSubAccounts: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxAgents: number;
|
||||||
|
maxKBSize: number;
|
||||||
|
};
|
||||||
|
usage: {
|
||||||
|
subAccountsCount: number;
|
||||||
|
tokensTotal: number;
|
||||||
|
tokensUsed: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子账号信息接口定义 (匹配后端 snake_case 字段)
|
||||||
|
*/
|
||||||
|
export interface ChildMember {
|
||||||
|
id: string; // 关系 ID
|
||||||
|
parent_user_id: string;
|
||||||
|
child_user_id: string;
|
||||||
|
child_name: string;
|
||||||
|
child_phone: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
allocatedTokens: number;
|
||||||
|
usedTokens: number;
|
||||||
|
authorizedAgentIds: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员与账号管理 API
|
||||||
|
*/
|
||||||
|
export const MembershipAPI = {
|
||||||
|
/**
|
||||||
|
* 获取个人会员信息
|
||||||
|
*/
|
||||||
|
getMe: () => api.get<MembershipInfo>('/membership/me').then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取子账号列表
|
||||||
|
*/
|
||||||
|
listMembers: () => api.get<ApiResponse<ApiListData<ChildMember>>>('/membership/members/list').then(r => r.data.data.items),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加子账号
|
||||||
|
* @param childId 用户ID
|
||||||
|
*/
|
||||||
|
addChild: (childId: string) => api.post<ApiResponse<any>>('/membership/members', { childId }).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除子账号
|
||||||
|
* @param relationId 关系ID
|
||||||
|
*/
|
||||||
|
removeChild: (relationId: string) => api.delete<ApiResponse<any>>(`/membership/members/${relationId}`).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配算力配额
|
||||||
|
*/
|
||||||
|
allocateQuota: (payload: { userId: string; resourceType: 'tokens'; amount: number }) =>
|
||||||
|
api.post<ApiResponse<any>>('/membership/quota', payload).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权资源(智能体/知识库)访问
|
||||||
|
*/
|
||||||
|
authorizeResource: (payload: { userId: string; resourceType: 'agent' | 'knowledge'; resourceId: string; level: 'read' | 'write' }) =>
|
||||||
|
api.post<ApiResponse<any>>('/membership/access', payload).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消资源授权
|
||||||
|
*/
|
||||||
|
revokeResource: (payload: { userId: string; resourceType: 'agent' | 'knowledge'; resourceId: string }) =>
|
||||||
|
api.delete<ApiResponse<any>>('/membership/access', { data: payload }).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起会员购买/升级
|
||||||
|
* @returns 返回 payUrl
|
||||||
|
*/
|
||||||
|
subscribe: (payload: { tier: string; durationDays: number }) =>
|
||||||
|
api.post<ApiResponse<{ payUrl: string }>>('/membership/subscribe', payload).then(r => r.data.data),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
|
color?: string;
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件按钮图标
|
||||||
|
* @param color 图标颜色,默认为 #5C7480
|
||||||
|
* @param size 图标大小,默认为 32
|
||||||
|
*/
|
||||||
|
export function IconAttachment({ color = '#5C7480', size = 32, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 8.875 8.6875)"
|
||||||
|
d="M6.9053 13.0928L12.9053 7.0928Q13.134 6.8769 13.125 6.5625Q13.134 6.2481 12.9053 6.0322Q12.6894 5.8035 12.375 5.8125Q12.0606 5.8035 11.8447 6.0322L5.8447 12.0322Q4.9393 12.9375 3.75 12.9375Q2.5607 12.9375 1.6553 12.0322Q0.75 11.1268 0.75 9.9375Q0.75 8.7482 1.6553 7.8428L8.4053 1.0928Q8.7482 0.75 9.375 0.75Q10.0018 0.75 10.3447 1.0928Q10.6875 1.4357 10.6875 2.0625Q10.6875 2.6893 10.3447 3.0322L3.5947 9.7822Q3.366 9.9981 3.375 10.3125Q3.366 10.6269 3.5947 10.8428Q3.8106 11.0715 4.125 11.0625Q4.4394 11.0715 4.6553 10.8428L11.4053 4.0928Q12.1875 3.3107 12.1875 2.0625Q12.1875 0.8143 11.4053 0.0322Q10.6232 -0.75 9.375 -0.75Q8.1268 -0.75 7.3447 0.0322L0.5947 6.7822Q-0.75 8.1268 -0.75 9.9375Q-0.75 11.7482 0.5947 13.0928Q1.9393 14.4375 3.75 14.4375Q5.5607 14.4375 6.9053 13.0928Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI按钮图标
|
||||||
|
* @param color 图标颜色,默认为 #5C7480
|
||||||
|
* @param size 图标大小,默认为 32
|
||||||
|
*/
|
||||||
|
export function IconPrompt({ color = '#5C7480', size = 32, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 10 8.5)"
|
||||||
|
d="M8.0929 3.9071L6.7115 -0.2372Q6.6207 -0.5383 6.3354 -0.6708Q6.0582 -0.8195 5.7628 -0.7115Q5.5943 -0.6565 5.4697 -0.5303Q5.3435 -0.4057 5.2885 -0.2372L3.9071 3.9071L-0.2372 5.2885Q-0.5383 5.3793 -0.6708 5.6646Q-0.8195 5.9418 -0.7115 6.2372Q-0.6565 6.4057 -0.5303 6.5303Q-0.4057 6.6565 -0.2372 6.7115L3.9071 8.0929L5.2885 12.2372Q5.3793 12.5383 5.6646 12.6708Q5.9418 12.8195 6.2372 12.7115Q6.4057 12.6565 6.5303 12.5303Q6.6565 12.4057 6.7115 12.2372L8.0929 8.0929L12.2372 6.7115Q12.5383 6.6207 12.6708 6.3354Q12.8195 6.0582 12.7115 5.7628Q12.6565 5.5943 12.5303 5.4697Q12.4057 5.3435 12.2372 5.2885L8.0929 3.9071ZM6 2.3717L6.7885 4.7372Q6.8435 4.9057 6.9697 5.0303Q7.0943 5.1565 7.2628 5.2115L9.6283 6L7.2628 6.7885Q7.0943 6.8435 6.9697 6.9697Q6.8435 7.0943 6.7885 7.2628L6 9.6283L5.2115 7.2628Q5.1565 7.0943 5.0303 6.9697Q4.9057 6.8435 4.7372 6.7885L2.3717 6L4.7372 5.2115Q4.9057 5.1565 5.0303 5.0303Q5.1565 4.9057 5.2115 4.7372L6 2.3717Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 19 19)"
|
||||||
|
d="M3.4193 1.0807L2.7531 -0.2516Q2.6537 -0.4655 2.4279 -0.5336Q2.2063 -0.6146 1.9984 -0.5031Q1.9167 -0.4624 1.8523 -0.3977Q1.7876 -0.3333 1.7469 -0.2516L1.0807 1.0807L-0.2516 1.7469Q-0.4655 1.8463 -0.5336 2.0721Q-0.6146 2.2937 -0.5031 2.5016Q-0.4624 2.5833 -0.3977 2.6477Q-0.3333 2.7124 -0.2516 2.7531L1.0807 3.4193L1.7469 4.7516Q1.8463 4.9655 2.0721 5.0336Q2.2937 5.1146 2.5016 5.0031Q2.5833 4.9624 2.6477 4.8977Q2.7124 4.8333 2.7531 4.7516L3.4193 3.4193L4.7516 2.7531Q4.9655 2.6537 5.0336 2.4279Q5.1146 2.2063 5.0031 1.9984Q4.9624 1.9167 4.8977 1.8523Q4.8333 1.7876 4.7516 1.7469L3.4193 1.0807ZM2.25 1.2578L2.4969 1.7516Q2.5376 1.8333 2.6023 1.8977Q2.6667 1.9624 2.7484 2.0031L3.2422 2.25L2.7484 2.4969Q2.6667 2.5376 2.6023 2.6023Q2.5376 2.6667 2.4969 2.7484L2.25 3.2422L2.0031 2.7484Q1.9624 2.6667 1.8977 2.6023Q1.8333 2.5376 1.7516 2.4969L1.2578 2.25L1.7516 2.0031Q1.8333 1.9624 1.8977 1.8977Q1.9624 1.8333 2.0031 1.7516L2.25 1.2578Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 12px;
|
||||||
|
height: 100vh;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed {
|
||||||
|
width: 68px;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-brand {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-search {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-search-input {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0 !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-nav-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-user-card {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 8px 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-toggle:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--color-bg) !important;
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input:hover {
|
||||||
|
border-color: var(--color-border-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-suffix {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0 -4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item.active {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
padding: 12px 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: #F7FCFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user .sidebar-user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #5CCFC4 0%, #F2C94D 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-name {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
@ -9,17 +9,21 @@ import {
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
SunOutlined,
|
LogoutOutlined,
|
||||||
MoonOutlined,
|
LeftOutlined,
|
||||||
LogoutOutlined
|
RightOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
CreditCardOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../store/auth';
|
import { useAuth } from '../store/auth';
|
||||||
import { useTheme } from '../main';
|
|
||||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||||
|
import './Sidebar.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenPalette?: () => void;
|
onOpenPalette?: () => void;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_GROUPS: Array<{
|
const NAV_GROUPS: Array<{
|
||||||
|
|
@ -51,111 +55,112 @@ const NAV_GROUPS: Array<{
|
||||||
{
|
{
|
||||||
label: '商城',
|
label: '商城',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token商城' }
|
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token 商城' },
|
||||||
|
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
export default function Sidebar({ onOpenPalette, onNavigate, collapsed, onToggleCollapse }: Props) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mode, toggle } = useTheme();
|
|
||||||
|
|
||||||
const isMac =
|
const isMac =
|
||||||
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
|
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
|
||||||
const cmdKey = isMac ? '⌘' : 'Ctrl';
|
const cmdKey = isMac ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
|
||||||
<div className="brand">
|
<div className="sidebar-brand">
|
||||||
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
|
<img src={kaiwuIcon} alt="鲸域AI" className="sidebar-brand-logo" />
|
||||||
<span>鲸域AI</span>
|
{!collapsed && <span className="sidebar-brand-name">鲸域AI</span>}
|
||||||
<div className="sidebar-brand-spacer" />
|
<div className="sidebar-brand-toggle" onClick={onToggleCollapse}>
|
||||||
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
{collapsed ? <RightOutlined style={{ fontSize: 12 }} /> : <LeftOutlined style={{ fontSize: 12 }} />}
|
||||||
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
</div>
|
||||||
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="sidebar-search" onClick={onOpenPalette}>
|
||||||
onClick={() => {
|
<div className="sidebar-search-input">
|
||||||
onOpenPalette?.();
|
<SearchOutlined className="sidebar-search-icon" />
|
||||||
onNavigate?.();
|
{!collapsed && (
|
||||||
}}
|
<>
|
||||||
className="nav-item sidebar-search-action"
|
<span className="sidebar-search-placeholder">快速搜索</span>
|
||||||
>
|
<span className="sidebar-search-suffix">{cmdKey} K</span>
|
||||||
<span className="sidebar-search-label">
|
</>
|
||||||
<SearchOutlined className="nav-icon" />
|
)}
|
||||||
<span>快速搜索</span>
|
</div>
|
||||||
</span>
|
|
||||||
<span className="kbd">{cmdKey} K</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{NAV_GROUPS.map((group) => (
|
{NAV_GROUPS.map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label} className="sidebar-nav-group">
|
||||||
<div className="nav-section-label">{group.label}</div>
|
{!collapsed && <div className="sidebar-nav-label">{group.label}</div>}
|
||||||
{group.items.map((it) => (
|
{group.items.map((it) => (
|
||||||
<NavLink
|
<Tooltip key={it.to} title={collapsed ? it.label : ''} placement="right">
|
||||||
key={it.to}
|
<NavLink
|
||||||
to={it.to}
|
to={it.to}
|
||||||
end={it.end}
|
end={it.end}
|
||||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `sidebar-nav-item ${isActive ? 'active' : ''}`}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
<span className="nav-icon">{it.icon}</span>
|
<span className="sidebar-nav-icon">{it.icon}</span>
|
||||||
<span>{it.label}</span>
|
{!collapsed && <span>{it.label}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<Dropdown
|
<div className="sidebar-user">
|
||||||
menu={{
|
<Dropdown
|
||||||
items: [
|
menu={{
|
||||||
{
|
items: [
|
||||||
key: 'name',
|
{
|
||||||
label: <span className="sidebar-user-role">{user.phone}</span>,
|
key: 'name',
|
||||||
disabled: true
|
label: <span className="sidebar-user-role">{user.phone}</span>,
|
||||||
},
|
disabled: true
|
||||||
{ type: 'divider' },
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'profile',
|
||||||
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`,
|
icon: <UserOutlined />,
|
||||||
disabled: true
|
label: '个人中心',
|
||||||
},
|
onClick: () => {
|
||||||
{ type: 'divider' },
|
navigate('/profile');
|
||||||
{
|
onNavigate?.();
|
||||||
key: 'logout',
|
}
|
||||||
icon: <LogoutOutlined />,
|
},
|
||||||
label: '退出登录',
|
{ type: 'divider' },
|
||||||
onClick: async () => {
|
{
|
||||||
await logout();
|
key: 'logout',
|
||||||
navigate('/login');
|
icon: <LogoutOutlined />,
|
||||||
onNavigate?.();
|
label: '退出登录',
|
||||||
|
onClick: async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
onNavigate?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
}}
|
||||||
}}
|
placement="topLeft"
|
||||||
placement="topLeft"
|
>
|
||||||
>
|
<div className="sidebar-user-card">
|
||||||
<div className="sidebar-user">
|
<div className="sidebar-user-avatar">
|
||||||
<Avatar size={32} className="sidebar-user-avatar">
|
{(user.name?.charAt(0) || '?').toUpperCase()}
|
||||||
{(user.name?.charAt(0) || '?').toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<div className="sidebar-user-main">
|
|
||||||
<div className="sidebar-user-name">
|
|
||||||
{user.name}
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-user-role">
|
|
||||||
{user.role === 'admin' ? '管理员' : '成员'}
|
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-user-info">
|
||||||
|
<div className="sidebar-user-name">{user.name}</div>
|
||||||
|
<div className="sidebar-user-role">
|
||||||
|
{user.role === 'admin' ? '管理员' : '成员'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dropdown>
|
||||||
</Dropdown>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
() => ({
|
() => ({
|
||||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
|
colorPrimary: isDark ? '#55a5ff' : '#5CCFC4',
|
||||||
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
|
colorInfo: isDark ? '#72b7ff' : '#5CCFC4',
|
||||||
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
||||||
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
||||||
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@an
|
||||||
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
|
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
|
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
|
||||||
|
import '../styles/marketplace-page-web.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logic: MarketplacePageLogicOutput;
|
logic: MarketplacePageLogicOutput;
|
||||||
|
|
@ -12,35 +13,26 @@ export default function MarketplacePageWeb({ logic }: Props) {
|
||||||
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
|
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="page-container">
|
||||||
<div className="page-hero">
|
<div className="marketplace-header">
|
||||||
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
|
<div className="marketplace-header-content">
|
||||||
<div
|
<div className="marketplace-intro">
|
||||||
style={{
|
<div className="marketplace-badge">
|
||||||
display: 'inline-flex',
|
<CompassOutlined className="marketplace-badge-icon" />
|
||||||
alignItems: 'center',
|
探索社区智能体
|
||||||
gap: 8,
|
</div>
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 999,
|
<h2 className="page-title" style={{ marginBottom: 10 }}>
|
||||||
background: 'var(--color-surface)',
|
找到更适合你的 AI 伙伴
|
||||||
border: '1px solid var(--color-border)',
|
</h2>
|
||||||
color: 'var(--color-text-secondary)',
|
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||||
fontSize: 12,
|
浏览社区创建的智能体,快速复制、微调并投入你的日常工作流。
|
||||||
fontWeight: 500,
|
</div>
|
||||||
marginBottom: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
探索社区智能体
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="hero-title">找到更适合你的 AI 伙伴</h1>
|
|
||||||
<p className="hero-subtitle">
|
|
||||||
浏览社区创建的智能体,快速复制、微调并投入你的日常工作流。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-container" style={{ paddingTop: 28 }}>
|
<div style={{ paddingTop: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,43 @@
|
||||||
@import './marketplace-page-web-large-2k.css';
|
@import './marketplace-page-web-large-2k.css';
|
||||||
@import './marketplace-page-web-ultra-4k.css';
|
@import './marketplace-page-web-ultra-4k.css';
|
||||||
|
|
||||||
.marketplace-web-hero-inner {
|
.marketplace-header {
|
||||||
max-width: var(--marketplace-max-width, 1240px);
|
border-radius: 24px;
|
||||||
margin: 0 auto;
|
padding: 30px 30px 26px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||||
|
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-badge {
|
.marketplace-header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-intro {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-bottom: 18px;
|
background: rgba(255,255,255,0.78);
|
||||||
background: var(--color-surface);
|
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-container {
|
.marketplace-badge-icon {
|
||||||
max-width: var(--marketplace-max-width, 1240px);
|
color: var(--color-brand);
|
||||||
padding-top: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-toolbar {
|
.marketplace-web-toolbar {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { App as AntApp } from 'antd';
|
||||||
|
import { MembershipAPI, MembershipInfo } from '../../api/membership';
|
||||||
|
|
||||||
|
export interface PricingTier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
yearlyPrice?: string;
|
||||||
|
yearlyMonthlyPrice?: string;
|
||||||
|
unit: string;
|
||||||
|
description: string;
|
||||||
|
features: string[];
|
||||||
|
points?: string;
|
||||||
|
models?: string;
|
||||||
|
bonus?: string;
|
||||||
|
tag?: string;
|
||||||
|
buttonText: string;
|
||||||
|
type: 'personal' | 'enterprise';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSONAL_TIERS: PricingTier[] = [
|
||||||
|
{
|
||||||
|
id: 'trial',
|
||||||
|
name: 'Trial',
|
||||||
|
price: '0',
|
||||||
|
unit: '人民币/月',
|
||||||
|
description: '适合首次探索和轻量使用',
|
||||||
|
features: ['首次探索和轻量任务。', '试用期后,可以升级至更高级版本。'],
|
||||||
|
points: '每日登录积分 10',
|
||||||
|
bonus: 'Aura Work: 7天试用积分',
|
||||||
|
tag: '体验',
|
||||||
|
buttonText: '当前订阅',
|
||||||
|
type: 'personal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
price: '139',
|
||||||
|
yearlyPrice: '1390',
|
||||||
|
yearlyMonthlyPrice: '116',
|
||||||
|
unit: '人民币/月',
|
||||||
|
description: '每周工作与日常执行',
|
||||||
|
features: ['适合每周轻量调研、数据分析和任务跟进。', '为稳定的每周产出而设,无需顾虑额外消耗', '限时优惠:订阅用户可享受更多专属权益。'],
|
||||||
|
points: '每日登录积分 30',
|
||||||
|
models: '高质量模型 50次 / 3小时',
|
||||||
|
bonus: '会员积分 4000 / 月',
|
||||||
|
tag: '热门',
|
||||||
|
buttonText: '开始',
|
||||||
|
type: 'personal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ultra',
|
||||||
|
name: 'Ultra',
|
||||||
|
price: '1399',
|
||||||
|
yearlyPrice: '13990',
|
||||||
|
yearlyMonthlyPrice: '1116',
|
||||||
|
unit: '人民币/月',
|
||||||
|
description: '超大项目与更高配置',
|
||||||
|
features: ['适合高强度任务与重度使用场景', '面向需要更高积分与更大灵活性的资源用户', '限时优惠:订阅用户可享受更多专属权益。', '个人套餐顶配版,包含全部能力'],
|
||||||
|
points: '每日登录积分 100',
|
||||||
|
models: '无限制使用高质量模型',
|
||||||
|
bonus: '会员积分 40000 / 月',
|
||||||
|
tag: '旗舰',
|
||||||
|
buttonText: '开始',
|
||||||
|
type: 'personal',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTERPRISE_TIERS: PricingTier[] = [
|
||||||
|
{
|
||||||
|
id: 'ent_basic',
|
||||||
|
name: '企业入门版',
|
||||||
|
price: '20000',
|
||||||
|
unit: '人民币/年',
|
||||||
|
description: '适合快速起步的小团队',
|
||||||
|
features: ['所有子账号共享企业积分池', '全员共享插件', '企业发票 (增值税普票)', '用量报告与成员消耗排行'],
|
||||||
|
points: '企业积分池 35000 / 月',
|
||||||
|
models: '高质量模型配额 100次 / 3小时',
|
||||||
|
bonus: '子账号上限 5',
|
||||||
|
tag: '基础',
|
||||||
|
buttonText: '立即开通',
|
||||||
|
type: 'enterprise',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ent_standard',
|
||||||
|
name: '企业标准版',
|
||||||
|
price: '40000',
|
||||||
|
unit: '人民币/年',
|
||||||
|
description: '适合日常协作提效',
|
||||||
|
features: ['所有子账号共享企业积分池', '全员共享插件', '企业发票 (增值税普票)', '用量报告与成员消耗排行', '成员角色管理'],
|
||||||
|
points: '企业积分池 80000 / 月',
|
||||||
|
models: '高质量模型配额 100次 / 3小时',
|
||||||
|
bonus: '子账号上限 15',
|
||||||
|
tag: '热门',
|
||||||
|
buttonText: '立即开通',
|
||||||
|
type: 'enterprise',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
name: '按需定制',
|
||||||
|
price: '面议',
|
||||||
|
unit: '',
|
||||||
|
description: '适合大型团队 / 行业方案',
|
||||||
|
features: ['独立部署 / 专属网络环境', '统一登录 / 操作记录可追溯', '行业专属插件与技能', '服务保障与 7×24 专属支持', '专属折扣'],
|
||||||
|
points: '企业积分池 面议',
|
||||||
|
models: '高质量模型配额 不限量',
|
||||||
|
bonus: '子账号上限 不限量',
|
||||||
|
tag: '专享',
|
||||||
|
buttonText: '联系我们',
|
||||||
|
type: 'enterprise',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function usePricingLogic() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'personal' | 'enterprise'>('personal');
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
const [membership, setMembership] = useState<MembershipInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMembership();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMembership = async () => {
|
||||||
|
try {
|
||||||
|
const info = await MembershipAPI.getMe();
|
||||||
|
setMembership(info);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load membership', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = async (tier: PricingTier) => {
|
||||||
|
if (tier.price === '面议' || tier.id === 'trial') return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const durationDays = billingCycle === 'monthly' ? 30 : 365;
|
||||||
|
const { payUrl } = await MembershipAPI.subscribe({ tier: tier.id, durationDays });
|
||||||
|
if (payUrl) {
|
||||||
|
window.location.href = payUrl;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '发起订阅失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
membership,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PricingLogicOutput = ReturnType<typeof usePricingLogic>;
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { CheckOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Spin } from 'antd';
|
||||||
|
import type { PricingLogicOutput } from '../PricingLogic';
|
||||||
|
import '../styles/pricing.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: PricingLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DiamondIcon = () => (
|
||||||
|
<span style={{ fontSize: 16, color: 'var(--color-text-tertiary)' }}>◇</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PricingH5({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page pricing-page-h5">
|
||||||
|
<div className="pricing-header">
|
||||||
|
<h1 className="pricing-title">选择套餐</h1>
|
||||||
|
|
||||||
|
<div className="pricing-tabs">
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'personal' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('personal')}
|
||||||
|
>
|
||||||
|
个人版
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'enterprise' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('enterprise')}
|
||||||
|
>
|
||||||
|
企业版
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'personal' && (
|
||||||
|
<div>
|
||||||
|
<div className="billing-toggle">
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'monthly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
>
|
||||||
|
月付
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'yearly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
>
|
||||||
|
年付 <span className="discount-tag">-2月</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '60px 0' }}><Spin size="large" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="pricing-grid">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<div key={tier.id} className={`pricing-card ${tier.tag === '热门' ? 'highlight' : ''}`}>
|
||||||
|
{tier.tag && <div className="pricing-card-tag">{tier.tag}</div>}
|
||||||
|
|
||||||
|
<div className="pricing-card-name">{tier.name}</div>
|
||||||
|
|
||||||
|
<div className="pricing-card-price">
|
||||||
|
{tier.price !== '面议' && <span className="price-symbol">¥</span>}
|
||||||
|
<span className="price-amount">
|
||||||
|
{billingCycle === 'yearly' && tier.yearlyMonthlyPrice
|
||||||
|
? tier.yearlyMonthlyPrice
|
||||||
|
: tier.price}
|
||||||
|
</span>
|
||||||
|
{tier.unit && <span className="price-unit">{tier.unit}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billingCycle === 'yearly' && tier.yearlyPrice && (
|
||||||
|
<div className="pricing-card-price-yearly">
|
||||||
|
¥{tier.yearlyPrice} / 年
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pricing-card-desc">{tier.description}</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={`pricing-card-btn btn-${tier.id.split('_')[0]}`}
|
||||||
|
onClick={() => handleSubscribe(tier)}
|
||||||
|
disabled={tier.id === 'trial'}
|
||||||
|
>
|
||||||
|
{tier.buttonText}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="pricing-card-metrics">
|
||||||
|
{tier.points && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.points}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tier.bonus && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.bonus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tier.models && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.models}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="pricing-card-features">
|
||||||
|
{tier.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="feature-item">
|
||||||
|
<CheckOutlined className="feature-icon-check" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { CheckOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Spin } from 'antd';
|
||||||
|
import type { PricingLogicOutput, PricingTier } from '../PricingLogic';
|
||||||
|
import '../styles/pricing.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: PricingLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DiamondIcon = () => (
|
||||||
|
<span style={{ fontSize: 16, color: 'var(--color-text-tertiary)' }}>◇</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PricingWeb({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page">
|
||||||
|
<div className="pricing-header">
|
||||||
|
<h1 className="pricing-title">选择适合你工作方式的套餐</h1>
|
||||||
|
|
||||||
|
<div className="pricing-tabs">
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'personal' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('personal')}
|
||||||
|
>
|
||||||
|
个人版
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'enterprise' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('enterprise')}
|
||||||
|
>
|
||||||
|
企业版
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'personal' && (
|
||||||
|
<div>
|
||||||
|
<div className="billing-toggle">
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'monthly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
>
|
||||||
|
连续包月
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'yearly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
>
|
||||||
|
连续包年 <span className="discount-tag">立省两月</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '100px 0' }}><Spin size="large" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="pricing-grid">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<div key={tier.id} className={`pricing-card ${tier.tag === '热门' ? 'highlight' : ''}`}>
|
||||||
|
{tier.tag && <div className="pricing-card-tag">{tier.tag}</div>}
|
||||||
|
|
||||||
|
<div className="pricing-card-name">{tier.name}</div>
|
||||||
|
|
||||||
|
<div className="pricing-card-price">
|
||||||
|
{tier.price !== '面议' && <span className="price-symbol">¥</span>}
|
||||||
|
<span className="price-amount">
|
||||||
|
{billingCycle === 'yearly' && tier.yearlyMonthlyPrice
|
||||||
|
? tier.yearlyMonthlyPrice
|
||||||
|
: tier.price}
|
||||||
|
</span>
|
||||||
|
{tier.unit && <span className="price-unit">{tier.unit}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billingCycle === 'yearly' && tier.yearlyPrice && (
|
||||||
|
<div className="pricing-card-price-yearly">
|
||||||
|
¥{tier.yearlyPrice} / 年
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{billingCycle === 'monthly' && <div className="pricing-card-desc">{tier.description}</div>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={`pricing-card-btn btn-${tier.id.split('_')[0]}`}
|
||||||
|
onClick={() => handleSubscribe(tier)}
|
||||||
|
disabled={tier.id === 'trial'}
|
||||||
|
>
|
||||||
|
{tier.buttonText}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="pricing-card-metrics">
|
||||||
|
{tier.points && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.points}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tier.bonus && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.bonus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tier.models && (
|
||||||
|
<div className="metric-item">
|
||||||
|
<DiamondIcon /> {tier.models}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="pricing-card-features">
|
||||||
|
{tier.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="feature-item">
|
||||||
|
<CheckOutlined className="feature-icon-check" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
.pricing-page {
|
||||||
|
padding: 40px 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-header {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tab-item {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tab-item.active {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item.active {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item .discount-tag {
|
||||||
|
color: var(--color-brand);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.highlight {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
right: 24px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-price {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-symbol {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-amount {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-price-yearly {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.highlight .pricing-card-btn {
|
||||||
|
background: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-trial {
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-features {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon-check { color: var(--color-text-tertiary); }
|
||||||
|
.feature-icon-diamond { color: var(--color-text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
.pricing-card-metrics {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 Styles */
|
||||||
|
.pricing-page-h5 {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-page-h5 .pricing-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-page-h5 .pricing-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { usePricingLogic } from './Pricing/PricingLogic';
|
||||||
|
import PricingWeb from './Pricing/components/PricingWeb';
|
||||||
|
import PricingH5 from './Pricing/components/PricingH5';
|
||||||
|
|
||||||
|
const isMobileDevice = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
const logic = usePricingLogic();
|
||||||
|
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(isMobileDevice());
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile ? <PricingH5 logic={logic} /> : <PricingWeb logic={logic} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { App as AntApp } from 'antd';
|
||||||
|
import { MembershipAPI, MembershipInfo, ChildMember } from '../../api/membership';
|
||||||
|
import { AgentAPI, Agent } from '../../api/agents';
|
||||||
|
import { AuthAPI, AuthUser } from '../../api/auth';
|
||||||
|
|
||||||
|
export function useProfileLogic() {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [membership, setMembership] = useState<MembershipInfo | null>(null);
|
||||||
|
const [members, setMembers] = useState<ChildMember[]>([]);
|
||||||
|
const [myAgents, setMyAgents] = useState<Agent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { message, modal } = AntApp.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. 先获取基础的用户信息和会员状态
|
||||||
|
const [u, mInfo] = await Promise.all([
|
||||||
|
AuthAPI.me(),
|
||||||
|
MembershipAPI.getMe(),
|
||||||
|
]);
|
||||||
|
setUser(u);
|
||||||
|
setMembership(mInfo);
|
||||||
|
|
||||||
|
// 2. 根据会员等级判断是否需要获取子账号列表
|
||||||
|
// 只有企业版才支持子账号管理,其他版本调用会返回 403
|
||||||
|
if (mInfo.tier === 'ent_basic' || mInfo.tier === 'ent_standard') {
|
||||||
|
try {
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch members', e);
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取个人拥有的智能体列表
|
||||||
|
if (u.phone) {
|
||||||
|
try {
|
||||||
|
const agents = await AgentAPI.mine(u.phone);
|
||||||
|
setMyAgents(agents);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch agents', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to init profile', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChild = async (childId: string) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.addChild(childId);
|
||||||
|
message.success('添加子账号成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '添加失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveChild = async (relationId: string) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: '确认移除',
|
||||||
|
content: '确定要移除该子账号吗?移除后将取消所有资源授权。',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.removeChild(relationId);
|
||||||
|
message.success('移除成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '移除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllocateQuota = async (userId: string, amount: number) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.allocateQuota({ userId, resourceType: 'tokens', amount });
|
||||||
|
message.success('分配成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '分配失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorizeAgent = async (userId: string, agentId: string, level: 'read' | 'write') => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.authorizeResource({ userId, resourceType: 'agent', resourceId: agentId, level });
|
||||||
|
message.success('授权成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '授权失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAgent = async (userId: string, agentId: string) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.revokeResource({ userId, resourceType: 'agent', resourceId: agentId });
|
||||||
|
message.success('取消授权成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
loading,
|
||||||
|
init,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileLogicOutput = ReturnType<typeof useProfileLogic>;
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal, Input, Select, Space, Tag, InputNumber, Popconfirm, List, Card } from 'antd';
|
||||||
|
import { UserAddOutlined, DeleteOutlined, KeyOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||||
|
import type { ProfileLogicOutput } from '../ProfileLogic';
|
||||||
|
import '../styles/profile.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: ProfileLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileH5({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
loading,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [quotaModalVisible, setQuotaModalVisible] = useState(false);
|
||||||
|
const [authModalVisible, setAuthModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [newChildId, setNewChildId] = useState('');
|
||||||
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
|
const [quotaAmount, setQuotaAmount] = useState<number>(100000);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string>('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page profile-page-h5">
|
||||||
|
<div className="profile-header-card">
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-avatar">
|
||||||
|
{user?.name?.charAt(0) || user?.phone?.slice(-4) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="profile-details">
|
||||||
|
<h2>
|
||||||
|
{user?.name || user?.phone}
|
||||||
|
<span className="profile-tier-badge">
|
||||||
|
{membership?.tierName || '普通用户'}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>
|
||||||
|
至:{membership?.expireAt ? new Date(membership.expireAt).toLocaleDateString() : '永久'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{((membership?.usage?.tokensTotal || 0) / 1000).toFixed(1)}k</span>
|
||||||
|
<span className="stat-label">总算力</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{((membership?.usage?.tokensUsed || 0) / 1000).toFixed(1)}k</span>
|
||||||
|
<span className="stat-label">已使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.subAccountsCount || 0}/{membership?.limits?.maxSubAccounts || 0}</span>
|
||||||
|
<span className="stat-label">子账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>子账号管理</h3>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => setAddModalVisible(true)}
|
||||||
|
disabled={membership?.usage?.subAccountsCount === membership?.limits?.maxSubAccounts}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
loading={loading}
|
||||||
|
dataSource={members}
|
||||||
|
renderItem={(record: any) => (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12, borderRadius: 12 }}
|
||||||
|
actions={[
|
||||||
|
<DashboardOutlined key="quota" onClick={() => { setSelectedMember(record); setQuotaModalVisible(true); }} />,
|
||||||
|
<KeyOutlined key="auth" onClick={() => { setSelectedMember(record); setAuthModalVisible(true); }} />,
|
||||||
|
<Popconfirm key="delete" title="确定移除吗?" onConfirm={() => handleRemoveChild(record.id)}>
|
||||||
|
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
avatar={<div className="member-avatar">{record.child_name?.charAt(0)}</div>}
|
||||||
|
title={record.child_name}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12 }}>{record.child_phone}</div>
|
||||||
|
<div className="quota-display" style={{ fontSize: 12, margin: '4px 0' }}>
|
||||||
|
{record.allocatedTokens?.toLocaleString()} Tokens
|
||||||
|
</div>
|
||||||
|
<div className="resource-tags">
|
||||||
|
{record.authorizedAgentIds?.map((id: string) => {
|
||||||
|
const agent = myAgents.find(a => a.id === id);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={id}
|
||||||
|
closable
|
||||||
|
onClose={() => handleRevokeAgent(record.child_user_id, id)}
|
||||||
|
style={{ borderRadius: 4, fontSize: 10, margin: '2px' }}
|
||||||
|
>
|
||||||
|
{agent?.name || '未知'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals are shared with Web version, but could be optimized for H5 if needed */}
|
||||||
|
<Modal
|
||||||
|
title="添加子账号"
|
||||||
|
open={addModalVisible}
|
||||||
|
confirmLoading={addLoading}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!newChildId.trim()) return;
|
||||||
|
setAddLoading(true);
|
||||||
|
const success = await handleAddChild(newChildId);
|
||||||
|
setAddLoading(false);
|
||||||
|
if (success) {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input placeholder="用户手机号" value={newChildId} onChange={e => setNewChildId(e.target.value)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 分配算力`}
|
||||||
|
open={quotaModalVisible}
|
||||||
|
onOk={() => { handleAllocateQuota(selectedMember.child_user_id, quotaAmount); setQuotaModalVisible(false); }}
|
||||||
|
onCancel={() => setQuotaModalVisible(false)}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1000} step={10000} value={quotaAmount} onChange={val => setQuotaAmount(val || 0)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 授权资源`}
|
||||||
|
open={authModalVisible}
|
||||||
|
onOk={() => { handleAuthorizeAgent(selectedMember.child_user_id, selectedAgentId, 'read'); setAuthModalVisible(false); setSelectedAgentId(''); }}
|
||||||
|
onCancel={() => setAuthModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Select style={{ width: '100%' }} placeholder="选择智能体" value={selectedAgentId} onChange={setSelectedAgentId}>
|
||||||
|
{myAgents.map(a => <Select.Option key={a.id} value={a.id}>{a.name}</Select.Option>)}
|
||||||
|
</Select>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Table, Modal, Input, Select, Space, Tag, InputNumber, Popconfirm } from 'antd';
|
||||||
|
import { UserAddOutlined, DeleteOutlined, KeyOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||||
|
import type { ProfileLogicOutput } from '../ProfileLogic';
|
||||||
|
import '../styles/profile.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: ProfileLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileWeb({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
loading,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [quotaModalVisible, setQuotaModalVisible] = useState(false);
|
||||||
|
const [authModalVisible, setAuthModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [newChildId, setNewChildId] = useState('');
|
||||||
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
|
const [quotaAmount, setQuotaAmount] = useState<number>(100000);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string>('');
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '成员',
|
||||||
|
key: 'member',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<div className="member-info">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{record.child_name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>{record.child_phone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '算力配额',
|
||||||
|
key: 'quota',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<span className="quota-display">{record.allocatedTokens?.toLocaleString() || 0} Tokens</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已授权资源',
|
||||||
|
key: 'resources',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<div className="resource-tags">
|
||||||
|
{record.authorizedAgentIds?.map((id: string) => {
|
||||||
|
const agent = myAgents.find(a => a.id === id);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={id}
|
||||||
|
closable
|
||||||
|
onClose={() => handleRevokeAgent(record.child_user_id, id)}
|
||||||
|
style={{ borderRadius: 4, background: 'var(--color-fill-secondary)', border: 'none' }}
|
||||||
|
>
|
||||||
|
🤖 {agent?.name || '未知智能体'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!record.authorizedAgentIds || record.authorizedAgentIds.length === 0) && (
|
||||||
|
<span style={{ color: 'var(--color-text-tertiary)', fontSize: 12 }}>暂无授权</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '加入时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
render: (val: string) => val ? new Date(val).toLocaleDateString() : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(record);
|
||||||
|
setQuotaModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
分配
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(record);
|
||||||
|
setAuthModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
授权
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定移除该子账号吗?"
|
||||||
|
onConfirm={() => handleRemoveChild(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page">
|
||||||
|
<div className="profile-header-card">
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-avatar">
|
||||||
|
{user?.name?.charAt(0) || user?.phone?.slice(-4) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="profile-details">
|
||||||
|
<h2>
|
||||||
|
{user?.name || user?.phone}
|
||||||
|
<span className="profile-tier-badge">
|
||||||
|
{membership?.tierName || '普通用户'}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
有效期至:{membership?.expireAt ? new Date(membership.expireAt).toLocaleDateString() : '永久有效'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.tokensTotal.toLocaleString() || 0}</span>
|
||||||
|
<span className="stat-label">总算力 (Tokens)</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.tokensUsed.toLocaleString() || 0}</span>
|
||||||
|
<span className="stat-label">已使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.subAccountsCount || 0} / {membership?.limits?.maxSubAccounts || 0}</span>
|
||||||
|
<span className="stat-label">子账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>子账号管理</h3>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => setAddModalVisible(true)}
|
||||||
|
disabled={membership?.usage?.subAccountsCount === membership?.limits?.maxSubAccounts}
|
||||||
|
>
|
||||||
|
添加子账号
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={members}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
className="member-list-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
<Modal
|
||||||
|
title="添加子账号"
|
||||||
|
open={addModalVisible}
|
||||||
|
confirmLoading={addLoading}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!newChildId.trim()) return;
|
||||||
|
setAddLoading(true);
|
||||||
|
const success = await handleAddChild(newChildId);
|
||||||
|
setAddLoading(false);
|
||||||
|
if (success) {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>请输入子账号的用户手机号:</div>
|
||||||
|
<Input
|
||||||
|
placeholder="用户手机号"
|
||||||
|
value={newChildId}
|
||||||
|
onChange={e => setNewChildId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Quota Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 分配算力`}
|
||||||
|
open={quotaModalVisible}
|
||||||
|
onOk={() => {
|
||||||
|
handleAllocateQuota(selectedMember.child_user_id, quotaAmount);
|
||||||
|
setQuotaModalVisible(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setQuotaModalVisible(false)}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>分配数量 (Tokens):</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={1000}
|
||||||
|
step={10000}
|
||||||
|
value={quotaAmount}
|
||||||
|
onChange={val => setQuotaAmount(val || 0)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Auth Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 授权资源`}
|
||||||
|
open={authModalVisible}
|
||||||
|
onOk={() => {
|
||||||
|
handleAuthorizeAgent(selectedMember.child_user_id, selectedAgentId, 'read');
|
||||||
|
setAuthModalVisible(false);
|
||||||
|
setSelectedAgentId('');
|
||||||
|
}}
|
||||||
|
onCancel={() => setAuthModalVisible(false)}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>选择智能体:</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="请选择智能体"
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={setSelectedAgentId}
|
||||||
|
>
|
||||||
|
{myAgents.map(a => (
|
||||||
|
<Select.Option key={a.id} value={a.id}>{a.name}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
.profile-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tier-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-display {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 Styles */
|
||||||
|
.profile-page-h5 {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-h5 .profile-header-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-h5 .profile-stats {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useProfileLogic } from './Profile/ProfileLogic';
|
||||||
|
import ProfileWeb from './Profile/components/ProfileWeb';
|
||||||
|
import ProfileH5 from './Profile/components/ProfileH5';
|
||||||
|
|
||||||
|
const isMobileDevice = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const logic = useProfileLogic();
|
||||||
|
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(isMobileDevice());
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile ? <ProfileH5 logic={logic} /> : <ProfileWeb logic={logic} />;
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export default function StatsTopAgentsCard({ logic }: { logic: StatsPageLogic })
|
||||||
{data.topAgents.length === 0 ? (
|
{data.topAgents.length === 0 ? (
|
||||||
<Empty description="暂无" />
|
<Empty description="暂无" />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="stats-page-agent-list">
|
||||||
{data.topAgents.map((agent, index) => (
|
{data.topAgents.map((agent, index) => (
|
||||||
<div key={agent.id} className="stats-page-agent-item">
|
<div key={agent.id} className="stats-page-agent-item">
|
||||||
<div className="stats-page-agent-header">
|
<div className="stats-page-agent-header">
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,98 @@
|
||||||
|
.chat-side {
|
||||||
|
width: 300px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-side.is-full {
|
.chat-side.is-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-sidebar-header {
|
.chat-agent-sidebar-header {
|
||||||
padding: 16px;
|
padding: 16px 16px 8px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
}
|
||||||
|
|
||||||
|
.chat-agent-create-btn {
|
||||||
|
height: 40px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(79, 209, 197, 0.2) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-create-btn:hover {
|
||||||
|
background: var(--color-brand-hover) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-tab {
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-ink-bar {
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
height: 3px !important;
|
||||||
|
border-radius: 3px 3px 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-sidebar-list {
|
.chat-agent-sidebar-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group-count {
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item {
|
.chat-agent-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
border-radius: 12px;
|
||||||
border-left: 3px solid transparent;
|
margin-bottom: 2px;
|
||||||
transition: background 0.2s;
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item:hover {
|
.chat-agent-item:hover {
|
||||||
|
|
@ -30,22 +100,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item.active {
|
.chat-agent-item.active {
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-3);
|
||||||
border-left: 3px solid var(--color-brand);
|
border-left: 3px solid #5CCFC4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-item.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-avatar-wrap {
|
.chat-agent-avatar-wrap {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-avatar-img {
|
.chat-agent-avatar-img {
|
||||||
|
|
@ -60,7 +141,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-name {
|
.chat-agent-name {
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -77,8 +158,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 200px;
|
padding: 40px 20px;
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,5 +170,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-loading-text {
|
.chat-agent-loading-text {
|
||||||
margin-top: 8px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, Spin, Empty } from 'antd';
|
import { Button, Spin, Empty, Tabs } from 'antd';
|
||||||
import { MessageOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import type { Agent } from '../../../api';
|
import type { Agent } from '../../../api';
|
||||||
import './AgentSidebar.css';
|
import './AgentSidebar.css';
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ export default function AgentSidebar(props: {
|
||||||
}) {
|
}) {
|
||||||
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
|
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
|
||||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('mine');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agentList.length > 0) return;
|
if (agentList.length > 0) return;
|
||||||
|
|
@ -26,42 +27,90 @@ export default function AgentSidebar(props: {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [agentList.length]);
|
}, [agentList.length]);
|
||||||
|
|
||||||
|
const filteredList = useMemo(() => {
|
||||||
|
if (activeTab === 'public') return agentList.filter(a => a.visibility === 'public');
|
||||||
|
if (activeTab === 'private') return agentList.filter(a => a.visibility === 'private');
|
||||||
|
return agentList; // 'mine' tab shows all for now or filter by owner_id if available
|
||||||
|
}, [agentList, activeTab]);
|
||||||
|
|
||||||
|
// 模拟分组逻辑
|
||||||
|
const groupedAgents = useMemo(() => {
|
||||||
|
const groups: Record<string, Agent[]> = {
|
||||||
|
'我的智能体': [],
|
||||||
|
'推荐': []
|
||||||
|
};
|
||||||
|
|
||||||
|
filteredList.forEach(a => {
|
||||||
|
if (a.visibility === 'public') {
|
||||||
|
groups['推荐'].push(a);
|
||||||
|
} else {
|
||||||
|
groups['我的智能体'].push(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(groups).filter(([_, list]) => list.length > 0);
|
||||||
|
}, [filteredList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
|
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
|
||||||
<div className="chat-agent-sidebar-header">
|
<div className="chat-agent-sidebar-header">
|
||||||
<Button block type="dashed" onClick={onCreate}>
|
<Button
|
||||||
+ 创建智能体
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onCreate}
|
||||||
|
className="chat-agent-create-btn"
|
||||||
|
>
|
||||||
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-agent-tabs">
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{ key: 'mine', label: '我的' },
|
||||||
|
{ key: 'public', label: '公开' },
|
||||||
|
{ key: 'private', label: '私有' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="chat-agent-sidebar-list">
|
<div className="chat-agent-sidebar-list">
|
||||||
{agentList.length > 0 ? (
|
{agentList.length > 0 ? (
|
||||||
agentList.map((a) => {
|
groupedAgents.map(([groupName, list]) => (
|
||||||
const isActive = a.id === activeAgentId;
|
<div key={groupName} className="chat-agent-group">
|
||||||
return (
|
<div className="chat-agent-group-label">
|
||||||
<div
|
<span>{groupName}</span>
|
||||||
key={a.id}
|
<span className="chat-agent-group-count">{list.length}</span>
|
||||||
onClick={() => onSelect(a.id)}
|
|
||||||
className={`chat-agent-item ${isActive ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="chat-agent-avatar-wrap"
|
|
||||||
style={{ background: a.avatar || 'var(--gradient-brand)' }}
|
|
||||||
>
|
|
||||||
{isImageUrl(a.avatar) ? (
|
|
||||||
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
|
|
||||||
) : (
|
|
||||||
(a.name?.charAt(0) || '?').toUpperCase()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="chat-agent-info">
|
|
||||||
<div className="chat-agent-name">
|
|
||||||
{a.name}
|
|
||||||
{!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{list.map((a) => {
|
||||||
})
|
const isActive = a.id === activeAgentId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => onSelect(a.id)}
|
||||||
|
className={`chat-agent-item ${isActive ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="chat-agent-avatar-wrap"
|
||||||
|
style={{ background: a.avatar || 'var(--gradient-brand)' }}
|
||||||
|
>
|
||||||
|
{isImageUrl(a.avatar) ? (
|
||||||
|
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
|
||||||
|
) : (
|
||||||
|
(a.name?.charAt(0) || '?').toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="chat-agent-info">
|
||||||
|
<div className="chat-agent-name">{a.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
) : !loadingTimeout ? (
|
) : !loadingTimeout ? (
|
||||||
<div className="chat-agent-sidebar-status">
|
<div className="chat-agent-sidebar-status">
|
||||||
<Spin />
|
<Spin />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty-welcome {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-message {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
border: 1px solid rgba(49, 130, 206, 0.1);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import type { StreamingState } from '../hooks/useChatSender';
|
||||||
import type { CopyMode } from '../utils/copy';
|
import type { CopyMode } from '../utils/copy';
|
||||||
import MessageItem from './messages/MessageItem';
|
import MessageItem from './messages/MessageItem';
|
||||||
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||||
|
import './ChatBody.css';
|
||||||
|
|
||||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
|
|
@ -29,28 +30,16 @@ export default function ChatBody(props: {
|
||||||
<div ref={bodyRef} className="chat-body">
|
<div ref={bodyRef} className="chat-body">
|
||||||
<div className="messages-container">
|
<div className="messages-container">
|
||||||
{messages.length === 0 && !streaming.active ? (
|
{messages.length === 0 && !streaming.active ? (
|
||||||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
<div className="chat-empty-welcome">
|
||||||
<div
|
<div className="chat-welcome-avatar">
|
||||||
style={{
|
{isImageUrl(agent.avatar) ? (
|
||||||
width: 68,
|
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||||||
height: 68,
|
) : (
|
||||||
borderRadius: '50%',
|
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||||
background: agent.avatar || 'var(--gradient-brand)',
|
)}
|
||||||
color: '#fff',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 32,
|
|
||||||
margin: '0 auto 20px',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImageUrl(agent.avatar) ? <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> : (agent.name?.charAt(0) || '?').toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>你好,今天想一起完成什么?</h2>
|
<h2 className="chat-welcome-title">你好,今天想一起完成什么?</h2>
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -71,50 +60,31 @@ export default function ChatBody(props: {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{streaming.active && (
|
{streaming.active && (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="streaming-message">
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
||||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
||||||
if (streamingAgent) {
|
return (
|
||||||
return (
|
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
|
||||||
<Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}>
|
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||||
{streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'}
|
</Avatar>
|
||||||
</Avatar>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
})()}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="message-item-content">
|
||||||
{(() => {
|
<div className="message-item-header">
|
||||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
<span className="message-item-name">
|
||||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
|
||||||
if (streamingAgent) {
|
</span>
|
||||||
return (
|
</div>
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 6
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 400,
|
|
||||||
color: 'var(--color-text-secondary)'
|
|
||||||
}}>
|
|
||||||
{streamingAgent.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
<div className="bubble assistant">
|
<div className="bubble assistant">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{!!streaming.retryInfo?.message && (
|
{!!streaming.retryInfo?.message && (
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'rgba(59, 130, 246, 0.08)', border: '1px solid rgba(59, 130, 246, 0.18)' }}>
|
<div className="streaming-retry-card">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div className="streaming-retry-header">
|
||||||
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
|
<span className="streaming-retry-title">
|
||||||
|
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
|
||||||
|
</span>
|
||||||
{streaming.retryInfo.stage === 'fallback_model' ? (
|
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||||
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||||||
|
|
@ -126,25 +96,22 @@ export default function ChatBody(props: {
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4, fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
||||||
{!!streaming.retryInfo.reason && (
|
|
||||||
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.5 }}>{String(streaming.retryInfo.reason)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="streaming-section">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>推理过程</div>
|
<div className="streaming-section-label">推理过程</div>
|
||||||
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '6px 0' }} />
|
<Divider style={{ margin: '6px 0' }} />
|
||||||
<div>
|
<div className="streaming-section">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>正式回答</div>
|
<div className="streaming-section-label">正式回答</div>
|
||||||
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||||
<div>
|
<div style={{ marginTop: 8 }}>
|
||||||
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
||||||
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
.chat-header {
|
||||||
|
height: 72px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-model {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-brand);
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-stream-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: medium;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: #F7FCFA;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
background-color: #F7FCFA;
|
||||||
|
border: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-btn:hover {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
border-color: var(--color-border-strong) !important;
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-more-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons';
|
import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||||
import { Button, Dropdown, Modal, Space, Switch } from 'antd';
|
import { Button, Dropdown, Modal, Switch } from 'antd';
|
||||||
import type { Agent } from '../../../api';
|
import type { Agent } from '../../../api';
|
||||||
import { useIsMobile } from '../../../hooks/useIsMobile';
|
import './ChatHeader.css';
|
||||||
|
|
||||||
function formatAgentModel(raw: string | null | undefined) {
|
function formatAgentModel(raw: string | null | undefined) {
|
||||||
const s = String(raw ?? '').trim();
|
const s = String(raw ?? '').trim();
|
||||||
|
|
@ -23,6 +23,8 @@ function formatAgentModel(raw: string | null | undefined) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
export default function ChatHeader(props: {
|
export default function ChatHeader(props: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
useStream: boolean;
|
useStream: boolean;
|
||||||
|
|
@ -35,29 +37,37 @@ export default function ChatHeader(props: {
|
||||||
}) {
|
}) {
|
||||||
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
|
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
|
||||||
const modelText = formatAgentModel(agent.model);
|
const modelText = formatAgentModel(agent.model);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
|
<div className="chat-header">
|
||||||
<div className="chat-header-agent">
|
<div className="chat-header-left">
|
||||||
<div className="chat-header-agent-title">
|
<div className="chat-header-avatar">
|
||||||
<span className="chat-header-agent-name">{agent.name}</span>
|
{isImageUrl(agent.avatar) ? (
|
||||||
{!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>}
|
<img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||||
|
) : (
|
||||||
|
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-header-agent-meta">
|
<div className="chat-header-info">
|
||||||
{modelText}
|
<div className="chat-header-name-row">
|
||||||
|
<span className="chat-header-name">{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-desc">
|
||||||
|
<span className="chat-header-model">#{modelText}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
|
||||||
|
<div className="chat-header-right">
|
||||||
<div className="chat-header-stream-toggle">
|
<div className="chat-header-stream-toggle">
|
||||||
<span className="chat-header-stream-label">流式输出</span>
|
<span>流式输出</span>
|
||||||
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
|
||||||
<Button size="small" onClick={onOpenHistory}>
|
<Button className="chat-header-btn" onClick={onOpenHistory}>
|
||||||
历史对话
|
历史对话
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -76,12 +86,13 @@ export default function ChatHeader(props: {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<Button size="small" type={isMobile ? 'text' : 'primary'}>
|
<Button className="chat-header-more-btn">
|
||||||
{isMobile ? <EllipsisOutlined /> : <>更多 <DownOutlined /></>}
|
<EllipsisOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
.chat-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-wrapper .chat-input-toolbar-top,
|
||||||
|
.chat-input-wrapper .chat-input-card {
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-toolbar-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-actions-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn {
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
padding: 0 10px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn:hover {
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn-primary {
|
||||||
|
background: var(--color-brand-soft) !important;
|
||||||
|
color: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn-primary:hover {
|
||||||
|
background: var(--color-brand-soft-2) !important;
|
||||||
|
color: var(--color-brand-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-token-display {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card {
|
||||||
|
background: #FAFCFC;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card:focus-within {
|
||||||
|
/* border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-focus); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-textarea {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
resize: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-btn {
|
||||||
|
color: var(--color-text-tertiary) !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-btn:hover {
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand-hover) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
background: var(--color-border) !important;
|
||||||
|
color: var(--color-text-tertiary) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-stop-btn {
|
||||||
|
background: var(--color-danger) !important;
|
||||||
|
box-shadow: 0 4px 10px rgba(229, 62, 62, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-tag {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
border: 1px solid var(--color-brand-soft-2);
|
||||||
|
color: var(--color-brand);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
|
import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import type { ChatAttachment } from '../../../api';
|
import type { ChatAttachment } from '../../../api';
|
||||||
import type { Agent } from '../../../api/agents';
|
import type { Agent } from '../../../api/agents';
|
||||||
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
|
import { useState, useRef } from 'react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import './ChatInput.css';
|
||||||
|
import { IconAttachment, IconPrompt } from '../../../components/Icon';
|
||||||
|
|
||||||
export default function ChatInput(props: {
|
export default function ChatInput(props: {
|
||||||
input: string;
|
input: string;
|
||||||
|
|
@ -39,13 +40,9 @@ export default function ChatInput(props: {
|
||||||
onStop,
|
onStop,
|
||||||
onAttach,
|
onAttach,
|
||||||
onOpenTpl,
|
onOpenTpl,
|
||||||
modelOptions,
|
|
||||||
activeModelValue,
|
|
||||||
onChangeModel,
|
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
agentList,
|
agentList,
|
||||||
onInsertMention,
|
|
||||||
showActions = true
|
showActions = true
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -98,7 +95,6 @@ export default function ChatInput(props: {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检测 @ 触发提及选择
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInput(value);
|
setInput(value);
|
||||||
|
|
@ -107,32 +103,17 @@ export default function ChatInput(props: {
|
||||||
const textBeforeCursor = value.slice(0, cursorPos);
|
const textBeforeCursor = value.slice(0, cursorPos);
|
||||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
console.log('[@mention] input change:', {
|
|
||||||
value,
|
|
||||||
cursorPos,
|
|
||||||
textBeforeCursor,
|
|
||||||
atIndex,
|
|
||||||
agentListLength: agentList.length,
|
|
||||||
agentList: agentList.map(a => ({ id: a.id, name: a.name }))
|
|
||||||
});
|
|
||||||
|
|
||||||
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
|
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
|
||||||
const query = textBeforeCursor.slice(atIndex + 1);
|
const query = textBeforeCursor.slice(atIndex + 1);
|
||||||
console.log('[@mention] matched @ at position:', { atIndex, query });
|
|
||||||
if (!query.includes(' ')) {
|
if (!query.includes(' ')) {
|
||||||
setMentionQuery(query);
|
setMentionQuery(query);
|
||||||
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const textarea = inputRef.current?.resizableTextArea?.textArea;
|
const textarea = inputRef.current?.resizableTextArea?.textArea;
|
||||||
if (!textarea) {
|
if (!textarea) return;
|
||||||
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const caret = getCaretPos(textarea, cursorPos);
|
const caret = getCaretPos(textarea, cursorPos);
|
||||||
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
|
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
|
||||||
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
|
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
|
||||||
setMentionPos({ top, left });
|
setMentionPos({ top, left });
|
||||||
console.log('[@mention] show popover:', { top, left, query });
|
|
||||||
setShowMentionPopover(true);
|
setShowMentionPopover(true);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -172,167 +153,166 @@ export default function ChatInput(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-input-wrapper">
|
<div className="chat-input-wrapper">
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
{showActions && (
|
||||||
{attachments.map((a, i) => (
|
<div className="chat-input-toolbar-top">
|
||||||
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
|
<div className="chat-input-actions-left">
|
||||||
📎 {a.name}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{imageUrls.map((u, i) => (
|
|
||||||
<div key={i} style={{ position: 'relative' }}>
|
|
||||||
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
icon={<HistoryOutlined />}
|
||||||
type="primary"
|
onClick={onOpenHistory}
|
||||||
shape="circle"
|
className="chat-input-action-btn"
|
||||||
icon={<CloseOutlined style={{ fontSize: 8 }} />}
|
>
|
||||||
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chat-input-card-wrap">
|
|
||||||
{showActions && <div className="chat-input-actions">
|
|
||||||
<Tooltip title="历史记录">
|
|
||||||
<Button size="small" type="text" className="chat-input-action-btn" icon={<HistoryIcon />} onClick={onOpenHistory}>
|
|
||||||
历史记录
|
历史记录
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
<Button
|
||||||
<Tooltip title="新增会话">
|
type="primary"
|
||||||
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onNewSession}
|
||||||
|
className="chat-input-action-btn chat-input-action-btn-primary"
|
||||||
|
>
|
||||||
新建会话
|
新建会话
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>}
|
|
||||||
<div className="chat-input-card">
|
|
||||||
<div className="chat-input-stack">
|
|
||||||
<Input.TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
|
|
||||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== 'Enter') return;
|
|
||||||
if ((e as any).isComposing) return;
|
|
||||||
|
|
||||||
if (e.metaKey || e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
const el = e.currentTarget;
|
|
||||||
const start = el.selectionStart ?? input.length;
|
|
||||||
const end = el.selectionEnd ?? input.length;
|
|
||||||
const next = input.slice(0, start) + '\n' + input.slice(end);
|
|
||||||
setInput(next);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.selectionStart = el.selectionEnd = start + 1;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!e.shiftKey && !e.altKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="chat-input-textarea"
|
|
||||||
disabled={sending}
|
|
||||||
/>
|
|
||||||
{showMentionPopover && mentionPos && (
|
|
||||||
<div
|
|
||||||
className="mention-popover"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: mentionPos.top,
|
|
||||||
left: mentionPos.left,
|
|
||||||
zIndex: 10000,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
|
||||||
maxHeight: 200,
|
|
||||||
overflowY: 'auto',
|
|
||||||
minWidth: 150
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredAgents.length === 0 ? (
|
|
||||||
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
未找到匹配的智能体
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredAgents.map(agent => (
|
|
||||||
<div
|
|
||||||
key={agent.id}
|
|
||||||
className="mention-item"
|
|
||||||
onClick={() => handleSelectAgent(agent)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: '1px solid var(--color-border)'
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--color-text)' }}>
|
|
||||||
{agent.name}
|
|
||||||
</div>
|
|
||||||
{agent.description && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
{agent.description.slice(0, 30)}
|
|
||||||
{agent.description.length > 30 ? '...' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="chat-input-toolbar">
|
|
||||||
<div className="chat-input-toolbar-left">
|
|
||||||
{/* <Select
|
|
||||||
value={activeModelValue || undefined}
|
|
||||||
className="chat-model-select"
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
options={modelOptions}
|
|
||||||
suffixIcon={<DownOutlined className="chat-model-select-arrow" />}
|
|
||||||
placeholder="选择模型"
|
|
||||||
onChange={onChangeModel}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<Upload
|
|
||||||
className="chat-upload"
|
|
||||||
multiple
|
|
||||||
beforeUpload={(_f, files) => {
|
|
||||||
onAttach(files as File[]);
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
showUploadList={false}
|
|
||||||
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
|
|
||||||
>
|
|
||||||
<Button type="text" className="chat-tool-button" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
|
|
||||||
</Upload>
|
|
||||||
<Button type="text" className="chat-tool-button" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={onOpenTpl} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sending ? (
|
|
||||||
<Button danger shape="circle" onClick={onStop} icon={<span className="chat-stop-icon" />} className="chat-send-button" />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
shape="circle"
|
|
||||||
onClick={onSend}
|
|
||||||
icon={<ArrowUpOutlined />}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
className="chat-send-button chat-send-button-primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* <div className="chat-input-token-display">
|
||||||
|
12,480 tokens
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="chat-input-card">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{attachments.map((a, i) => (
|
||||||
|
<Tag
|
||||||
|
key={i}
|
||||||
|
closable
|
||||||
|
className="chat-attachment-tag"
|
||||||
|
onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
📎 {a.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{imageUrls.map((u, i) => (
|
||||||
|
<div key={i} style={{ position: 'relative', marginBottom: 8 }}>
|
||||||
|
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<CloseOutlined style={{ fontSize: 8 }} />}
|
||||||
|
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input.TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
|
||||||
|
autoSize={{ minRows: 2, maxRows: 10 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if ((e as any).isComposing) return;
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const start = el.selectionStart ?? input.length;
|
||||||
|
const end = el.selectionEnd ?? input.length;
|
||||||
|
const next = input.slice(0, start) + '\n' + input.slice(end);
|
||||||
|
setInput(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.selectionStart = el.selectionEnd = start + 1;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="chat-input-textarea"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="chat-input-bottom-bar">
|
||||||
|
<div className="chat-input-tools">
|
||||||
|
<Upload
|
||||||
|
multiple
|
||||||
|
beforeUpload={(_f, files) => {
|
||||||
|
onAttach(files as File[]);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
showUploadList={false}
|
||||||
|
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
>
|
||||||
|
<Button type="text" className="chat-tool-btn" icon={<IconAttachment />} />
|
||||||
|
</Upload>
|
||||||
|
<Button type="text" className="chat-tool-btn" icon={<IconPrompt />} onClick={onOpenTpl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sending ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
shape="circle"
|
||||||
|
onClick={onStop}
|
||||||
|
icon={<div style={{ width: 10, height: 10, background: '#fff', borderRadius: 2 }} />}
|
||||||
|
className="chat-send-btn chat-stop-btn"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
onClick={onSend}
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="chat-send-btn"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showMentionPopover && mentionPos && (
|
||||||
|
<div
|
||||||
|
className="mention-popover"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: mentionPos.top,
|
||||||
|
left: mentionPos.left,
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
minWidth: 160
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredAgents.length === 0 ? (
|
||||||
|
<div style={{ padding: 8, color: 'var(--color-text-tertiary)', fontSize: 12 }}>
|
||||||
|
未找到匹配的智能体
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAgents.map(agent => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className="mention-item"
|
||||||
|
onClick={() => handleSelectAgent(agent)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid var(--color-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
.chat-outline {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 16px;
|
||||||
|
gap: 24px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline.is-collapsed {
|
||||||
|
width: 52px !important;
|
||||||
|
min-width: 52px !important;
|
||||||
|
padding: 30px 8px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-toggle:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-section {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-title::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-index {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active .chat-outline-index {
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active .chat-outline-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 推荐话题部分 */
|
||||||
|
.chat-outline-recommend {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-item {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-item:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { LeftOutlined, OrderedListOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import type { ChatMessage } from '../../../api';
|
import type { ChatMessage } from '../../../api';
|
||||||
import { markdownToPlainText } from '../utils/copy';
|
import { markdownToPlainText } from '../utils/copy';
|
||||||
|
import './ChatOutline.css';
|
||||||
|
|
||||||
function summarize(content: string) {
|
function summarize(content: string) {
|
||||||
const plain = markdownToPlainText(content);
|
const plain = markdownToPlainText(content);
|
||||||
|
|
@ -12,35 +14,49 @@ function summarize(content: string) {
|
||||||
return text.slice(0, 44) + '…';
|
return text.slice(0, 44) + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (id: string) => void; activeId?: string | null }) {
|
interface ChatOutlineProps {
|
||||||
const { messages, onJump, activeId } = props;
|
messages: ChatMessage[];
|
||||||
|
onJump: (id: string) => void;
|
||||||
|
activeId?: string | null;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatOutline(props: ChatOutlineProps) {
|
||||||
|
const { messages, onJump, activeId, collapsed, onToggleCollapse } = props;
|
||||||
const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent');
|
const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent');
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<aside className="chat-outline">
|
|
||||||
<div className="chat-outline-title">对话大纲</div>
|
|
||||||
<div style={{ padding: 12, color: 'var(--color-text-tertiary)', fontSize: 12 }}>暂无</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="chat-outline">
|
<aside className={`chat-outline ${collapsed ? 'is-collapsed' : ''}`}>
|
||||||
<div className="chat-outline-title">对话大纲</div>
|
<div className="chat-outline-toggle" onClick={onToggleCollapse}>
|
||||||
<div className="chat-outline-list">
|
{collapsed ? <LeftOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||||
{items.map((m, idx) => (
|
</div>
|
||||||
<button
|
|
||||||
key={m.id}
|
<div className="chat-outline-section">
|
||||||
type="button"
|
<div className="chat-outline-title">{collapsed ? <OrderedListOutlined /> : '对话大纲'}</div>
|
||||||
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`}
|
<div className="chat-outline-list"></div>
|
||||||
onClick={() => onJump(m.id)}
|
{items.length === 0 ? (
|
||||||
title={summarize(m.content)}
|
!collapsed && (
|
||||||
>
|
<div style={{ padding: '12px 4px', color: 'var(--color-text-tertiary)', fontSize: 13 }}>
|
||||||
<span className="chat-outline-index">{idx + 1}</span>
|
暂无对话记录
|
||||||
<span className="chat-outline-text">{summarize(m.content)}</span>
|
</div>
|
||||||
</button>
|
)
|
||||||
))}
|
) : (
|
||||||
|
<div className="chat-outline-list">
|
||||||
|
{items.map((m, idx) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onJump(m.id)}
|
||||||
|
title={summarize(m.content)}
|
||||||
|
>
|
||||||
|
<span className="chat-outline-index">{idx + 1}</span>
|
||||||
|
{!collapsed && <span className="chat-outline-text">{summarize(m.content)}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { App as AntApp, Empty } from 'antd';
|
import { App as AntApp, Empty } from 'antd';
|
||||||
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
||||||
import { markdownToPlainText } from '../utils/copy';
|
import { markdownToPlainText } from '../utils/copy';
|
||||||
import type { ModelOverrides } from '../../../api';
|
import type { ModelOverrides } from '../../../api';
|
||||||
|
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
|
||||||
import AgentSidebar from './AgentSidebar';
|
import AgentSidebar from './AgentSidebar';
|
||||||
import ChatBody from './ChatBody';
|
import ChatBody from './ChatBody';
|
||||||
import ChatDrawers from './ChatDrawers';
|
import ChatDrawers from './ChatDrawers';
|
||||||
import ChatHeader from './ChatHeader';
|
import ChatHeader from './ChatHeader';
|
||||||
import ChatInput from './ChatInput';
|
import ChatInput from './ChatInput';
|
||||||
import ChatOutline from './ChatOutline';
|
import ChatOutline from './ChatOutline';
|
||||||
|
import '../styles/chat-page-web.css';
|
||||||
|
|
||||||
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
|
const viewport = useDesktopViewport();
|
||||||
|
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
@ -39,7 +44,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
} = logic;
|
} = logic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-shell">
|
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
|
||||||
<AgentSidebar
|
<AgentSidebar
|
||||||
agentList={agentList}
|
agentList={agentList}
|
||||||
activeAgentId={id}
|
activeAgentId={id}
|
||||||
|
|
@ -49,23 +54,23 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
|
|
||||||
<section className="chat-main">
|
<section className="chat-main">
|
||||||
{!agent ? (
|
{!agent ? (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="chat-empty-state">
|
||||||
<Empty description="请在左侧选择一个智能体开始对话" />
|
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="chat-content-layout">
|
||||||
<ChatHeader
|
<div className="chat-conversation-panel">
|
||||||
agent={agent}
|
<ChatHeader
|
||||||
useStream={sender.useStream}
|
agent={agent}
|
||||||
setUseStream={sender.setUseStream}
|
useStream={sender.useStream}
|
||||||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
setUseStream={sender.setUseStream}
|
||||||
onOpenParams={() => setParamsDrawerOpen(true)}
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
onOpenMcp={() => setMcpDrawerOpen(true)}
|
onOpenParams={() => setParamsDrawerOpen(true)}
|
||||||
onManageAgent={() => navigate(`/agents/${id}`)}
|
onOpenMcp={() => setMcpDrawerOpen(true)}
|
||||||
onClear={sender.handleClear}
|
onManageAgent={() => navigate(`/agents/${id}`)}
|
||||||
/>
|
onClear={sender.handleClear}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="chat-content-row">
|
|
||||||
<ChatBody
|
<ChatBody
|
||||||
bodyRef={bodyRef}
|
bodyRef={bodyRef}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
|
@ -86,45 +91,47 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatOutline
|
<ChatInput
|
||||||
messages={messages}
|
input={sender.input}
|
||||||
activeId={highlightId}
|
setInput={sender.setInput}
|
||||||
onJump={(msgId) => {
|
sending={sender.sending}
|
||||||
setHighlightId(msgId);
|
attachments={sender.attachments}
|
||||||
const el = document.getElementById('msg-' + msgId);
|
setAttachments={sender.setAttachments}
|
||||||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
imageUrls={sender.imageUrls}
|
||||||
|
setImageUrls={sender.setImageUrls}
|
||||||
|
onSend={sender.handleSend}
|
||||||
|
onStop={sender.handleStop}
|
||||||
|
onAttach={sender.handleAttach}
|
||||||
|
onOpenTpl={() => setTplDrawerOpen(true)}
|
||||||
|
modelOptions={sender.modelOptions}
|
||||||
|
activeModelValue={sender.activeModelValue}
|
||||||
|
onChangeModel={(modelId) => {
|
||||||
|
const picked = sender.modelOptions.find((o) => o.value === modelId);
|
||||||
|
setOverrides((o: ModelOverrides) => ({
|
||||||
|
...o,
|
||||||
|
model_id: modelId,
|
||||||
|
model: picked?.label ?? o.model
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
|
agentList={agentList}
|
||||||
|
onInsertMention={() => {}}
|
||||||
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
|
onNewSession={handleNewSession}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatInput
|
<ChatOutline
|
||||||
input={sender.input}
|
messages={messages}
|
||||||
setInput={sender.setInput}
|
activeId={highlightId}
|
||||||
sending={sender.sending}
|
collapsed={outlineCollapsed}
|
||||||
attachments={sender.attachments}
|
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||||
setAttachments={sender.setAttachments}
|
onJump={(msgId) => {
|
||||||
imageUrls={sender.imageUrls}
|
setHighlightId(msgId);
|
||||||
setImageUrls={sender.setImageUrls}
|
const el = document.getElementById('msg-' + msgId);
|
||||||
onSend={sender.handleSend}
|
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
onStop={sender.handleStop}
|
|
||||||
onAttach={sender.handleAttach}
|
|
||||||
onOpenTpl={() => setTplDrawerOpen(true)}
|
|
||||||
modelOptions={sender.modelOptions}
|
|
||||||
activeModelValue={sender.activeModelValue}
|
|
||||||
onChangeModel={(modelId) => {
|
|
||||||
const picked = sender.modelOptions.find((o) => o.value === modelId);
|
|
||||||
setOverrides((o: ModelOverrides) => ({
|
|
||||||
...o,
|
|
||||||
model_id: modelId,
|
|
||||||
model: picked?.label ?? o.model
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
agentList={agentList}
|
|
||||||
onInsertMention={() => {}}
|
|
||||||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
|
||||||
onNewSession={handleNewSession}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,113 @@
|
||||||
.message-item-container {
|
.message-item-container {
|
||||||
margin-bottom: 20px;
|
transition: background 0.3s;
|
||||||
transition: background 0.4s, padding 0.4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-container.highlighted {
|
.message-item-container.highlighted {
|
||||||
padding: 8px;
|
background: var(--color-surface-2);
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(254, 243, 199, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Assistant Message Styles */
|
.message-item-assistant,
|
||||||
.message-item-assistant {
|
.message-item-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
max-width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-avatar {
|
.message-item-user {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 2px;
|
width: 36px;
|
||||||
background-color: #52c41a;
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-content {
|
.message-item-content {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-header {
|
.message-item-user .message-item-content {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-name {
|
.message-item-name {
|
||||||
font-size: 12px;
|
font-weight: 600;
|
||||||
font-weight: 400;
|
color: var(--color-text);
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Message Styles */
|
.message-item-time {
|
||||||
.message-item-user {
|
color: var(--color-text-tertiary);
|
||||||
display: flex;
|
font-size: 11px;
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-user-content-wrapper {
|
.bubble {
|
||||||
flex: 1;
|
padding: 12px 16px;
|
||||||
min-width: 0;
|
border-radius: 16px;
|
||||||
max-width: 78%;
|
font-size: 14.5px;
|
||||||
display: flex;
|
line-height: 1.6;
|
||||||
flex-direction: column;
|
position: relative;
|
||||||
align-items: flex-end;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-user-avatar {
|
.bubble.assistant {
|
||||||
flex-shrink: 0;
|
background: #ffffff;
|
||||||
margin-top: 2px;
|
padding: 16px;
|
||||||
background-color: #1890ff;
|
color: var(--color-text);
|
||||||
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention {
|
.bubble.user {
|
||||||
color: var(--color-brand);
|
background: var(--color-primary);
|
||||||
font-weight: 500;
|
color: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.user span.mention {
|
||||||
|
background: rgba(255, 159, 10, 0.22);
|
||||||
|
border: 1px solid rgba(255, 159, 10, 0.38);
|
||||||
|
color: #ff9f0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-actions {
|
.message-item-actions {
|
||||||
padding-top: 4px;
|
display: flex;
|
||||||
border-top: 1px solid var(--color-border);
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-container:hover .message-item-actions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-btn {
|
.actions-btn {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-btn:hover {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
background: var(--color-surface-3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.user .markdown p {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.assistant .markdown p {
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||||||
import type { Agent } from '../../../../api/agents';
|
import type { Agent } from '../../../../api/agents';
|
||||||
import Markdown from '../../../../components/Markdown';
|
import Markdown from '../../../../components/Markdown';
|
||||||
|
|
@ -37,6 +38,11 @@ export default function MessageItem(props: {
|
||||||
const activeIdx = branch?.activeIndex ?? 0;
|
const activeIdx = branch?.activeIndex ?? 0;
|
||||||
const total = branch?.total ?? 1;
|
const total = branch?.total ?? 1;
|
||||||
|
|
||||||
|
const timeStr = useMemo(() => {
|
||||||
|
if (!message.createdAt) return '';
|
||||||
|
return dayjs(message.createdAt * 1000).format('HH:mm');
|
||||||
|
}, [message.createdAt]);
|
||||||
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
if (!branch || !message.parentId) return;
|
if (!branch || !message.parentId) return;
|
||||||
const i = Math.max(0, activeIdx - 1);
|
const i = Math.max(0, activeIdx - 1);
|
||||||
|
|
@ -54,81 +60,77 @@ export default function MessageItem(props: {
|
||||||
id={'msg-' + message.id}
|
id={'msg-' + message.id}
|
||||||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
||||||
>
|
>
|
||||||
{!isUser ? (
|
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
||||||
<div className="message-item-assistant">
|
<Avatar
|
||||||
<Avatar
|
src={isUser ? undefined : answerAgent?.avatar}
|
||||||
src={answerAgent?.avatar}
|
size={36}
|
||||||
size={36}
|
className="message-item-avatar"
|
||||||
className="message-item-assistant-avatar"
|
>
|
||||||
>
|
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
|
||||||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
<div className="message-item-assistant-content">
|
<div className="message-item-content">
|
||||||
<div className="message-item-assistant-header">
|
<div className="message-item-header">
|
||||||
<span className="message-item-assistant-name">
|
<span className="message-item-name">
|
||||||
{answerAgent?.name || 'AI'}
|
{isUser ? '我' : (answerAgent?.name || 'AI')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="message-item-time">{timeStr}</span>
|
||||||
<div className={`bubble ${bubbleRole}`}>
|
</div>
|
||||||
|
|
||||||
|
<div className={`bubble ${bubbleRole}`}>
|
||||||
|
{isUser && !formattedContent.includes(' ? (
|
||||||
|
<span dangerouslySetInnerHTML={{
|
||||||
|
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
<Markdown>{formattedContent}</Markdown>
|
<Markdown>{formattedContent}</Markdown>
|
||||||
<div className="message-item-actions">
|
)}
|
||||||
{hasBranches && (
|
</div>
|
||||||
<Space size={2}>
|
|
||||||
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
|
<div className="message-item-actions">
|
||||||
‹
|
{hasBranches && (
|
||||||
</Button>
|
<Space size={2}>
|
||||||
<span>
|
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
|
||||||
{activeIdx + 1} / {total}
|
‹
|
||||||
</span>
|
</Button>
|
||||||
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
|
<span>
|
||||||
›
|
{activeIdx + 1} / {total}
|
||||||
</Button>
|
</span>
|
||||||
</Space>
|
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
|
||||||
)}
|
›
|
||||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
</Button>
|
||||||
<Dropdown
|
</Space>
|
||||||
trigger={['click']}
|
)}
|
||||||
menu={{
|
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||||
items: [
|
<Dropdown
|
||||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
trigger={['click']}
|
||||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
menu={{
|
||||||
]
|
items: [
|
||||||
}}
|
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
||||||
>
|
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
||||||
<Tooltip title="复制">
|
]
|
||||||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
}}
|
||||||
</Tooltip>
|
>
|
||||||
</Dropdown>
|
<Tooltip title="复制">
|
||||||
|
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||||
|
</Tooltip>
|
||||||
|
</Dropdown>
|
||||||
|
{!isUser && (
|
||||||
<Tooltip title="重新生成">
|
<Tooltip title="重新生成">
|
||||||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isMobile && message.meta && (
|
|
||||||
<div>
|
|
||||||
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
|
||||||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
|
||||||
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="message-item-user">
|
|
||||||
<div className="message-item-user-content-wrapper">
|
|
||||||
<div className={`bubble ${bubbleRole}`}>
|
|
||||||
{formattedContent.includes(' ? (
|
|
||||||
<Markdown>{formattedContent}</Markdown>
|
|
||||||
) : (
|
|
||||||
<span dangerouslySetInnerHTML={{
|
|
||||||
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
|
||||||
}} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Avatar className="message-item-user-avatar" size={36}>我</Avatar>
|
{/* {!isMobile && message.meta && !isUser && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||||||
|
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||||||
|
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
.chat-shell.desktop-standardPc {
|
.chat-shell.desktop-standardPc {
|
||||||
grid-template-columns: 17.5rem minmax(0, 1fr);
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-standardPc .chat-content-row {
|
.chat-shell.desktop-standardPc .chat-side {
|
||||||
grid-template-columns: minmax(0, 1fr) 260px;
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-standardPc .chat-outline {
|
.chat-shell.desktop-standardPc .chat-outline {
|
||||||
width: 100%;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,28 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-tablet,
|
.chat-content-layout {
|
||||||
.chat-shell.desktop-smallPc,
|
display: flex;
|
||||||
.chat-shell.desktop-standardPc,
|
height: 100%;
|
||||||
.chat-shell.desktop-large2k,
|
width: 100%;
|
||||||
.chat-shell.desktop-ultra4k,
|
overflow: hidden;
|
||||||
.chat-shell.desktop-tablet .chat-content-row,
|
|
||||||
.chat-shell.desktop-smallPc .chat-content-row,
|
|
||||||
.chat-shell.desktop-standardPc .chat-content-row,
|
|
||||||
.chat-shell.desktop-large2k .chat-content-row,
|
|
||||||
.chat-shell.desktop-ultra4k .chat-content-row {
|
|
||||||
display: grid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-conversation-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.desktop-standardPc {
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.desktop-standardPc .chat-main {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
310
src/styles.css
310
src/styles.css
|
|
@ -280,111 +280,6 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-chat {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 248px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 6px 10px 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand .brand-logo {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
object-fit: contain;
|
|
||||||
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-section-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
padding: 14px 12px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item {
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item:hover {
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item.active {
|
|
||||||
background: var(--color-brand-soft);
|
|
||||||
color: var(--color-brand);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item .nav-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .kbd {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-user {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card {
|
.agent-card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|
@ -469,7 +364,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-side {
|
.chat-side {
|
||||||
width: 260px;
|
width: 300px;
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -494,135 +389,12 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
height: 60px;
|
|
||||||
padding: 0 24px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--color-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-stream-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-h5 .chat-header-stream-toggle {
|
|
||||||
margin-right: 4px;;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-stream-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-body {
|
.chat-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-outline {
|
|
||||||
width: 260px;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface);
|
|
||||||
padding: 14px 12px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-item {
|
|
||||||
border: 0;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-item.active {
|
|
||||||
background-color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-index {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
line-height: 1.4;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-text {
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-text);
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.chat-content-row > .chat-outline {
|
.chat-content-row > .chat-outline {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -703,11 +475,6 @@ body {
|
||||||
padding: 1.125rem 0.75rem 6rem;
|
padding: 1.125rem 0.75rem 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 0 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
top: -22px;
|
top: -22px;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -747,7 +514,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
min-height: 0;
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-body .messages-container {
|
.chat-body .messages-container {
|
||||||
|
|
@ -757,36 +526,10 @@ body {
|
||||||
padding: 16px 12px 80px;
|
padding: 16px 12px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
|
||||||
max-width: 78%;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 14px;
|
|
||||||
white-space: normal;
|
|
||||||
word-wrap: break-word;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 14.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-h5 .bubble {
|
.is-h5 .bubble {
|
||||||
max-width: 94%;
|
max-width: 94%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.user {
|
|
||||||
background: #0a84ff;
|
|
||||||
color: #ffffff;
|
|
||||||
margin-left: auto;
|
|
||||||
border-bottom-right-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble.assistant {
|
|
||||||
background: #edf1f6;
|
|
||||||
color: #111827;
|
|
||||||
border: 0;
|
|
||||||
border-bottom-left-radius: 5px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble.assistant p,
|
.bubble.assistant p,
|
||||||
.bubble.assistant h1,
|
.bubble.assistant h1,
|
||||||
.bubble.assistant h2,
|
.bubble.assistant h2,
|
||||||
|
|
@ -851,27 +594,6 @@ body {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 820px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 24px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-card {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 14px 16px 12px;
|
|
||||||
min-height: 110px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-card-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -24px;
|
top: -24px;
|
||||||
|
|
@ -1671,10 +1393,6 @@ body {
|
||||||
.border-l { border-left: 1px solid var(--color-border); }
|
.border-l { border-left: 1px solid var(--color-border); }
|
||||||
.shadow-lg { box-shadow: var(--shadow-lg); }
|
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||||
|
|
||||||
.ant-btn-primary {
|
|
||||||
box-shadow: 0 1px 2px rgba(194, 84, 31, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input,
|
.ant-input,
|
||||||
.ant-input-affix-wrapper,
|
.ant-input-affix-wrapper,
|
||||||
.ant-input-number,
|
.ant-input-number,
|
||||||
|
|
@ -1696,14 +1414,6 @@ body {
|
||||||
border-color: var(--color-border-strong) !important;
|
border-color: var(--color-border-strong) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-input-affix-wrapper-focused,
|
|
||||||
.ant-input:focus,
|
|
||||||
.ant-select-focused .ant-select-selector,
|
|
||||||
.ant-picker-focused {
|
|
||||||
border-color: var(--color-brand) !important;
|
|
||||||
box-shadow: var(--shadow-focus) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card {
|
.ant-card {
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
border-color: var(--color-border) !important;
|
border-color: var(--color-border) !important;
|
||||||
|
|
@ -1743,12 +1453,6 @@ span.mention {
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.user span.mention {
|
|
||||||
background: rgba(255, 159, 10, 0.22);
|
|
||||||
border: 1px solid rgba(255, 159, 10, 0.38);
|
|
||||||
color: #ff9f0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-collapse-content {
|
.ant-collapse-content {
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
color: var(--color-text) !important;
|
color: var(--color-text) !important;
|
||||||
|
|
@ -2362,6 +2066,12 @@ span.mention {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-page-agent-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-page-agent-item {
|
.stats-page-agent-item {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,59 @@
|
||||||
:root,
|
:root,
|
||||||
[data-theme='light'] {
|
[data-theme='light'] {
|
||||||
--color-bg: #f5f9ff;
|
--color-bg: #FAFCFC;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-2: #eef5ff;
|
--color-surface-2: #edfdfd;
|
||||||
--color-surface-3: #dceaff;
|
--color-surface-3: #e6fffa;
|
||||||
--color-border: #d6e5fb;
|
--color-border: #E0EBEB;
|
||||||
--color-border-strong: #a9c8f6;
|
--color-border-strong: #cbd5e0;
|
||||||
--color-border-focus: #1167ff;
|
--color-border-focus: #4fd1c5;
|
||||||
--color-text: #06143f;
|
--color-text: #0F1F2E;
|
||||||
--color-text-secondary: #405784;
|
--color-text-secondary: #0F1F2E;
|
||||||
--color-text-tertiary: #7c8caf;
|
--color-text-tertiary: #718096;
|
||||||
--color-brand: #1167ff;
|
--color-primary: #5CCFC4;
|
||||||
--color-brand-hover: #0754df;
|
--color-brand: #4fd1c5;
|
||||||
--color-brand-soft: #eaf3ff;
|
--color-brand-hover: #38b2ac;
|
||||||
--color-brand-soft-2: #d7e8ff;
|
--color-brand-soft: #e6fffa;
|
||||||
--color-success: #0d9f6e;
|
--color-brand-soft-2: #b2f5ea;
|
||||||
--color-success-soft: #e7f8f1;
|
--color-success: #38a169;
|
||||||
--color-warning: #b7791f;
|
--color-success-soft: #f0fff4;
|
||||||
--color-warning-soft: #fff5dc;
|
--color-warning: #d69e2e;
|
||||||
--color-danger: #cf3434;
|
--color-warning-soft: #fffff0;
|
||||||
--color-danger-soft: #ffeaea;
|
--color-danger: #e53e3e;
|
||||||
--color-info: #1e86ff;
|
--color-danger-soft: #fff5f5;
|
||||||
--color-info-soft: #e8f3ff;
|
--color-info: #3182ce;
|
||||||
--shadow-xs: 0 1px 2px rgba(6, 20, 63, 0.04);
|
--color-info-soft: #ebf8ff;
|
||||||
--shadow-sm: 0 2px 8px rgba(17, 103, 255, 0.06);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 10px 26px rgba(17, 103, 255, 0.1);
|
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
--shadow-lg: 0 18px 46px rgba(17, 103, 255, 0.14);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-xl: 0 24px 70px rgba(6, 20, 63, 0.16);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-focus: 0 0 0 3px rgba(17, 103, 255, 0.18);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 48%, #22a6ff 100%);
|
--shadow-focus: 0 0 0 3px rgba(79, 209, 197, 0.2);
|
||||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(34, 166, 255, 0.18), transparent 62%),
|
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.14), transparent 58%),
|
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.15), transparent 60%),
|
||||||
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
|
radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.1), transparent 50%),
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--color-bg: #071126;
|
--color-bg: #0f172a;
|
||||||
--color-surface: #0c1730;
|
--color-surface: #1e293b;
|
||||||
--color-surface-2: #111f3c;
|
--color-surface-2: #1e293b;
|
||||||
--color-surface-3: #17294c;
|
--color-surface-3: #334155;
|
||||||
--color-border: #1f3764;
|
--color-border: #334155;
|
||||||
--color-border-strong: #31558f;
|
--color-border-strong: #475569;
|
||||||
--color-border-focus: #55a5ff;
|
--color-border-focus: #4fd1c5;
|
||||||
--color-text: #edf5ff;
|
--color-text: #f8fafc;
|
||||||
--color-text-secondary: #b6c8e8;
|
--color-text-secondary: #94a3b8;
|
||||||
--color-text-tertiary: #7f93ba;
|
--color-text-tertiary: #64748b;
|
||||||
--color-brand: #55a5ff;
|
--color-brand: #4fd1c5;
|
||||||
--color-brand-hover: #7bbaff;
|
--color-brand-hover: #38b2ac;
|
||||||
--color-brand-soft: #10284f;
|
--color-brand-soft: #134e4a;
|
||||||
--color-brand-soft-2: #17396c;
|
--color-brand-soft-2: #115e59;
|
||||||
--color-info: #72b7ff;
|
--color-info: #60a5fa;
|
||||||
--color-info-soft: #10284f;
|
--color-info-soft: #1e3a8a;
|
||||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 54%, #55c2ff 100%);
|
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(85, 165, 255, 0.18), transparent 62%),
|
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.18), transparent 62%),
|
||||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.16), transparent 58%),
|
radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.16), transparent 58%),
|
||||||
linear-gradient(180deg, #071126 0%, #0c1730 100%);
|
linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,31 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
// 默认走 Go 后端 :4001;要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000
|
||||||
// 本地开发走根路径,生产构建部署到 /aura 子路径
|
export default defineConfig(({ command, mode }) => {
|
||||||
base: command === 'serve' ? '/' : '/aura/',
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
plugins: [react()],
|
const target = env.VITE_API_TARGET || 'https://tianchaoai.cc';
|
||||||
server: {
|
|
||||||
port: 3001
|
return {
|
||||||
}
|
// 本地开发走根路径,生产构建部署到 /aura 子路径
|
||||||
}));
|
base: command === 'serve' ? '/' : '/aura/',
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/api/v1'),
|
||||||
|
// SSE 不要被压缩;保持长连接
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
proxyReq.setHeader('Accept-Encoding', 'identity');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue