feat: support membership and sub account

main
yannyang 2026-07-27 00:42:15 +08:00
parent e33a1c3741
commit 2a4dc72059
39 changed files with 3280 additions and 992 deletions

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Button, Drawer, Spin } from 'antd'; import { Button, Drawer, Spin } from 'antd';
import { MenuOutlined, SearchOutlined } from '@ant-design/icons'; import { MenuOutlined, SearchOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import CommandPalette from './components/CommandPalette'; import CommandPalette from './components/CommandPalette';
import AgentList from './pages/AgentList'; import AgentList from './pages/AgentList';
@ -13,6 +13,8 @@ import PointsMallPage from './pages/PointsMallPage';
import TeamsPage from './pages/TeamsPage'; import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage'; import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage'; import StatsPage from './pages/StatsPage';
import ProfilePage from './pages/ProfilePage';
import PricingPage from './pages/PricingPage';
import SharedSessionPage from './pages/SharedSessionPage'; import SharedSessionPage from './pages/SharedSessionPage';
import WorkflowsPage from './pages/WorkflowsPage'; import WorkflowsPage from './pages/WorkflowsPage';
import { useAuth } from './store/auth'; import { useAuth } from './store/auth';
@ -29,6 +31,7 @@ export default function App() {
const location = useLocation(); const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// 全局快捷键 Ctrl/⌘ + K // 全局快捷键 Ctrl/⌘ + K
@ -58,6 +61,8 @@ export default function App() {
<Route path="/teams" element={<TeamsPage />} /> <Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} /> <Route path="/prompts" element={<PromptLibraryPage />} />
<Route path="/stats" element={<StatsPage />} /> <Route path="/stats" element={<StatsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/workflows" element={<WorkflowsPage />} /> <Route path="/workflows" element={<WorkflowsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@ -79,7 +84,11 @@ export default function App() {
<div className="layout-shell"> <div className="layout-shell">
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */} {/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? ( {!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
<Sidebar onOpenPalette={() => setPaletteOpen(true)} /> <Sidebar
onOpenPalette={() => setPaletteOpen(true)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
) : null} ) : null}
<main className={`main${isMobile ? ' is-h5' : ''}`}> <main className={`main${isMobile ? ' is-h5' : ''}`}>
{isMobile && ( {isMobile && (

View File

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { clearUserStorage } from '../utils/storage'; import { clearUserStorage } from '../utils/storage';
export const API_BASE_URL = 'https://www.tianchaoai.cc/api/v1/'; export const API_BASE_URL = import.meta.env.DEV ? '/api/' : 'https://www.tianchaoai.cc/api/v1/';
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`; export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`; export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;

View File

@ -15,4 +15,5 @@ export * from './stats';
export * from './llmProviders'; export * from './llmProviders';
export * from './streamChat'; export * from './streamChat';
export * from './workflows'; export * from './workflows';
export * from './membership';

111
src/api/membership.ts Normal file
View File

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

View File

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

252
src/components/Sidebar.css Normal file
View File

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

View File

@ -9,17 +9,21 @@ import {
ApartmentOutlined, ApartmentOutlined,
BarChartOutlined, BarChartOutlined,
TeamOutlined, TeamOutlined,
SunOutlined, LogoutOutlined,
MoonOutlined, LeftOutlined,
LogoutOutlined RightOutlined,
UserOutlined,
CreditCardOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuth } from '../store/auth'; import { useAuth } from '../store/auth';
import { useTheme } from '../main';
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png'; import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
import './Sidebar.css';
interface Props { interface Props {
onOpenPalette?: () => void; onOpenPalette?: () => void;
onNavigate?: () => void; onNavigate?: () => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
} }
const NAV_GROUPS: Array<{ const NAV_GROUPS: Array<{
@ -51,111 +55,112 @@ const NAV_GROUPS: Array<{
{ {
label: '商城', label: '商城',
items: [ items: [
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token商城' } { to: '/points-mall', icon: <CompassOutlined />, label: 'Token 商城' },
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
] ]
} }
]; ];
export default function Sidebar({ onOpenPalette, onNavigate }: Props) { export default function Sidebar({ onOpenPalette, onNavigate, collapsed, onToggleCollapse }: Props) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { mode, toggle } = useTheme();
const isMac = const isMac =
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || ''); typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
const cmdKey = isMac ? '⌘' : 'Ctrl'; const cmdKey = isMac ? '⌘' : 'Ctrl';
return ( return (
<aside className="sidebar"> <aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
<div className="brand"> <div className="sidebar-brand">
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" /> <img src={kaiwuIcon} alt="鲸域AI" className="sidebar-brand-logo" />
<span>AI</span> {!collapsed && <span className="sidebar-brand-name">AI</span>}
<div className="sidebar-brand-spacer" /> <div className="sidebar-brand-toggle" onClick={onToggleCollapse}>
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}> {collapsed ? <RightOutlined style={{ fontSize: 12 }} /> : <LeftOutlined style={{ fontSize: 12 }} />}
<button className="theme-toggle" onClick={toggle} aria-label="切换主题"> </div>
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
</button>
</Tooltip>
</div> </div>
<div <div className="sidebar-search" onClick={onOpenPalette}>
onClick={() => { <div className="sidebar-search-input">
onOpenPalette?.(); <SearchOutlined className="sidebar-search-icon" />
onNavigate?.(); {!collapsed && (
}} <>
className="nav-item sidebar-search-action" <span className="sidebar-search-placeholder"></span>
> <span className="sidebar-search-suffix">{cmdKey} K</span>
<span className="sidebar-search-label"> </>
<SearchOutlined className="nav-icon" /> )}
<span></span> </div>
</span>
<span className="kbd">{cmdKey} K</span>
</div> </div>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
{NAV_GROUPS.map((group) => ( {NAV_GROUPS.map((group) => (
<div key={group.label}> <div key={group.label} className="sidebar-nav-group">
<div className="nav-section-label">{group.label}</div> {!collapsed && <div className="sidebar-nav-label">{group.label}</div>}
{group.items.map((it) => ( {group.items.map((it) => (
<NavLink <Tooltip key={it.to} title={collapsed ? it.label : ''} placement="right">
key={it.to} <NavLink
to={it.to} to={it.to}
end={it.end} end={it.end}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `sidebar-nav-item ${isActive ? 'active' : ''}`}
onClick={onNavigate} onClick={onNavigate}
> >
<span className="nav-icon">{it.icon}</span> <span className="sidebar-nav-icon">{it.icon}</span>
<span>{it.label}</span> {!collapsed && <span>{it.label}</span>}
</NavLink> </NavLink>
</Tooltip>
))} ))}
</div> </div>
))} ))}
</div> </div>
{user && ( {user && (
<Dropdown <div className="sidebar-user">
menu={{ <Dropdown
items: [ menu={{
{ items: [
key: 'name', {
label: <span className="sidebar-user-role">{user.phone}</span>, key: 'name',
disabled: true label: <span className="sidebar-user-role">{user.phone}</span>,
}, disabled: true
{ type: 'divider' }, },
{ {
key: 'role', key: 'profile',
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined />,
disabled: true label: '个人中心',
}, onClick: () => {
{ type: 'divider' }, navigate('/profile');
{ onNavigate?.();
key: 'logout', }
icon: <LogoutOutlined />, },
label: '退出登录', { type: 'divider' },
onClick: async () => { {
await logout(); key: 'logout',
navigate('/login'); icon: <LogoutOutlined />,
onNavigate?.(); label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
onNavigate?.();
}
} }
} ]
] }}
}} placement="topLeft"
placement="topLeft" >
> <div className="sidebar-user-card">
<div className="sidebar-user"> <div className="sidebar-user-avatar">
<Avatar size={32} className="sidebar-user-avatar"> {(user.name?.charAt(0) || '?').toUpperCase()}
{(user.name?.charAt(0) || '?').toUpperCase()}
</Avatar>
<div className="sidebar-user-main">
<div className="sidebar-user-name">
{user.name}
</div>
<div className="sidebar-user-role">
{user.role === 'admin' ? '管理员' : '成员'}
</div> </div>
{!collapsed && (
<div className="sidebar-user-info">
<div className="sidebar-user-name">{user.name}</div>
<div className="sidebar-user-role">
{user.role === 'admin' ? '管理员' : '成员'}
</div>
</div>
)}
</div> </div>
</div> </Dropdown>
</Dropdown> </div>
)} )}
</aside> </aside>
); );

View File

@ -42,8 +42,8 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
() => ({ () => ({
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { token: {
colorPrimary: isDark ? '#55a5ff' : '#1167ff', colorPrimary: isDark ? '#55a5ff' : '#5CCFC4',
colorInfo: isDark ? '#72b7ff' : '#1e86ff', colorInfo: isDark ? '#72b7ff' : '#5CCFC4',
colorBgBase: isDark ? '#071126' : '#f5f9ff', colorBgBase: isDark ? '#071126' : '#f5f9ff',
colorBgContainer: isDark ? '#0c1730' : '#ffffff', colorBgContainer: isDark ? '#0c1730' : '#ffffff',
colorBgElevated: isDark ? '#111f3c' : '#ffffff', colorBgElevated: isDark ? '#111f3c' : '#ffffff',

View File

@ -2,6 +2,7 @@ import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@an
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd'; import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic'; import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
import '../styles/marketplace-page-web.css';
interface Props { interface Props {
logic: MarketplacePageLogicOutput; logic: MarketplacePageLogicOutput;
@ -12,35 +13,26 @@ export default function MarketplacePageWeb({ logic }: Props) {
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic; const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
return ( return (
<div> <div className="page-container">
<div className="page-hero"> <div className="marketplace-header">
<div style={{ maxWidth: 1240, margin: '0 auto' }}> <div className="marketplace-header-content">
<div <div className="marketplace-intro">
style={{ <div className="marketplace-badge">
display: 'inline-flex', <CompassOutlined className="marketplace-badge-icon" />
alignItems: 'center',
gap: 8, </div>
padding: '6px 10px',
borderRadius: 999, <h2 className="page-title" style={{ marginBottom: 10 }}>
background: 'var(--color-surface)', AI
border: '1px solid var(--color-border)', </h2>
color: 'var(--color-text-secondary)', <div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
fontSize: 12,
fontWeight: 500, </div>
marginBottom: 18,
}}
>
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
</div> </div>
<h1 className="hero-title"> AI </h1>
<p className="hero-subtitle">
</p>
</div> </div>
</div> </div>
<div className="page-container" style={{ paddingTop: 28 }}> <div style={{ paddingTop: 0 }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',

View File

@ -4,28 +4,43 @@
@import './marketplace-page-web-large-2k.css'; @import './marketplace-page-web-large-2k.css';
@import './marketplace-page-web-ultra-4k.css'; @import './marketplace-page-web-ultra-4k.css';
.marketplace-web-hero-inner { .marketplace-header {
max-width: var(--marketplace-max-width, 1240px); border-radius: 24px;
margin: 0 auto; padding: 30px 30px 26px;
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%);
border: 1px solid rgba(8, 145, 178, 0.12);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
margin-bottom: 24px;
} }
.marketplace-web-badge { .marketplace-header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.marketplace-intro {
max-width: 620px;
}
.marketplace-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 10px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
margin-bottom: 18px; background: rgba(255,255,255,0.78);
background: var(--color-surface); border: 1px solid rgba(8, 145, 178, 0.10);
border: 1px solid var(--color-border);
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
margin-bottom: 16px;
} }
.marketplace-web-container { .marketplace-badge-icon {
max-width: var(--marketplace-max-width, 1240px); color: var(--color-brand);
padding-top: 28px;
} }
.marketplace-web-toolbar { .marketplace-web-toolbar {

View File

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

View File

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

View File

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

View File

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

24
src/pages/PricingPage.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

24
src/pages/ProfilePage.tsx Normal file
View File

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

View File

@ -12,7 +12,7 @@ export default function StatsTopAgentsCard({ logic }: { logic: StatsPageLogic })
{data.topAgents.length === 0 ? ( {data.topAgents.length === 0 ? (
<Empty description="暂无" /> <Empty description="暂无" />
) : ( ) : (
<div> <div className="stats-page-agent-list">
{data.topAgents.map((agent, index) => ( {data.topAgents.map((agent, index) => (
<div key={agent.id} className="stats-page-agent-item"> <div key={agent.id} className="stats-page-agent-item">
<div className="stats-page-agent-header"> <div className="stats-page-agent-header">

View File

@ -1,28 +1,98 @@
.chat-side {
width: 300px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
height: 100%;
}
.chat-side.is-full { .chat-side.is-full {
width: 100%; width: 100%;
border: 0; border: 0;
} }
.chat-agent-sidebar-header { .chat-agent-sidebar-header {
padding: 16px; padding: 16px 16px 8px;
border-bottom: 1px solid var(--color-border); }
.chat-agent-create-btn {
height: 40px !important;
border-radius: 12px !important;
font-weight: 600 !important;
background: var(--color-brand) !important;
border: none !important;
box-shadow: 0 4px 12px rgba(79, 209, 197, 0.2) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.chat-agent-create-btn:hover {
background: var(--color-brand-hover) !important;
transform: translateY(-1px);
}
.chat-agent-tabs {
padding: 0 16px;
}
.chat-agent-tabs .ant-tabs-nav {
margin-bottom: 8px !important;
}
.chat-agent-tabs .ant-tabs-tab {
padding: 8px 4px !important;
font-size: 13px !important;
color: var(--color-text-secondary) !important;
}
.chat-agent-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--color-text) !important;
font-weight: 600 !important;
}
.chat-agent-tabs .ant-tabs-ink-bar {
background: var(--color-brand) !important;
height: 3px !important;
border-radius: 3px 3px 0 0 !important;
} }
.chat-agent-sidebar-list { .chat-agent-sidebar-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 8px 12px;
}
.chat-agent-group {
margin-bottom: 20px;
}
.chat-agent-group-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-tertiary);
padding: 0 8px 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-agent-group-count {
font-weight: 400;
} }
.chat-agent-item { .chat-agent-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 8px 10px;
cursor: pointer; cursor: pointer;
background: transparent; border-radius: 12px;
border-left: 3px solid transparent; margin-bottom: 2px;
transition: background 0.2s; transition: all 0.2s;
position: relative;
} }
.chat-agent-item:hover { .chat-agent-item:hover {
@ -30,22 +100,33 @@
} }
.chat-agent-item.active { .chat-agent-item.active {
background: var(--color-surface-2); background: var(--color-surface-3);
border-left: 3px solid var(--color-brand); border-left: 3px solid #5CCFC4;
}
.chat-agent-item.active::after {
content: '';
position: absolute;
right: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-brand);
} }
.chat-agent-avatar-wrap { .chat-agent-avatar-wrap {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 10px;
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 13px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.chat-agent-avatar-img { .chat-agent-avatar-img {
@ -60,7 +141,7 @@
} }
.chat-agent-name { .chat-agent-name {
font-size: 14px; font-size: 13.5px;
color: var(--color-text); color: var(--color-text);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -77,8 +158,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 200px; padding: 40px 20px;
padding: 20px;
text-align: center; text-align: center;
} }
@ -90,5 +170,5 @@
} }
.chat-agent-loading-text { .chat-agent-loading-text {
margin-top: 8px; margin-top: 12px;
} }

View File

@ -1,6 +1,6 @@
import { Button, Spin, Empty } from 'antd'; import { Button, Spin, Empty, Tabs } from 'antd';
import { MessageOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import type { Agent } from '../../../api'; import type { Agent } from '../../../api';
import './AgentSidebar.css'; import './AgentSidebar.css';
@ -15,6 +15,7 @@ export default function AgentSidebar(props: {
}) { }) {
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props; const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
const [loadingTimeout, setLoadingTimeout] = useState(false); const [loadingTimeout, setLoadingTimeout] = useState(false);
const [activeTab, setActiveTab] = useState('mine');
useEffect(() => { useEffect(() => {
if (agentList.length > 0) return; if (agentList.length > 0) return;
@ -26,42 +27,90 @@ export default function AgentSidebar(props: {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [agentList.length]); }, [agentList.length]);
const filteredList = useMemo(() => {
if (activeTab === 'public') return agentList.filter(a => a.visibility === 'public');
if (activeTab === 'private') return agentList.filter(a => a.visibility === 'private');
return agentList; // 'mine' tab shows all for now or filter by owner_id if available
}, [agentList, activeTab]);
// 模拟分组逻辑
const groupedAgents = useMemo(() => {
const groups: Record<string, Agent[]> = {
'我的智能体': [],
'推荐': []
};
filteredList.forEach(a => {
if (a.visibility === 'public') {
groups['推荐'].push(a);
} else {
groups['我的智能体'].push(a);
}
});
return Object.entries(groups).filter(([_, list]) => list.length > 0);
}, [filteredList]);
return ( return (
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}> <aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
<div className="chat-agent-sidebar-header"> <div className="chat-agent-sidebar-header">
<Button block type="dashed" onClick={onCreate}> <Button
+ block
type="primary"
icon={<PlusOutlined />}
onClick={onCreate}
className="chat-agent-create-btn"
>
</Button> </Button>
</div> </div>
<div className="chat-agent-tabs">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{ key: 'mine', label: '我的' },
{ key: 'public', label: '公开' },
{ key: 'private', label: '私有' }
]}
/>
</div>
<div className="chat-agent-sidebar-list"> <div className="chat-agent-sidebar-list">
{agentList.length > 0 ? ( {agentList.length > 0 ? (
agentList.map((a) => { groupedAgents.map(([groupName, list]) => (
const isActive = a.id === activeAgentId; <div key={groupName} className="chat-agent-group">
return ( <div className="chat-agent-group-label">
<div <span>{groupName}</span>
key={a.id} <span className="chat-agent-group-count">{list.length}</span>
onClick={() => onSelect(a.id)}
className={`chat-agent-item ${isActive ? 'active' : ''}`}
>
<div
className="chat-agent-avatar-wrap"
style={{ background: a.avatar || 'var(--gradient-brand)' }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div className="chat-agent-info">
<div className="chat-agent-name">
{a.name}
{!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
</div>
</div>
</div> </div>
); {list.map((a) => {
}) const isActive = a.id === activeAgentId;
return (
<div
key={a.id}
onClick={() => onSelect(a.id)}
className={`chat-agent-item ${isActive ? 'active' : ''}`}
>
<div
className="chat-agent-avatar-wrap"
style={{ background: a.avatar || 'var(--gradient-brand)' }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div className="chat-agent-info">
<div className="chat-agent-name">{a.name}</div>
</div>
</div>
);
})}
</div>
))
) : !loadingTimeout ? ( ) : !loadingTimeout ? (
<div className="chat-agent-sidebar-status"> <div className="chat-agent-sidebar-status">
<Spin /> <Spin />

View File

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

View File

@ -5,6 +5,7 @@ import type { StreamingState } from '../hooks/useChatSender';
import type { CopyMode } from '../utils/copy'; import type { CopyMode } from '../utils/copy';
import MessageItem from './messages/MessageItem'; import MessageItem from './messages/MessageItem';
import { RetrievedView, ToolCallView } from './messages/MetaViews'; import { RetrievedView, ToolCallView } from './messages/MetaViews';
import './ChatBody.css';
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
@ -29,28 +30,16 @@ export default function ChatBody(props: {
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
<div className="messages-container"> <div className="messages-container">
{messages.length === 0 && !streaming.active ? ( {messages.length === 0 && !streaming.active ? (
<div style={{ textAlign: 'center', marginTop: 120 }}> <div className="chat-empty-welcome">
<div <div className="chat-welcome-avatar">
style={{ {isImageUrl(agent.avatar) ? (
width: 68, <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
height: 68, ) : (
borderRadius: '50%', (agent.name?.charAt(0) || '?').toUpperCase()
background: agent.avatar || 'var(--gradient-brand)', )}
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 32,
margin: '0 auto 20px',
boxShadow: 'var(--shadow-lg)',
overflow: 'hidden'
}}
>
{isImageUrl(agent.avatar) ? <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> : (agent.name?.charAt(0) || '?').toUpperCase()}
</div> </div>
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}></h2> <h2 className="chat-welcome-title"></h2>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p> <p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
</div> </div>
) : ( ) : (
<> <>
@ -71,50 +60,31 @@ export default function ChatBody(props: {
))} ))}
{streaming.active && ( {streaming.active && (
<div style={{ marginBottom: 24 }}> <div className="streaming-message">
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{(() => { {(() => {
const streamingAgentId = streaming.targetAgentId || currentAgentId; const streamingAgentId = streaming.targetAgentId || currentAgentId;
const streamingAgent = agentList.find(a => a.id === streamingAgentId); const streamingAgent = agentList.find(a => a.id === streamingAgentId);
if (streamingAgent) { return (
return ( <Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
<Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}> {streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
{streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'} </Avatar>
</Avatar> );
);
}
return null;
})()} })()}
<div style={{ flex: 1, minWidth: 0 }}> <div className="message-item-content">
{(() => { <div className="message-item-header">
const streamingAgentId = streaming.targetAgentId || currentAgentId; <span className="message-item-name">
const streamingAgent = agentList.find(a => a.id === streamingAgentId); {agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
if (streamingAgent) { </span>
return ( </div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 6
}}>
<span style={{
fontSize: 12,
fontWeight: 400,
color: 'var(--color-text-secondary)'
}}>
{streamingAgent.name}
</span>
</div>
);
}
return null;
})()}
<div className="bubble assistant"> <div className="bubble assistant">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!!streaming.retryInfo?.message && ( {!!streaming.retryInfo?.message && (
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'rgba(59, 130, 246, 0.08)', border: '1px solid rgba(59, 130, 246, 0.18)' }}> <div className="streaming-retry-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <div className="streaming-retry-header">
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span> <span className="streaming-retry-title">
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
</span>
{streaming.retryInfo.stage === 'fallback_model' ? ( {streaming.retryInfo.stage === 'fallback_model' ? (
<Tag color="processing" style={{ marginInlineEnd: 0 }}> <Tag color="processing" style={{ marginInlineEnd: 0 }}>
{String(streaming.retryInfo.fromModel || '')} {String(streaming.retryInfo.toModel || '')} {String(streaming.retryInfo.fromModel || '')} {String(streaming.retryInfo.toModel || '')}
@ -126,25 +96,22 @@ export default function ChatBody(props: {
</Tag> </Tag>
)} )}
</div> </div>
<div style={{ marginTop: 4, fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div> <div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
{!!streaming.retryInfo.reason && (
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.5 }}>{String(streaming.retryInfo.reason)}</div>
)}
</div> </div>
)} )}
<div> <div className="streaming-section">
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div> <div className="streaming-section-label"></div>
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>} {streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
</div> </div>
<Divider style={{ margin: '6px 0' }} /> <Divider style={{ margin: '6px 0' }} />
<div> <div className="streaming-section">
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div> <div className="streaming-section-label"></div>
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>} {streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
</div> </div>
</div> </div>
</div> </div>
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && ( {(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
<div> <div style={{ marginTop: 8 }}>
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />} {streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />} {streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
</div> </div>

View File

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

View File

@ -1,7 +1,7 @@
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons'; import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined } from '@ant-design/icons';
import { Button, Dropdown, Modal, Space, Switch } from 'antd'; import { Button, Dropdown, Modal, Switch } from 'antd';
import type { Agent } from '../../../api'; import type { Agent } from '../../../api';
import { useIsMobile } from '../../../hooks/useIsMobile'; import './ChatHeader.css';
function formatAgentModel(raw: string | null | undefined) { function formatAgentModel(raw: string | null | undefined) {
const s = String(raw ?? '').trim(); const s = String(raw ?? '').trim();
@ -23,6 +23,8 @@ function formatAgentModel(raw: string | null | undefined) {
return s; return s;
} }
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
export default function ChatHeader(props: { export default function ChatHeader(props: {
agent: Agent; agent: Agent;
useStream: boolean; useStream: boolean;
@ -35,29 +37,37 @@ export default function ChatHeader(props: {
}) { }) {
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props; const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
const modelText = formatAgentModel(agent.model); const modelText = formatAgentModel(agent.model);
const isMobile = useIsMobile();
return ( return (
<div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}> <div className="chat-header">
<div className="chat-header-agent"> <div className="chat-header-left">
<div className="chat-header-agent-title"> <div className="chat-header-avatar">
<span className="chat-header-agent-name">{agent.name}</span> {isImageUrl(agent.avatar) ? (
{!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>} <img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
(agent.name?.charAt(0) || '?').toUpperCase()
)}
</div> </div>
<div className="chat-header-agent-meta"> <div className="chat-header-info">
{modelText} <div className="chat-header-name-row">
<span className="chat-header-name">{agent.name}</span>
</div>
<div className="chat-header-desc">
<span className="chat-header-model">#{modelText}</span>
</div>
</div> </div>
</div> </div>
<Space>
<div className="chat-header-right">
<div className="chat-header-stream-toggle"> <div className="chat-header-stream-toggle">
<span className="chat-header-stream-label"></span> <span></span>
<Switch size="small" checked={useStream} onChange={setUseStream} /> <Switch size="small" checked={useStream} onChange={setUseStream} />
</div> </div>
{!isMobile && (
<Button size="small" onClick={onOpenHistory}> <Button className="chat-header-btn" onClick={onOpenHistory}>
</Button> </Button>
)}
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
@ -76,12 +86,13 @@ export default function ChatHeader(props: {
} }
] ]
}} }}
placement="bottomRight"
> >
<Button size="small" type={isMobile ? 'text' : 'primary'}> <Button className="chat-header-more-btn">
{isMobile ? <EllipsisOutlined /> : <> <DownOutlined /></>} <EllipsisOutlined />
</Button> </Button>
</Dropdown> </Dropdown>
</Space> </div>
</div> </div>
); );
} }

View File

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

View File

@ -1,10 +1,11 @@
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons'; import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd'; import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
import type { ChatAttachment } from '../../../api'; import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents'; import type { Agent } from '../../../api/agents';
import { HistoryIcon, NewChatIcon } from '../../../components/icons'; import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react'; import './ChatInput.css';
import { IconAttachment, IconPrompt } from '../../../components/Icon';
export default function ChatInput(props: { export default function ChatInput(props: {
input: string; input: string;
@ -39,13 +40,9 @@ export default function ChatInput(props: {
onStop, onStop,
onAttach, onAttach,
onOpenTpl, onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
onOpenHistory, onOpenHistory,
onNewSession, onNewSession,
agentList, agentList,
onInsertMention,
showActions = true showActions = true
} = props; } = props;
@ -98,7 +95,6 @@ export default function ChatInput(props: {
}; };
}; };
// 检测 @ 触发提及选择
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const value = e.target.value;
setInput(value); setInput(value);
@ -107,32 +103,17 @@ export default function ChatInput(props: {
const textBeforeCursor = value.slice(0, cursorPos); const textBeforeCursor = value.slice(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@'); const atIndex = textBeforeCursor.lastIndexOf('@');
console.log('[@mention] input change:', {
value,
cursorPos,
textBeforeCursor,
atIndex,
agentListLength: agentList.length,
agentList: agentList.map(a => ({ id: a.id, name: a.name }))
});
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) { if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1); const query = textBeforeCursor.slice(atIndex + 1);
console.log('[@mention] matched @ at position:', { atIndex, query });
if (!query.includes(' ')) { if (!query.includes(' ')) {
setMentionQuery(query); setMentionQuery(query);
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
requestAnimationFrame(() => { requestAnimationFrame(() => {
const textarea = inputRef.current?.resizableTextArea?.textArea; const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) { if (!textarea) return;
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
return;
}
const caret = getCaretPos(textarea, cursorPos); const caret = getCaretPos(textarea, cursorPos);
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8); const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left)); const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
setMentionPos({ top, left }); setMentionPos({ top, left });
console.log('[@mention] show popover:', { top, left, query });
setShowMentionPopover(true); setShowMentionPopover(true);
}); });
return; return;
@ -172,167 +153,166 @@ export default function ChatInput(props: {
return ( return (
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}> {showActions && (
{attachments.map((a, i) => ( <div className="chat-input-toolbar-top">
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}> <div className="chat-input-actions-left">
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<div key={i} style={{ position: 'relative' }}>
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
<Button <Button
size="small" icon={<HistoryOutlined />}
type="primary" onClick={onOpenHistory}
shape="circle" className="chat-input-action-btn"
icon={<CloseOutlined style={{ fontSize: 8 }} />} >
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
<div className="chat-input-card-wrap">
{showActions && <div className="chat-input-actions">
<Tooltip title="历史记录">
<Button size="small" type="text" className="chat-input-action-btn" icon={<HistoryIcon />} onClick={onOpenHistory}>
</Button> </Button>
</Tooltip> <Button
<Tooltip title="新增会话"> type="primary"
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}> icon={<PlusOutlined />}
onClick={onNewSession}
className="chat-input-action-btn chat-input-action-btn-primary"
>
</Button> </Button>
</Tooltip>
</div>}
<div className="chat-input-card">
<div className="chat-input-stack">
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 3, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? input.length;
const end = el.selectionEnd ?? input.length;
const next = input.slice(0, start) + '\n' + input.slice(end);
setInput(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
});
return;
}
if (!e.shiftKey && !e.altKey) {
e.preventDefault();
onSend();
}
}}
className="chat-input-textarea"
disabled={sending}
/>
{showMentionPopover && mentionPos && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 150
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '6px 10px',
cursor: 'pointer',
borderBottom: '1px solid var(--color-border)'
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--color-text)' }}>
{agent.name}
</div>
{agent.description && (
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{agent.description.slice(0, 30)}
{agent.description.length > 30 ? '...' : ''}
</div>
)}
</div>
))
)}
</div>
)}
<div className="chat-input-toolbar">
<div className="chat-input-toolbar-left">
{/* <Select
value={activeModelValue || undefined}
className="chat-model-select"
popupMatchSelectWidth={false}
options={modelOptions}
suffixIcon={<DownOutlined className="chat-model-select-arrow" />}
placeholder="选择模型"
onChange={onChangeModel}
/> */}
<Upload
className="chat-upload"
multiple
beforeUpload={(_f, files) => {
onAttach(files as File[]);
return false;
}}
showUploadList={false}
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
>
<Button type="text" className="chat-tool-button" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
</Upload>
<Button type="text" className="chat-tool-button" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={onOpenTpl} />
</div>
{sending ? (
<Button danger shape="circle" onClick={onStop} icon={<span className="chat-stop-icon" />} className="chat-send-button" />
) : (
<Button
type="primary"
shape="circle"
onClick={onSend}
icon={<ArrowUpOutlined />}
disabled={!input.trim()}
className="chat-send-button chat-send-button-primary"
/>
)}
</div>
</div> </div>
{/* <div className="chat-input-token-display">
12,480 tokens
</div> */}
</div>
)}
<div className="chat-input-card">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{attachments.map((a, i) => (
<Tag
key={i}
closable
className="chat-attachment-tag"
onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}
>
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<div key={i} style={{ position: 'relative', marginBottom: 8 }}>
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
<Button
size="small"
type="primary"
shape="circle"
icon={<CloseOutlined style={{ fontSize: 8 }} />}
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 2, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? input.length;
const end = el.selectionEnd ?? input.length;
const next = input.slice(0, start) + '\n' + input.slice(end);
setInput(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
});
return;
}
if (!e.shiftKey && !e.altKey) {
e.preventDefault();
onSend();
}
}}
className="chat-input-textarea"
disabled={sending}
/>
<div className="chat-input-bottom-bar">
<div className="chat-input-tools">
<Upload
multiple
beforeUpload={(_f, files) => {
onAttach(files as File[]);
return false;
}}
showUploadList={false}
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
>
<Button type="text" className="chat-tool-btn" icon={<IconAttachment />} />
</Upload>
<Button type="text" className="chat-tool-btn" icon={<IconPrompt />} onClick={onOpenTpl} />
</div>
{sending ? (
<Button
danger
shape="circle"
onClick={onStop}
icon={<div style={{ width: 10, height: 10, background: '#fff', borderRadius: 2 }} />}
className="chat-send-btn chat-stop-btn"
/>
) : (
<Button
type="primary"
shape="circle"
onClick={onSend}
icon={<ArrowUpOutlined />}
disabled={!input.trim()}
className="chat-send-btn"
/>
)}
</div> </div>
</div> </div>
{showMentionPopover && mentionPos && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 8,
boxShadow: 'var(--shadow-lg)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 160
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)', fontSize: 12 }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderBottom: '1px solid var(--color-border)'
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
{agent.name}
</div>
</div>
))
)}
</div>
)}
</div> </div>
); );
} }

View File

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

View File

@ -1,5 +1,7 @@
import { LeftOutlined, OrderedListOutlined, RightOutlined } from '@ant-design/icons';
import type { ChatMessage } from '../../../api'; import type { ChatMessage } from '../../../api';
import { markdownToPlainText } from '../utils/copy'; import { markdownToPlainText } from '../utils/copy';
import './ChatOutline.css';
function summarize(content: string) { function summarize(content: string) {
const plain = markdownToPlainText(content); const plain = markdownToPlainText(content);
@ -12,35 +14,49 @@ function summarize(content: string) {
return text.slice(0, 44) + '…'; return text.slice(0, 44) + '…';
} }
export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (id: string) => void; activeId?: string | null }) { interface ChatOutlineProps {
const { messages, onJump, activeId } = props; messages: ChatMessage[];
onJump: (id: string) => void;
activeId?: string | null;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
export default function ChatOutline(props: ChatOutlineProps) {
const { messages, onJump, activeId, collapsed, onToggleCollapse } = props;
const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent'); const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent');
if (items.length === 0) {
return (
<aside className="chat-outline">
<div className="chat-outline-title"></div>
<div style={{ padding: 12, color: 'var(--color-text-tertiary)', fontSize: 12 }}></div>
</aside>
);
}
return ( return (
<aside className="chat-outline"> <aside className={`chat-outline ${collapsed ? 'is-collapsed' : ''}`}>
<div className="chat-outline-title"></div> <div className="chat-outline-toggle" onClick={onToggleCollapse}>
<div className="chat-outline-list"> {collapsed ? <LeftOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
{items.map((m, idx) => ( </div>
<button
key={m.id} <div className="chat-outline-section">
type="button" <div className="chat-outline-title">{collapsed ? <OrderedListOutlined /> : '对话大纲'}</div>
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`} <div className="chat-outline-list"></div>
onClick={() => onJump(m.id)} {items.length === 0 ? (
title={summarize(m.content)} !collapsed && (
> <div style={{ padding: '12px 4px', color: 'var(--color-text-tertiary)', fontSize: 13 }}>
<span className="chat-outline-index">{idx + 1}</span>
<span className="chat-outline-text">{summarize(m.content)}</span> </div>
</button> )
))} ) : (
<div className="chat-outline-list">
{items.map((m, idx) => (
<button
key={m.id}
type="button"
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`}
onClick={() => onJump(m.id)}
title={summarize(m.content)}
>
<span className="chat-outline-index">{idx + 1}</span>
{!collapsed && <span className="chat-outline-text">{summarize(m.content)}</span>}
</button>
))}
</div>
)}
</div> </div>
</aside> </aside>
); );

View File

@ -1,16 +1,21 @@
import { useState } from 'react';
import { App as AntApp, Empty } from 'antd'; import { App as AntApp, Empty } from 'antd';
import type { ChatPageLogicOutput } from '../ChatPageLogic'; import type { ChatPageLogicOutput } from '../ChatPageLogic';
import { markdownToPlainText } from '../utils/copy'; import { markdownToPlainText } from '../utils/copy';
import type { ModelOverrides } from '../../../api'; import type { ModelOverrides } from '../../../api';
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
import AgentSidebar from './AgentSidebar'; import AgentSidebar from './AgentSidebar';
import ChatBody from './ChatBody'; import ChatBody from './ChatBody';
import ChatDrawers from './ChatDrawers'; import ChatDrawers from './ChatDrawers';
import ChatHeader from './ChatHeader'; import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput'; import ChatInput from './ChatInput';
import ChatOutline from './ChatOutline'; import ChatOutline from './ChatOutline';
import '../styles/chat-page-web.css';
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) { export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const viewport = useDesktopViewport();
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
const { const {
id, id,
@ -39,7 +44,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
} = logic; } = logic;
return ( return (
<div className="chat-shell"> <div className={`chat-shell ${desktopViewportClass(viewport)}`}>
<AgentSidebar <AgentSidebar
agentList={agentList} agentList={agentList}
activeAgentId={id} activeAgentId={id}
@ -49,23 +54,23 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
<section className="chat-main"> <section className="chat-main">
{!agent ? ( {!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div className="chat-empty-state">
<Empty description="请在左侧选择一个智能体开始对话" /> <Empty description="请在左侧选择一个智能体开始对话" />
</div> </div>
) : ( ) : (
<> <div className="chat-content-layout">
<ChatHeader <div className="chat-conversation-panel">
agent={agent} <ChatHeader
useStream={sender.useStream} agent={agent}
setUseStream={sender.setUseStream} useStream={sender.useStream}
onOpenHistory={() => setHistoryDrawerOpen(true)} setUseStream={sender.setUseStream}
onOpenParams={() => setParamsDrawerOpen(true)} onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenMcp={() => setMcpDrawerOpen(true)} onOpenParams={() => setParamsDrawerOpen(true)}
onManageAgent={() => navigate(`/agents/${id}`)} onOpenMcp={() => setMcpDrawerOpen(true)}
onClear={sender.handleClear} onManageAgent={() => navigate(`/agents/${id}`)}
/> onClear={sender.handleClear}
/>
<div className="chat-content-row">
<ChatBody <ChatBody
bodyRef={bodyRef} bodyRef={bodyRef}
agent={agent} agent={agent}
@ -86,45 +91,47 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
}} }}
/> />
<ChatOutline <ChatInput
messages={messages} input={sender.input}
activeId={highlightId} setInput={sender.setInput}
onJump={(msgId) => { sending={sender.sending}
setHighlightId(msgId); attachments={sender.attachments}
const el = document.getElementById('msg-' + msgId); setAttachments={sender.setAttachments}
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' }); imageUrls={sender.imageUrls}
setImageUrls={sender.setImageUrls}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={(modelId) => {
const picked = sender.modelOptions.find((o) => o.value === modelId);
setOverrides((o: ModelOverrides) => ({
...o,
model_id: modelId,
model: picked?.label ?? o.model
}));
}} }}
agentList={agentList}
onInsertMention={() => {}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={handleNewSession}
/> />
</div> </div>
<ChatInput <ChatOutline
input={sender.input} messages={messages}
setInput={sender.setInput} activeId={highlightId}
sending={sender.sending} collapsed={outlineCollapsed}
attachments={sender.attachments} onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
setAttachments={sender.setAttachments} onJump={(msgId) => {
imageUrls={sender.imageUrls} setHighlightId(msgId);
setImageUrls={sender.setImageUrls} const el = document.getElementById('msg-' + msgId);
onSend={sender.handleSend} if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={(modelId) => {
const picked = sender.modelOptions.find((o) => o.value === modelId);
setOverrides((o: ModelOverrides) => ({
...o,
model_id: modelId,
model: picked?.label ?? o.model
}));
}} }}
agentList={agentList}
onInsertMention={() => {}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={handleNewSession}
/> />
</> </div>
)} )}
</section> </section>

View File

@ -1,78 +1,113 @@
.message-item-container { .message-item-container {
margin-bottom: 20px; transition: background 0.3s;
transition: background 0.4s, padding 0.4s;
} }
.message-item-container.highlighted { .message-item-container.highlighted {
padding: 8px; background: var(--color-surface-2);
border-radius: 10px;
background: rgba(254, 243, 199, 0.6);
} }
/* Assistant Message Styles */ .message-item-assistant,
.message-item-assistant { .message-item-user {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start; max-width: 90%;
} }
.message-item-assistant-avatar { .message-item-user {
margin-left: auto;
flex-direction: row-reverse;
}
.message-item-avatar {
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px; width: 36px;
background-color: #52c41a; height: 36px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.message-item-assistant-content { .message-item-content {
flex: 1; display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
} }
.message-item-assistant-header { .message-item-user .message-item-content {
align-items: flex-end;
}
.message-item-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 13px;
} }
.message-item-assistant-name { .message-item-name {
font-size: 12px; font-weight: 600;
font-weight: 400; color: var(--color-text);
color: var(--color-text-secondary);
} }
/* User Message Styles */ .message-item-time {
.message-item-user { color: var(--color-text-tertiary);
display: flex; font-size: 11px;
gap: 12px;
align-items: flex-start;
justify-content: flex-end;
} }
.message-item-user-content-wrapper { .bubble {
flex: 1; padding: 12px 16px;
min-width: 0; border-radius: 16px;
max-width: 78%; font-size: 14.5px;
display: flex; line-height: 1.6;
flex-direction: column; position: relative;
align-items: flex-end; word-wrap: break-word;
} }
.message-item-user-avatar { .bubble.assistant {
flex-shrink: 0; background: #ffffff;
margin-top: 2px; padding: 16px;
background-color: #1890ff; color: var(--color-text);
border-radius: 16px;
} }
.mention { .bubble.user {
color: var(--color-brand); background: var(--color-primary);
font-weight: 500; color: #fff;
border-radius: 16px;
}
.bubble.user span.mention {
background: rgba(255, 159, 10, 0.22);
border: 1px solid rgba(255, 159, 10, 0.38);
color: #ff9f0a;
} }
.message-item-actions { .message-item-actions {
padding-top: 4px; display: flex;
border-top: 1px solid var(--color-border); align-items: center;
gap: 4px;
margin-top: 8px;
transition: opacity 0.2s;
opacity: 0;
}
.message-item-container:hover .message-item-actions {
opacity: 1;
} }
.actions-btn { .actions-btn {
color: var(--color-text-secondary); color: var(--color-text-tertiary) !important;
}
.actions-btn:hover {
color: var(--color-text-secondary) !important;
background: var(--color-surface-3) !important;
}
.bubble.user .markdown p {
color: #fff;
}
.bubble.assistant .markdown p {
color: var(--color-text);
} }

View File

@ -1,6 +1,7 @@
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd'; import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CopyOutlined, SyncOutlined } from '@ant-design/icons'; import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BranchInfo, ChatMessage } from '../../../../api'; import type { BranchInfo, ChatMessage } from '../../../../api';
import type { Agent } from '../../../../api/agents'; import type { Agent } from '../../../../api/agents';
import Markdown from '../../../../components/Markdown'; import Markdown from '../../../../components/Markdown';
@ -37,6 +38,11 @@ export default function MessageItem(props: {
const activeIdx = branch?.activeIndex ?? 0; const activeIdx = branch?.activeIndex ?? 0;
const total = branch?.total ?? 1; const total = branch?.total ?? 1;
const timeStr = useMemo(() => {
if (!message.createdAt) return '';
return dayjs(message.createdAt * 1000).format('HH:mm');
}, [message.createdAt]);
const goPrev = () => { const goPrev = () => {
if (!branch || !message.parentId) return; if (!branch || !message.parentId) return;
const i = Math.max(0, activeIdx - 1); const i = Math.max(0, activeIdx - 1);
@ -54,81 +60,77 @@ export default function MessageItem(props: {
id={'msg-' + message.id} id={'msg-' + message.id}
className={`message-item-container ${highlighted ? 'highlighted' : ''}`} className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
> >
{!isUser ? ( <div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
<div className="message-item-assistant"> <Avatar
<Avatar src={isUser ? undefined : answerAgent?.avatar}
src={answerAgent?.avatar} size={36}
size={36} className="message-item-avatar"
className="message-item-assistant-avatar" >
> {isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'} </Avatar>
</Avatar>
<div className="message-item-assistant-content"> <div className="message-item-content">
<div className="message-item-assistant-header"> <div className="message-item-header">
<span className="message-item-assistant-name"> <span className="message-item-name">
{answerAgent?.name || 'AI'} {isUser ? '我' : (answerAgent?.name || 'AI')}
</span> </span>
</div> <span className="message-item-time">{timeStr}</span>
<div className={`bubble ${bubbleRole}`}> </div>
<div className={`bubble ${bubbleRole}`}>
{isUser && !formattedContent.includes('![image](') ? (
<span dangerouslySetInnerHTML={{
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
}} />
) : (
<Markdown>{formattedContent}</Markdown> <Markdown>{formattedContent}</Markdown>
<div className="message-item-actions"> )}
{hasBranches && ( </div>
<Space size={2}>
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}> <div className="message-item-actions">
{hasBranches && (
</Button> <Space size={2}>
<span> <Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
{activeIdx + 1} / {total}
</span> </Button>
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}> <span>
{activeIdx + 1} / {total}
</Button> </span>
</Space> <Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
)}
{message.meta?.aborted && <Tag color="orange"></Tag>} </Button>
<Dropdown </Space>
trigger={['click']} )}
menu={{ {message.meta?.aborted && <Tag color="orange"></Tag>}
items: [ <Dropdown
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') }, trigger={['click']}
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') } menu={{
] items: [
}} { key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
> { key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
<Tooltip title="复制"> ]
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} /> }}
</Tooltip> >
</Dropdown> <Tooltip title="复制">
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
</Tooltip>
</Dropdown>
{!isUser && (
<Tooltip title="重新生成"> <Tooltip title="重新生成">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} /> <Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
</Tooltip> </Tooltip>
</div>
</div>
{!isMobile && message.meta && (
<div>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
</div>
)}
</div>
</div>
) : (
<div className="message-item-user">
<div className="message-item-user-content-wrapper">
<div className={`bubble ${bubbleRole}`}>
{formattedContent.includes('![image](') ? (
<Markdown>{formattedContent}</Markdown>
) : (
<span dangerouslySetInnerHTML={{
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
}} />
)} )}
</div> </div>
</div>
<Avatar className="message-item-user-avatar" size={36}></Avatar> {/* {!isMobile && message.meta && !isUser && (
<div style={{ marginTop: 8 }}>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
</div>
)} */}
</div> </div>
)} </div>
</div> </div>
); );
} }

View File

@ -1,12 +1,13 @@
.chat-shell.desktop-standardPc { .chat-shell.desktop-standardPc {
grid-template-columns: 17.5rem minmax(0, 1fr); display: flex;
} }
.chat-shell.desktop-standardPc .chat-content-row { .chat-shell.desktop-standardPc .chat-side {
grid-template-columns: minmax(0, 1fr) 260px; width: 300px;
flex-shrink: 0;
} }
.chat-shell.desktop-standardPc .chat-outline { .chat-shell.desktop-standardPc .chat-outline {
width: 100%; flex-shrink: 0;
min-width: 0;
} }

View File

@ -11,15 +11,28 @@
justify-content: center; justify-content: center;
} }
.chat-shell.desktop-tablet, .chat-content-layout {
.chat-shell.desktop-smallPc, display: flex;
.chat-shell.desktop-standardPc, height: 100%;
.chat-shell.desktop-large2k, width: 100%;
.chat-shell.desktop-ultra4k, overflow: hidden;
.chat-shell.desktop-tablet .chat-content-row,
.chat-shell.desktop-smallPc .chat-content-row,
.chat-shell.desktop-standardPc .chat-content-row,
.chat-shell.desktop-large2k .chat-content-row,
.chat-shell.desktop-ultra4k .chat-content-row {
display: grid;
} }
.chat-conversation-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
background: var(--color-bg);
}
.chat-shell.desktop-standardPc {
grid-template-columns: 280px minmax(0, 1fr);
}
.chat-shell.desktop-standardPc .chat-main {
height: 100vh;
overflow: hidden;
}

View File

@ -280,111 +280,6 @@ body {
overflow: hidden; overflow: hidden;
} }
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.main-chat {
overflow: hidden;
}
.sidebar {
width: 248px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
color: var(--color-text);
display: flex;
flex-direction: column;
padding: 14px 12px;
}
.sidebar .brand {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 6px 10px 18px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar .brand .brand-logo {
width: 28px;
height: 28px;
flex: 0 0 auto;
object-fit: contain;
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
}
.sidebar .nav-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-tertiary);
padding: 14px 12px 6px;
}
.sidebar .nav-item {
padding: 7px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
}
.sidebar .nav-item:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
.sidebar .nav-item.active {
background: var(--color-brand-soft);
color: var(--color-brand);
font-weight: 600;
}
.sidebar .nav-item .nav-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.sidebar .kbd {
font-size: 10.5px;
color: var(--color-text-tertiary);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
cursor: pointer;
border-radius: 10px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
}
.agent-card { .agent-card {
background: var(--color-surface); background: var(--color-surface);
border-radius: 14px; border-radius: 14px;
@ -469,7 +364,7 @@ body {
} }
.chat-side { .chat-side {
width: 260px; width: 300px;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@ -494,135 +389,12 @@ body {
display: flex; display: flex;
} }
.chat-header {
height: 60px;
padding: 0 24px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-surface);
}
.chat-header-agent {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-header-agent-title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.chat-header-agent-name {
font-weight: 600;
font-size: 16px;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.chat-header-agent-desc {
font-size: 12px;
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 380px;
}
.chat-header-agent-meta {
font-size: 12px;
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 720px;
}
.chat-header-stream-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-right: 12px;
}
.is-h5 .chat-header-stream-toggle {
margin-right: 4px;;
}
.chat-header-stream-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.chat-body { .chat-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background: var(--color-bg); background: var(--color-bg);
} }
.chat-outline {
width: 260px;
border-left: 1px solid var(--color-border);
background: var(--color-surface);
padding: 14px 12px;
overflow: auto;
}
.chat-outline-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 10px;
}
.chat-outline-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-outline-item {
border: 0;
background: var(--color-surface);
border-radius: 10px;
padding: 4px 10px;
text-align: left;
cursor: pointer;
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--color-text);
}
.chat-outline-item.active {
background-color: #eee;
}
.chat-outline-index {
font-size: 12px;
color: var(--color-text-tertiary);
line-height: 1.4;
flex: 0 0 auto;
}
.chat-outline-text {
font-size: 12.5px;
line-height: 1.4;
color: var(--color-text);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.chat-content-row > .chat-outline { .chat-content-row > .chat-outline {
display: none; display: none;
@ -703,11 +475,6 @@ body {
padding: 1.125rem 0.75rem 6rem; padding: 1.125rem 0.75rem 6rem;
} }
.chat-input-wrapper {
max-width: 100%;
padding: 0 0.75rem 1rem;
}
.chat-input-actions { .chat-input-actions {
top: -22px; top: -22px;
right: 0; right: 0;
@ -747,7 +514,9 @@ body {
} }
.main-content { .main-content {
min-height: 0; flex: 1;
overflow: auto;
position: relative;
} }
.chat-body .messages-container { .chat-body .messages-container {
@ -757,36 +526,10 @@ body {
padding: 16px 12px 80px; padding: 16px 12px 80px;
} }
.bubble {
max-width: 78%;
display: inline-block;
padding: 8px;
border-radius: 14px;
white-space: normal;
word-wrap: break-word;
line-height: 1.5;
font-size: 14.5px;
}
.is-h5 .bubble { .is-h5 .bubble {
max-width: 94%; max-width: 94%;
} }
.bubble.user {
background: #0a84ff;
color: #ffffff;
margin-left: auto;
border-bottom-right-radius: 5px;
}
.bubble.assistant {
background: #edf1f6;
color: #111827;
border: 0;
border-bottom-left-radius: 5px;
box-shadow: none;
}
.bubble.assistant p, .bubble.assistant p,
.bubble.assistant h1, .bubble.assistant h1,
.bubble.assistant h2, .bubble.assistant h2,
@ -851,27 +594,6 @@ body {
font-weight: 600; font-weight: 600;
} }
.chat-input-wrapper {
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: 0 24px 24px;
}
.chat-input-card {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 14px 16px 12px;
min-height: 110px;
box-shadow: var(--shadow-sm);
}
.chat-input-card-wrap {
position: relative;
}
.chat-input-actions { .chat-input-actions {
position: absolute; position: absolute;
top: -24px; top: -24px;
@ -1671,10 +1393,6 @@ body {
.border-l { border-left: 1px solid var(--color-border); } .border-l { border-left: 1px solid var(--color-border); }
.shadow-lg { box-shadow: var(--shadow-lg); } .shadow-lg { box-shadow: var(--shadow-lg); }
.ant-btn-primary {
box-shadow: 0 1px 2px rgba(194, 84, 31, 0.18) !important;
}
.ant-input, .ant-input,
.ant-input-affix-wrapper, .ant-input-affix-wrapper,
.ant-input-number, .ant-input-number,
@ -1696,14 +1414,6 @@ body {
border-color: var(--color-border-strong) !important; border-color: var(--color-border-strong) !important;
} }
.ant-input-affix-wrapper-focused,
.ant-input:focus,
.ant-select-focused .ant-select-selector,
.ant-picker-focused {
border-color: var(--color-brand) !important;
box-shadow: var(--shadow-focus) !important;
}
.ant-card { .ant-card {
background: var(--color-surface) !important; background: var(--color-surface) !important;
border-color: var(--color-border) !important; border-color: var(--color-border) !important;
@ -1743,12 +1453,6 @@ span.mention {
line-height: 1.3; line-height: 1.3;
} }
.bubble.user span.mention {
background: rgba(255, 159, 10, 0.22);
border: 1px solid rgba(255, 159, 10, 0.38);
color: #ff9f0a;
}
.ant-collapse-content { .ant-collapse-content {
background: var(--color-surface) !important; background: var(--color-surface) !important;
color: var(--color-text) !important; color: var(--color-text) !important;
@ -2362,6 +2066,12 @@ span.mention {
border-radius: 999px; border-radius: 999px;
} }
.stats-page-agent-list {
max-height: 240px;
overflow-y: auto;
padding-right: 4px;
}
.stats-page-agent-item { .stats-page-agent-item {
margin-bottom: 12px; margin-bottom: 12px;
padding: 12px 14px; padding: 12px 14px;

View File

@ -1,58 +1,59 @@
:root, :root,
[data-theme='light'] { [data-theme='light'] {
--color-bg: #f5f9ff; --color-bg: #FAFCFC;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-2: #eef5ff; --color-surface-2: #edfdfd;
--color-surface-3: #dceaff; --color-surface-3: #e6fffa;
--color-border: #d6e5fb; --color-border: #E0EBEB;
--color-border-strong: #a9c8f6; --color-border-strong: #cbd5e0;
--color-border-focus: #1167ff; --color-border-focus: #4fd1c5;
--color-text: #06143f; --color-text: #0F1F2E;
--color-text-secondary: #405784; --color-text-secondary: #0F1F2E;
--color-text-tertiary: #7c8caf; --color-text-tertiary: #718096;
--color-brand: #1167ff; --color-primary: #5CCFC4;
--color-brand-hover: #0754df; --color-brand: #4fd1c5;
--color-brand-soft: #eaf3ff; --color-brand-hover: #38b2ac;
--color-brand-soft-2: #d7e8ff; --color-brand-soft: #e6fffa;
--color-success: #0d9f6e; --color-brand-soft-2: #b2f5ea;
--color-success-soft: #e7f8f1; --color-success: #38a169;
--color-warning: #b7791f; --color-success-soft: #f0fff4;
--color-warning-soft: #fff5dc; --color-warning: #d69e2e;
--color-danger: #cf3434; --color-warning-soft: #fffff0;
--color-danger-soft: #ffeaea; --color-danger: #e53e3e;
--color-info: #1e86ff; --color-danger-soft: #fff5f5;
--color-info-soft: #e8f3ff; --color-info: #3182ce;
--shadow-xs: 0 1px 2px rgba(6, 20, 63, 0.04); --color-info-soft: #ebf8ff;
--shadow-sm: 0 2px 8px rgba(17, 103, 255, 0.06); --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 10px 26px rgba(17, 103, 255, 0.1); --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-lg: 0 18px 46px rgba(17, 103, 255, 0.14); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-xl: 0 24px 70px rgba(6, 20, 63, 0.16); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-focus: 0 0 0 3px rgba(17, 103, 255, 0.18); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 48%, #22a6ff 100%); --shadow-focus: 0 0 0 3px rgba(79, 209, 197, 0.2);
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(34, 166, 255, 0.18), transparent 62%), --gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.14), transparent 58%), --gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.15), transparent 60%),
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%); radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.1), transparent 50%),
linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
} }
[data-theme='dark'] { [data-theme='dark'] {
--color-bg: #071126; --color-bg: #0f172a;
--color-surface: #0c1730; --color-surface: #1e293b;
--color-surface-2: #111f3c; --color-surface-2: #1e293b;
--color-surface-3: #17294c; --color-surface-3: #334155;
--color-border: #1f3764; --color-border: #334155;
--color-border-strong: #31558f; --color-border-strong: #475569;
--color-border-focus: #55a5ff; --color-border-focus: #4fd1c5;
--color-text: #edf5ff; --color-text: #f8fafc;
--color-text-secondary: #b6c8e8; --color-text-secondary: #94a3b8;
--color-text-tertiary: #7f93ba; --color-text-tertiary: #64748b;
--color-brand: #55a5ff; --color-brand: #4fd1c5;
--color-brand-hover: #7bbaff; --color-brand-hover: #38b2ac;
--color-brand-soft: #10284f; --color-brand-soft: #134e4a;
--color-brand-soft-2: #17396c; --color-brand-soft-2: #115e59;
--color-info: #72b7ff; --color-info: #60a5fa;
--color-info-soft: #10284f; --color-info-soft: #1e3a8a;
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 54%, #55c2ff 100%); --gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(85, 165, 255, 0.18), transparent 62%), --gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.18), transparent 62%),
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.16), transparent 58%), radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.16), transparent 58%),
linear-gradient(180deg, #071126 0%, #0c1730 100%); linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
} }

View File

@ -1,11 +1,31 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig(({ command }) => ({ // 默认走 Go 后端 :4001要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000
// 本地开发走根路径,生产构建部署到 /aura 子路径 export default defineConfig(({ command, mode }) => {
base: command === 'serve' ? '/' : '/aura/', const env = loadEnv(mode, process.cwd(), '');
plugins: [react()], const target = env.VITE_API_TARGET || 'https://tianchaoai.cc';
server: {
port: 3001 return {
} // 本地开发走根路径,生产构建部署到 /aura 子路径
})); base: command === 'serve' ? '/' : '/aura/',
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api/v1'),
// SSE 不要被压缩;保持长连接
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Accept-Encoding', 'identity');
});
}
}
}
}
};
});