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 { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
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 CommandPalette from './components/CommandPalette';
|
||||
import AgentList from './pages/AgentList';
|
||||
|
|
@ -13,6 +13,8 @@ import PointsMallPage from './pages/PointsMallPage';
|
|||
import TeamsPage from './pages/TeamsPage';
|
||||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||
import StatsPage from './pages/StatsPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PricingPage from './pages/PricingPage';
|
||||
import SharedSessionPage from './pages/SharedSessionPage';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import { useAuth } from './store/auth';
|
||||
|
|
@ -29,6 +31,7 @@ export default function App() {
|
|||
const location = useLocation();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 全局快捷键 Ctrl/⌘ + K
|
||||
|
|
@ -58,6 +61,8 @@ export default function App() {
|
|||
<Route path="/teams" element={<TeamsPage />} />
|
||||
<Route path="/prompts" element={<PromptLibraryPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
|
@ -79,7 +84,11 @@ export default function App() {
|
|||
<div className="layout-shell">
|
||||
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
|
||||
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
|
||||
<Sidebar
|
||||
onOpenPalette={() => setPaletteOpen(true)}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
) : null}
|
||||
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
||||
{isMobile && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import axios from 'axios';
|
||||
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(/\/$/, '');
|
||||
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ export * from './stats';
|
|||
export * from './llmProviders';
|
||||
export * from './streamChat';
|
||||
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,
|
||||
BarChartOutlined,
|
||||
TeamOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
LogoutOutlined
|
||||
LogoutOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
UserOutlined,
|
||||
CreditCardOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { useTheme } from '../main';
|
||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||
import './Sidebar.css';
|
||||
|
||||
interface Props {
|
||||
onOpenPalette?: () => void;
|
||||
onNavigate?: () => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
const NAV_GROUPS: Array<{
|
||||
|
|
@ -51,68 +55,65 @@ const NAV_GROUPS: Array<{
|
|||
{
|
||||
label: '商城',
|
||||
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 navigate = useNavigate();
|
||||
const { mode, toggle } = useTheme();
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
|
||||
const cmdKey = isMac ? '⌘' : 'Ctrl';
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
|
||||
<span>鲸域AI</span>
|
||||
<div className="sidebar-brand-spacer" />
|
||||
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
||||
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
||||
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<img src={kaiwuIcon} alt="鲸域AI" className="sidebar-brand-logo" />
|
||||
{!collapsed && <span className="sidebar-brand-name">鲸域AI</span>}
|
||||
<div className="sidebar-brand-toggle" onClick={onToggleCollapse}>
|
||||
{collapsed ? <RightOutlined style={{ fontSize: 12 }} /> : <LeftOutlined style={{ fontSize: 12 }} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
onOpenPalette?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className="nav-item sidebar-search-action"
|
||||
>
|
||||
<span className="sidebar-search-label">
|
||||
<SearchOutlined className="nav-icon" />
|
||||
<span>快速搜索</span>
|
||||
</span>
|
||||
<span className="kbd">{cmdKey} K</span>
|
||||
<div className="sidebar-search" onClick={onOpenPalette}>
|
||||
<div className="sidebar-search-input">
|
||||
<SearchOutlined className="sidebar-search-icon" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="sidebar-search-placeholder">快速搜索</span>
|
||||
<span className="sidebar-search-suffix">{cmdKey} K</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-scroll">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="nav-section-label">{group.label}</div>
|
||||
<div key={group.label} className="sidebar-nav-group">
|
||||
{!collapsed && <div className="sidebar-nav-label">{group.label}</div>}
|
||||
{group.items.map((it) => (
|
||||
<Tooltip key={it.to} title={collapsed ? it.label : ''} placement="right">
|
||||
<NavLink
|
||||
key={it.to}
|
||||
to={it.to}
|
||||
end={it.end}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
className={({ isActive }) => `sidebar-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<span className="nav-icon">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
<span className="sidebar-nav-icon">{it.icon}</span>
|
||||
{!collapsed && <span>{it.label}</span>}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="sidebar-user">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
|
@ -121,11 +122,14 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
label: <span className="sidebar-user-role">{user.phone}</span>,
|
||||
disabled: true
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'role',
|
||||
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`,
|
||||
disabled: true
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => {
|
||||
navigate('/profile');
|
||||
onNavigate?.();
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
|
|
@ -142,20 +146,21 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
}}
|
||||
placement="topLeft"
|
||||
>
|
||||
<div className="sidebar-user">
|
||||
<Avatar size={32} className="sidebar-user-avatar">
|
||||
<div className="sidebar-user-card">
|
||||
<div className="sidebar-user-avatar">
|
||||
{(user.name?.charAt(0) || '?').toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="sidebar-user-main">
|
||||
<div className="sidebar-user-name">
|
||||
{user.name}
|
||||
</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>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||
() => ({
|
||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
|
||||
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
|
||||
colorPrimary: isDark ? '#55a5ff' : '#5CCFC4',
|
||||
colorInfo: isDark ? '#72b7ff' : '#5CCFC4',
|
||||
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
||||
colorBgContainer: isDark ? '#0c1730' : '#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 { useNavigate } from 'react-router-dom';
|
||||
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
|
||||
import '../styles/marketplace-page-web.css';
|
||||
|
||||
interface Props {
|
||||
logic: MarketplacePageLogicOutput;
|
||||
|
|
@ -12,35 +13,26 @@ export default function MarketplacePageWeb({ logic }: Props) {
|
|||
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-hero">
|
||||
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
|
||||
<div className="page-container">
|
||||
<div className="marketplace-header">
|
||||
<div className="marketplace-header-content">
|
||||
<div className="marketplace-intro">
|
||||
<div className="marketplace-badge">
|
||||
<CompassOutlined className="marketplace-badge-icon" />
|
||||
探索社区智能体
|
||||
</div>
|
||||
<h1 className="hero-title">找到更适合你的 AI 伙伴</h1>
|
||||
<p className="hero-subtitle">
|
||||
|
||||
<h2 className="page-title" style={{ marginBottom: 10 }}>
|
||||
找到更适合你的 AI 伙伴
|
||||
</h2>
|
||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||
浏览社区创建的智能体,快速复制、微调并投入你的日常工作流。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-container" style={{ paddingTop: 28 }}>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -4,28 +4,43 @@
|
|||
@import './marketplace-page-web-large-2k.css';
|
||||
@import './marketplace-page-web-ultra-4k.css';
|
||||
|
||||
.marketplace-web-hero-inner {
|
||||
max-width: var(--marketplace-max-width, 1240px);
|
||||
margin: 0 auto;
|
||||
.marketplace-header {
|
||||
border-radius: 24px;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 18px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
background: rgba(255,255,255,0.78);
|
||||
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.marketplace-web-container {
|
||||
max-width: var(--marketplace-max-width, 1240px);
|
||||
padding-top: 28px;
|
||||
.marketplace-badge-icon {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.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 ? (
|
||||
<Empty description="暂无" />
|
||||
) : (
|
||||
<div>
|
||||
<div className="stats-page-agent-list">
|
||||
{data.topAgents.map((agent, index) => (
|
||||
<div key={agent.id} className="stats-page-agent-item">
|
||||
<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 {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chat-agent-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 16px 16px 8px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.2s;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-agent-item:hover {
|
||||
|
|
@ -30,22 +100,33 @@
|
|||
}
|
||||
|
||||
.chat-agent-item.active {
|
||||
background: var(--color-surface-2);
|
||||
border-left: 3px solid var(--color-brand);
|
||||
background: var(--color-surface-3);
|
||||
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 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.chat-agent-avatar-img {
|
||||
|
|
@ -60,7 +141,7 @@
|
|||
}
|
||||
|
||||
.chat-agent-name {
|
||||
font-size: 14px;
|
||||
font-size: 13.5px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -77,8 +158,7 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
padding: 20px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -90,5 +170,5 @@
|
|||
}
|
||||
|
||||
.chat-agent-loading-text {
|
||||
margin-top: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button, Spin, Empty } from 'antd';
|
||||
import { MessageOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Spin, Empty, Tabs } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import type { Agent } from '../../../api';
|
||||
import './AgentSidebar.css';
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ export default function AgentSidebar(props: {
|
|||
}) {
|
||||
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
|
||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('mine');
|
||||
|
||||
useEffect(() => {
|
||||
if (agentList.length > 0) return;
|
||||
|
|
@ -26,16 +27,65 @@ export default function AgentSidebar(props: {
|
|||
return () => clearTimeout(timer);
|
||||
}, [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 (
|
||||
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
|
||||
<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>
|
||||
</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">
|
||||
{agentList.length > 0 ? (
|
||||
agentList.map((a) => {
|
||||
groupedAgents.map(([groupName, list]) => (
|
||||
<div key={groupName} className="chat-agent-group">
|
||||
<div className="chat-agent-group-label">
|
||||
<span>{groupName}</span>
|
||||
<span className="chat-agent-group-count">{list.length}</span>
|
||||
</div>
|
||||
{list.map((a) => {
|
||||
const isActive = a.id === activeAgentId;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -54,14 +104,13 @@ export default function AgentSidebar(props: {
|
|||
)}
|
||||
</div>
|
||||
<div className="chat-agent-info">
|
||||
<div className="chat-agent-name">
|
||||
{a.name}
|
||||
{!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
|
||||
</div>
|
||||
<div className="chat-agent-name">{a.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : !loadingTimeout ? (
|
||||
<div className="chat-agent-sidebar-status">
|
||||
<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 MessageItem from './messages/MessageItem';
|
||||
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||
import './ChatBody.css';
|
||||
|
||||
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 className="messages-container">
|
||||
{messages.length === 0 && !streaming.active ? (
|
||||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: '50%',
|
||||
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 className="chat-empty-welcome">
|
||||
<div className="chat-welcome-avatar">
|
||||
{isImageUrl(agent.avatar) ? (
|
||||
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||||
) : (
|
||||
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>你好,今天想一起完成什么?</h2>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||||
<h2 className="chat-welcome-title">你好,今天想一起完成什么?</h2>
|
||||
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -71,50 +60,31 @@ export default function ChatBody(props: {
|
|||
))}
|
||||
|
||||
{streaming.active && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="streaming-message">
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{(() => {
|
||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
||||
if (streamingAgent) {
|
||||
return (
|
||||
<Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}>
|
||||
{streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
|
||||
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{(() => {
|
||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
||||
if (streamingAgent) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 6
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}>
|
||||
{streamingAgent.name}
|
||||
<div className="message-item-content">
|
||||
<div className="message-item-header">
|
||||
<span className="message-item-name">
|
||||
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<div className="bubble assistant">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{!!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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
|
||||
<div className="streaming-retry-card">
|
||||
<div className="streaming-retry-header">
|
||||
<span className="streaming-retry-title">
|
||||
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
|
||||
</span>
|
||||
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||||
|
|
@ -126,25 +96,22 @@ export default function ChatBody(props: {
|
|||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, 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 style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>推理过程</div>
|
||||
<div className="streaming-section">
|
||||
<div className="streaming-section-label">推理过程</div>
|
||||
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
||||
</div>
|
||||
<Divider style={{ margin: '6px 0' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>正式回答</div>
|
||||
<div className="streaming-section">
|
||||
<div className="streaming-section-label">正式回答</div>
|
||||
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||
<div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
||||
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
||||
</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 { Button, Dropdown, Modal, Space, Switch } from 'antd';
|
||||
import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Modal, Switch } from 'antd';
|
||||
import type { Agent } from '../../../api';
|
||||
import { useIsMobile } from '../../../hooks/useIsMobile';
|
||||
import './ChatHeader.css';
|
||||
|
||||
function formatAgentModel(raw: string | null | undefined) {
|
||||
const s = String(raw ?? '').trim();
|
||||
|
|
@ -23,6 +23,8 @@ function formatAgentModel(raw: string | null | undefined) {
|
|||
return s;
|
||||
}
|
||||
|
||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||
|
||||
export default function ChatHeader(props: {
|
||||
agent: Agent;
|
||||
useStream: boolean;
|
||||
|
|
@ -35,29 +37,37 @@ export default function ChatHeader(props: {
|
|||
}) {
|
||||
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
|
||||
const modelText = formatAgentModel(agent.model);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
|
||||
<div className="chat-header-agent">
|
||||
<div className="chat-header-agent-title">
|
||||
<span className="chat-header-agent-name">{agent.name}</span>
|
||||
{!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>}
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-left">
|
||||
<div className="chat-header-avatar">
|
||||
{isImageUrl(agent.avatar) ? (
|
||||
<img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||
) : (
|
||||
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-header-agent-meta">
|
||||
{modelText}
|
||||
<div className="chat-header-info">
|
||||
<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>
|
||||
<Space>
|
||||
</div>
|
||||
|
||||
<div className="chat-header-right">
|
||||
<div className="chat-header-stream-toggle">
|
||||
<span className="chat-header-stream-label">流式输出</span>
|
||||
<span>流式输出</span>
|
||||
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Button size="small" onClick={onOpenHistory}>
|
||||
|
||||
<Button className="chat-header-btn" onClick={onOpenHistory}>
|
||||
历史对话
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
|
@ -76,12 +86,13 @@ export default function ChatHeader(props: {
|
|||
}
|
||||
]
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button size="small" type={isMobile ? 'text' : 'primary'}>
|
||||
{isMobile ? <EllipsisOutlined /> : <>更多 <DownOutlined /></>}
|
||||
<Button className="chat-header-more-btn">
|
||||
<EllipsisOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</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 { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
|
||||
import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import type { ChatAttachment } from '../../../api';
|
||||
import type { Agent } from '../../../api/agents';
|
||||
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import './ChatInput.css';
|
||||
import { IconAttachment, IconPrompt } from '../../../components/Icon';
|
||||
|
||||
export default function ChatInput(props: {
|
||||
input: string;
|
||||
|
|
@ -39,13 +40,9 @@ export default function ChatInput(props: {
|
|||
onStop,
|
||||
onAttach,
|
||||
onOpenTpl,
|
||||
modelOptions,
|
||||
activeModelValue,
|
||||
onChangeModel,
|
||||
onOpenHistory,
|
||||
onNewSession,
|
||||
agentList,
|
||||
onInsertMention,
|
||||
showActions = true
|
||||
} = props;
|
||||
|
||||
|
|
@ -98,7 +95,6 @@ export default function ChatInput(props: {
|
|||
};
|
||||
};
|
||||
|
||||
// 检测 @ 触发提及选择
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setInput(value);
|
||||
|
|
@ -107,32 +103,17 @@ export default function ChatInput(props: {
|
|||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
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)))) {
|
||||
const query = textBeforeCursor.slice(atIndex + 1);
|
||||
console.log('[@mention] matched @ at position:', { atIndex, query });
|
||||
if (!query.includes(' ')) {
|
||||
setMentionQuery(query);
|
||||
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = inputRef.current?.resizableTextArea?.textArea;
|
||||
if (!textarea) {
|
||||
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
|
||||
return;
|
||||
}
|
||||
if (!textarea) return;
|
||||
const caret = getCaretPos(textarea, cursorPos);
|
||||
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
|
||||
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
|
||||
setMentionPos({ top, left });
|
||||
console.log('[@mention] show popover:', { top, left, query });
|
||||
setShowMentionPopover(true);
|
||||
});
|
||||
return;
|
||||
|
|
@ -172,14 +153,45 @@ export default function ChatInput(props: {
|
|||
|
||||
return (
|
||||
<div className="chat-input-wrapper">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{showActions && (
|
||||
<div className="chat-input-toolbar-top">
|
||||
<div className="chat-input-actions-left">
|
||||
<Button
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={onOpenHistory}
|
||||
className="chat-input-action-btn"
|
||||
>
|
||||
历史记录
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewSession}
|
||||
className="chat-input-action-btn chat-input-action-btn-primary"
|
||||
>
|
||||
新建会话
|
||||
</Button>
|
||||
</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} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== 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' }}>
|
||||
<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"
|
||||
|
|
@ -193,31 +205,15 @@ export default function ChatInput(props: {
|
|||
))}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<Tooltip title="新增会话">
|
||||
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
|
||||
新建会话
|
||||
</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 }}
|
||||
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;
|
||||
|
|
@ -230,7 +226,6 @@ export default function ChatInput(props: {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
|
|
@ -239,6 +234,44 @@ export default function ChatInput(props: {
|
|||
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>
|
||||
|
||||
{showMentionPopover && mentionPos && (
|
||||
<div
|
||||
className="mention-popover"
|
||||
|
|
@ -249,15 +282,15 @@ export default function ChatInput(props: {
|
|||
zIndex: 10000,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
minWidth: 150
|
||||
minWidth: 160
|
||||
}}
|
||||
>
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
|
||||
<div style={{ padding: 8, color: 'var(--color-text-tertiary)', fontSize: 12 }}>
|
||||
未找到匹配的智能体
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -267,72 +300,19 @@ export default function ChatInput(props: {
|
|||
className="mention-item"
|
||||
onClick={() => handleSelectAgent(agent)}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
padding: '8px 12px',
|
||||
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)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, 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>
|
||||
|
||||
</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 { markdownToPlainText } from '../utils/copy';
|
||||
import './ChatOutline.css';
|
||||
|
||||
function summarize(content: string) {
|
||||
const plain = markdownToPlainText(content);
|
||||
|
|
@ -12,22 +14,34 @@ function summarize(content: string) {
|
|||
return text.slice(0, 44) + '…';
|
||||
}
|
||||
|
||||
export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (id: string) => void; activeId?: string | null }) {
|
||||
const { messages, onJump, activeId } = props;
|
||||
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>
|
||||
);
|
||||
interface ChatOutlineProps {
|
||||
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');
|
||||
|
||||
return (
|
||||
<aside className="chat-outline">
|
||||
<div className="chat-outline-title">对话大纲</div>
|
||||
<aside className={`chat-outline ${collapsed ? 'is-collapsed' : ''}`}>
|
||||
<div className="chat-outline-toggle" onClick={onToggleCollapse}>
|
||||
{collapsed ? <LeftOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||
</div>
|
||||
|
||||
<div className="chat-outline-section">
|
||||
<div className="chat-outline-title">{collapsed ? <OrderedListOutlined /> : '对话大纲'}</div>
|
||||
<div className="chat-outline-list"></div>
|
||||
{items.length === 0 ? (
|
||||
!collapsed && (
|
||||
<div style={{ padding: '12px 4px', color: 'var(--color-text-tertiary)', fontSize: 13 }}>
|
||||
暂无对话记录
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="chat-outline-list">
|
||||
{items.map((m, idx) => (
|
||||
<button
|
||||
|
|
@ -38,10 +52,12 @@ export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (i
|
|||
title={summarize(m.content)}
|
||||
>
|
||||
<span className="chat-outline-index">{idx + 1}</span>
|
||||
<span className="chat-outline-text">{summarize(m.content)}</span>
|
||||
{!collapsed && <span className="chat-outline-text">{summarize(m.content)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { useState } from 'react';
|
||||
import { App as AntApp, Empty } from 'antd';
|
||||
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
||||
import { markdownToPlainText } from '../utils/copy';
|
||||
import type { ModelOverrides } from '../../../api';
|
||||
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
|
||||
import AgentSidebar from './AgentSidebar';
|
||||
import ChatBody from './ChatBody';
|
||||
import ChatDrawers from './ChatDrawers';
|
||||
import ChatHeader from './ChatHeader';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatOutline from './ChatOutline';
|
||||
import '../styles/chat-page-web.css';
|
||||
|
||||
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||
const { message } = AntApp.useApp();
|
||||
const viewport = useDesktopViewport();
|
||||
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
||||
|
||||
const {
|
||||
id,
|
||||
|
|
@ -39,7 +44,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
} = logic;
|
||||
|
||||
return (
|
||||
<div className="chat-shell">
|
||||
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
|
||||
<AgentSidebar
|
||||
agentList={agentList}
|
||||
activeAgentId={id}
|
||||
|
|
@ -49,11 +54,12 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
|
||||
<section className="chat-main">
|
||||
{!agent ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="chat-empty-state">
|
||||
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-content-layout">
|
||||
<div className="chat-conversation-panel">
|
||||
<ChatHeader
|
||||
agent={agent}
|
||||
useStream={sender.useStream}
|
||||
|
|
@ -65,7 +71,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
onClear={sender.handleClear}
|
||||
/>
|
||||
|
||||
<div className="chat-content-row">
|
||||
<ChatBody
|
||||
bodyRef={bodyRef}
|
||||
agent={agent}
|
||||
|
|
@ -86,17 +91,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
}}
|
||||
/>
|
||||
|
||||
<ChatOutline
|
||||
messages={messages}
|
||||
activeId={highlightId}
|
||||
onJump={(msgId) => {
|
||||
setHighlightId(msgId);
|
||||
const el = document.getElementById('msg-' + msgId);
|
||||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
input={sender.input}
|
||||
setInput={sender.setInput}
|
||||
|
|
@ -124,7 +118,20 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||
onNewSession={handleNewSession}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<ChatOutline
|
||||
messages={messages}
|
||||
activeId={highlightId}
|
||||
collapsed={outlineCollapsed}
|
||||
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||
onJump={(msgId) => {
|
||||
setHighlightId(msgId);
|
||||
const el = document.getElementById('msg-' + msgId);
|
||||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +1,113 @@
|
|||
.message-item-container {
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.4s, padding 0.4s;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.message-item-container.highlighted {
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(254, 243, 199, 0.6);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
/* Assistant Message Styles */
|
||||
.message-item-assistant {
|
||||
.message-item-assistant,
|
||||
.message-item-user {
|
||||
display: flex;
|
||||
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;
|
||||
margin-top: 2px;
|
||||
background-color: #52c41a;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.message-item-assistant-content {
|
||||
flex: 1;
|
||||
.message-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-item-assistant-header {
|
||||
.message-item-user .message-item-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-item-assistant-name {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
.message-item-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* User Message Styles */
|
||||
.message-item-user {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
.message-item-time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-item-user-content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 78%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-item-user-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
background-color: #1890ff;
|
||||
.bubble.assistant {
|
||||
background: #ffffff;
|
||||
padding: 16px;
|
||||
color: var(--color-text);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.mention {
|
||||
color: var(--color-brand);
|
||||
font-weight: 500;
|
||||
.bubble.user {
|
||||
background: var(--color-primary);
|
||||
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 {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
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 {
|
||||
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 { useMemo } from 'react';
|
||||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||||
import type { Agent } from '../../../../api/agents';
|
||||
import Markdown from '../../../../components/Markdown';
|
||||
|
|
@ -37,6 +38,11 @@ export default function MessageItem(props: {
|
|||
const activeIdx = branch?.activeIndex ?? 0;
|
||||
const total = branch?.total ?? 1;
|
||||
|
||||
const timeStr = useMemo(() => {
|
||||
if (!message.createdAt) return '';
|
||||
return dayjs(message.createdAt * 1000).format('HH:mm');
|
||||
}, [message.createdAt]);
|
||||
|
||||
const goPrev = () => {
|
||||
if (!branch || !message.parentId) return;
|
||||
const i = Math.max(0, activeIdx - 1);
|
||||
|
|
@ -54,23 +60,33 @@ export default function MessageItem(props: {
|
|||
id={'msg-' + message.id}
|
||||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
||||
>
|
||||
{!isUser ? (
|
||||
<div className="message-item-assistant">
|
||||
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
||||
<Avatar
|
||||
src={answerAgent?.avatar}
|
||||
src={isUser ? undefined : answerAgent?.avatar}
|
||||
size={36}
|
||||
className="message-item-assistant-avatar"
|
||||
className="message-item-avatar"
|
||||
>
|
||||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
|
||||
</Avatar>
|
||||
<div className="message-item-assistant-content">
|
||||
<div className="message-item-assistant-header">
|
||||
<span className="message-item-assistant-name">
|
||||
{answerAgent?.name || 'AI'}
|
||||
|
||||
<div className="message-item-content">
|
||||
<div className="message-item-header">
|
||||
<span className="message-item-name">
|
||||
{isUser ? '我' : (answerAgent?.name || 'AI')}
|
||||
</span>
|
||||
<span className="message-item-time">{timeStr}</span>
|
||||
</div>
|
||||
|
||||
<div className={`bubble ${bubbleRole}`}>
|
||||
{isUser && !formattedContent.includes(' ? (
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||||
}} />
|
||||
) : (
|
||||
<Markdown>{formattedContent}</Markdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="message-item-actions">
|
||||
{hasBranches && (
|
||||
<Space size={2}>
|
||||
|
|
@ -99,36 +115,22 @@ export default function MessageItem(props: {
|
|||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
{!isUser && (
|
||||
<Tooltip title="重新生成">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isMobile && message.meta && (
|
||||
<div>
|
||||
|
||||
{/* {!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 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>
|
||||
<Avatar className="message-item-user-avatar" size={36}>我</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
.chat-shell.desktop-standardPc {
|
||||
grid-template-columns: 17.5rem minmax(0, 1fr);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-shell.desktop-standardPc .chat-content-row {
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
.chat-shell.desktop-standardPc .chat-side {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-shell.desktop-standardPc .chat-outline {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,15 +11,28 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-shell.desktop-tablet,
|
||||
.chat-shell.desktop-smallPc,
|
||||
.chat-shell.desktop-standardPc,
|
||||
.chat-shell.desktop-large2k,
|
||||
.chat-shell.desktop-ultra4k,
|
||||
.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-content-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--color-surface);
|
||||
border-radius: 14px;
|
||||
|
|
@ -469,7 +364,7 @@ body {
|
|||
}
|
||||
|
||||
.chat-side {
|
||||
width: 260px;
|
||||
width: 300px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
|
@ -494,135 +389,12 @@ body {
|
|||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
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) {
|
||||
.chat-content-row > .chat-outline {
|
||||
display: none;
|
||||
|
|
@ -703,11 +475,6 @@ body {
|
|||
padding: 1.125rem 0.75rem 6rem;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
top: -22px;
|
||||
right: 0;
|
||||
|
|
@ -747,7 +514,9 @@ body {
|
|||
}
|
||||
|
||||
.main-content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-body .messages-container {
|
||||
|
|
@ -757,36 +526,10 @@ body {
|
|||
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 {
|
||||
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 h1,
|
||||
.bubble.assistant h2,
|
||||
|
|
@ -851,27 +594,6 @@ body {
|
|||
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 {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
|
|
@ -1671,10 +1393,6 @@ body {
|
|||
.border-l { border-left: 1px solid var(--color-border); }
|
||||
.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-affix-wrapper,
|
||||
.ant-input-number,
|
||||
|
|
@ -1696,14 +1414,6 @@ body {
|
|||
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 {
|
||||
background: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
|
|
@ -1743,12 +1453,6 @@ span.mention {
|
|||
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 {
|
||||
background: var(--color-surface) !important;
|
||||
color: var(--color-text) !important;
|
||||
|
|
@ -2362,6 +2066,12 @@ span.mention {
|
|||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.stats-page-agent-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.stats-page-agent-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
|
|
|
|||
|
|
@ -1,58 +1,59 @@
|
|||
:root,
|
||||
[data-theme='light'] {
|
||||
--color-bg: #f5f9ff;
|
||||
--color-bg: #FAFCFC;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #eef5ff;
|
||||
--color-surface-3: #dceaff;
|
||||
--color-border: #d6e5fb;
|
||||
--color-border-strong: #a9c8f6;
|
||||
--color-border-focus: #1167ff;
|
||||
--color-text: #06143f;
|
||||
--color-text-secondary: #405784;
|
||||
--color-text-tertiary: #7c8caf;
|
||||
--color-brand: #1167ff;
|
||||
--color-brand-hover: #0754df;
|
||||
--color-brand-soft: #eaf3ff;
|
||||
--color-brand-soft-2: #d7e8ff;
|
||||
--color-success: #0d9f6e;
|
||||
--color-success-soft: #e7f8f1;
|
||||
--color-warning: #b7791f;
|
||||
--color-warning-soft: #fff5dc;
|
||||
--color-danger: #cf3434;
|
||||
--color-danger-soft: #ffeaea;
|
||||
--color-info: #1e86ff;
|
||||
--color-info-soft: #e8f3ff;
|
||||
--shadow-xs: 0 1px 2px rgba(6, 20, 63, 0.04);
|
||||
--shadow-sm: 0 2px 8px rgba(17, 103, 255, 0.06);
|
||||
--shadow-md: 0 10px 26px rgba(17, 103, 255, 0.1);
|
||||
--shadow-lg: 0 18px 46px rgba(17, 103, 255, 0.14);
|
||||
--shadow-xl: 0 24px 70px rgba(6, 20, 63, 0.16);
|
||||
--shadow-focus: 0 0 0 3px rgba(17, 103, 255, 0.18);
|
||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 48%, #22a6ff 100%);
|
||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(34, 166, 255, 0.18), transparent 62%),
|
||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.14), transparent 58%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
|
||||
--color-surface-2: #edfdfd;
|
||||
--color-surface-3: #e6fffa;
|
||||
--color-border: #E0EBEB;
|
||||
--color-border-strong: #cbd5e0;
|
||||
--color-border-focus: #4fd1c5;
|
||||
--color-text: #0F1F2E;
|
||||
--color-text-secondary: #0F1F2E;
|
||||
--color-text-tertiary: #718096;
|
||||
--color-primary: #5CCFC4;
|
||||
--color-brand: #4fd1c5;
|
||||
--color-brand-hover: #38b2ac;
|
||||
--color-brand-soft: #e6fffa;
|
||||
--color-brand-soft-2: #b2f5ea;
|
||||
--color-success: #38a169;
|
||||
--color-success-soft: #f0fff4;
|
||||
--color-warning: #d69e2e;
|
||||
--color-warning-soft: #fffff0;
|
||||
--color-danger: #e53e3e;
|
||||
--color-danger-soft: #fff5f5;
|
||||
--color-info: #3182ce;
|
||||
--color-info-soft: #ebf8ff;
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(79, 209, 197, 0.2);
|
||||
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.15), transparent 60%),
|
||||
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'] {
|
||||
--color-bg: #071126;
|
||||
--color-surface: #0c1730;
|
||||
--color-surface-2: #111f3c;
|
||||
--color-surface-3: #17294c;
|
||||
--color-border: #1f3764;
|
||||
--color-border-strong: #31558f;
|
||||
--color-border-focus: #55a5ff;
|
||||
--color-text: #edf5ff;
|
||||
--color-text-secondary: #b6c8e8;
|
||||
--color-text-tertiary: #7f93ba;
|
||||
--color-brand: #55a5ff;
|
||||
--color-brand-hover: #7bbaff;
|
||||
--color-brand-soft: #10284f;
|
||||
--color-brand-soft-2: #17396c;
|
||||
--color-info: #72b7ff;
|
||||
--color-info-soft: #10284f;
|
||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 54%, #55c2ff 100%);
|
||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(85, 165, 255, 0.18), transparent 62%),
|
||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.16), transparent 58%),
|
||||
linear-gradient(180deg, #071126 0%, #0c1730 100%);
|
||||
--color-bg: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-surface-2: #1e293b;
|
||||
--color-surface-3: #334155;
|
||||
--color-border: #334155;
|
||||
--color-border-strong: #475569;
|
||||
--color-border-focus: #4fd1c5;
|
||||
--color-text: #f8fafc;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-tertiary: #64748b;
|
||||
--color-brand: #4fd1c5;
|
||||
--color-brand-hover: #38b2ac;
|
||||
--color-brand-soft: #134e4a;
|
||||
--color-brand-soft-2: #115e59;
|
||||
--color-info: #60a5fa;
|
||||
--color-info-soft: #1e3a8a;
|
||||
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||
--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(56, 178, 172, 0.16), transparent 58%),
|
||||
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';
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
// 默认走 Go 后端 :4001;要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const target = env.VITE_API_TARGET || 'https://tianchaoai.cc';
|
||||
|
||||
return {
|
||||
// 本地开发走根路径,生产构建部署到 /aura 子路径
|
||||
base: command === 'serve' ? '/' : '/aura/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3001
|
||||
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