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

View File

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

View File

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

View File

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

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

View File

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

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 ? (
<Empty description="暂无" />
) : (
<div>
<div className="stats-page-agent-list">
{data.topAgents.map((agent, index) => (
<div key={agent.id} className="stats-page-agent-item">
<div className="stats-page-agent-header">

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

View File

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

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

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

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

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

View File

@ -1,16 +1,21 @@
import { useState } from 'react';
import { App as AntApp, Empty } from 'antd';
import type { ChatPageLogicOutput } from '../ChatPageLogic';
import { markdownToPlainText } from '../utils/copy';
import type { ModelOverrides } from '../../../api';
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
import AgentSidebar from './AgentSidebar';
import ChatBody from './ChatBody';
import ChatDrawers from './ChatDrawers';
import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput';
import ChatOutline from './ChatOutline';
import '../styles/chat-page-web.css';
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp();
const viewport = useDesktopViewport();
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
const {
id,
@ -39,7 +44,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
} = logic;
return (
<div className="chat-shell">
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
<AgentSidebar
agentList={agentList}
activeAgentId={id}
@ -49,11 +54,12 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
<section className="chat-main">
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="chat-empty-state">
<Empty description="请在左侧选择一个智能体开始对话" />
</div>
) : (
<>
<div className="chat-content-layout">
<div className="chat-conversation-panel">
<ChatHeader
agent={agent}
useStream={sender.useStream}
@ -65,7 +71,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
onClear={sender.handleClear}
/>
<div className="chat-content-row">
<ChatBody
bodyRef={bodyRef}
agent={agent}
@ -86,17 +91,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
}}
/>
<ChatOutline
messages={messages}
activeId={highlightId}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
/>
</div>
<ChatInput
input={sender.input}
setInput={sender.setInput}
@ -124,7 +118,20 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={handleNewSession}
/>
</>
</div>
<ChatOutline
messages={messages}
activeId={highlightId}
collapsed={outlineCollapsed}
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
/>
</div>
)}
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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