refactor: batch convert mobile layout from px to rem for better h5 adaptation

main
sp mac bookpro 2605 2026-06-12 22:20:12 +08:00
parent 780ce6edba
commit d3009ef2f8
79 changed files with 4186 additions and 1835 deletions

View File

@ -0,0 +1,8 @@
{
"hash": "42f20ac0",
"configHash": "2e77f0d9",
"lockfileHash": "e3b0c442",
"browserHash": "a53d060b",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "module"
}

43
design-qa.md Normal file
View File

@ -0,0 +1,43 @@
# Design QA
Date: 2026-06-12
## Source Visual Truth
- VI reference image: `/Users/cheyne/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/klji8o7ki_9a9c/temp/RWTemp/2026-06/e8e4c8be4c09458aeb31c70b9a1f45ba/f95f6b7b7b37a8ebeb3ef7b99d60e90f.png`
- Target style: Kaiwu blue-white tech UI, deep navy text, blue action color, light cards, clear mobile workflow entry.
## Screenshot Evidence
- Desktop login: `artifacts/mobile-qa/desktop-login.png`
- Desktop chat: `artifacts/mobile-qa/desktop-chat.png`
- Desktop stats: `artifacts/mobile-qa/desktop-stats.png`
- Mobile login: `artifacts/mobile-qa/mobile-login.png`
- Mobile chat: `artifacts/mobile-qa/mobile-chat.png`
- Mobile stats: `artifacts/mobile-qa/mobile-stats.png`
- Mobile workflows: `artifacts/mobile-qa/mobile-workflows.png`
- Mobile prompt library: `artifacts/mobile-qa/mobile-prompts.png`
- Mobile agent init modal fixed: `artifacts/mobile-qa/mobile-agentsnew-fixed.png`
## Viewports And States
- Desktop: 1440px wide.
- Mobile: 390 x 844.
- Authenticated local mock state used for `/agents/new` visual verification.
## Checks
- `npx tsc -b --pretty false`: passed.
- `npm run build`: passed.
- Inline style scan on changed H5/web target scope: passed.
- Target file line-count scan: highest changed target file is 248 lines.
- Mobile `/agents/new` DOM check: `scrollWidth=390`, `hasHorizontalOverflow=false`, modal width 374, hero grid one column, avatar grid three columns, no overflow offenders.
## Findings
- Initial `/agents/new` mobile modal used a desktop two-column modal layout and six-column avatar grid, causing the confirm button to overflow.
- Fixed with scoped H5 modal overrides and by moving AgentEditor modal/capability/panel styles out of inline style into CSS classes.
## Final Result
passed

View File

@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" href="/favicon.png" />
<title>驷马智能</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>鲸域AI</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Button, Drawer, Spin } from 'antd'; import { Button, Drawer, Spin } from 'antd';
import { MenuOutlined, SearchOutlined } from '@ant-design/icons'; import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
@ -14,9 +14,11 @@ import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage'; import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage'; import StatsPage from './pages/StatsPage';
import SharedSessionPage from './pages/SharedSessionPage'; import SharedSessionPage from './pages/SharedSessionPage';
import WorkflowsPage from './pages/WorkflowsPage'; import WorkflowsPage from './pages/WorkflowsPage';
import { useAuth } from './store/auth'; import { useAuth } from './store/auth';
import { AgentAPI } from './api'; import { AgentAPI } from './api';
import { useIsMobile } from './hooks/useIsMobile';
import kaiwuIcon from './assets/brand/kaiwu-icon-gradient-transparent.png';
function HomeRedirect() { function HomeRedirect() {
return <Navigate to="/chat" replace />; return <Navigate to="/chat" replace />;
@ -24,10 +26,10 @@ function HomeRedirect() {
export default function App() { export default function App() {
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation(); const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [isMobile, setIsMobile] = useState(() => (typeof window === 'undefined' ? false : window.innerWidth < 768)); const isMobile = useIsMobile();
// 全局快捷键 Ctrl/⌘ + K // 全局快捷键 Ctrl/⌘ + K
useEffect(() => { useEffect(() => {
@ -42,13 +44,7 @@ export default function App() {
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [user]); }, [user]);
useEffect(() => { const mainContent = (
const onResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const mainContent = (
<Routes> <Routes>
<Route path="/" element={<HomeRedirect />} /> <Route path="/" element={<HomeRedirect />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
@ -89,7 +85,8 @@ export default function App() {
{isMobile && ( {isMobile && (
<div className="mobile-topbar"> <div className="mobile-topbar">
<Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} /> <Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} />
<div className="mobile-topbar-title">Agent Studio</div> <img src={kaiwuIcon} alt="鲸域AI" className="mobile-topbar-logo" />
<div className="mobile-topbar-title">AI</div>
<Button type="text" icon={<SearchOutlined />} onClick={() => setPaletteOpen(true)} /> <Button type="text" icon={<SearchOutlined />} onClick={() => setPaletteOpen(true)} />
</div> </div>
)} )}
@ -103,7 +100,7 @@ export default function App() {
open={mobileNavOpen} open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)} onClose={() => setMobileNavOpen(false)}
width={280} width={280}
styles={{ body: { padding: 0 } }} className="mobile-nav-drawer"
> >
<Sidebar onOpenPalette={() => setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} /> <Sidebar onOpenPalette={() => setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} />
</Drawer> </Drawer>

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2200 900">
<rect width="2200" height="900" fill="none"/>
<g transform="translate(150 150) scale(0.55)">
<defs>
<linearGradient id="kwLeft" x1="150" y1="150" x2="520" y2="860" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1677FF"/>
<stop offset="0.55" stop-color="#0B49C9"/>
<stop offset="1" stop-color="#0A1F5C"/>
</linearGradient>
<linearGradient id="kwRight" x1="500" y1="120" x2="875" y2="860" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#18B7FF"/>
<stop offset="0.55" stop-color="#1677FF"/>
<stop offset="1" stop-color="#0B55D9"/>
</linearGradient>
<linearGradient id="kwLight" x1="512" y1="675" x2="512" y2="805" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#DDF2FF" stop-opacity="0.95"/>
<stop offset="1" stop-color="#95CEFF" stop-opacity="0.10"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="16" stdDeviation="18" flood-color="#0A1F5C" flood-opacity="0.16"/>
</filter>
</defs>
<g>
<path d="M 176 158
C 176 132 204 116 227 129
L 512 315
L 430 377
C 408 394 397 420 397 448
L 397 652
C 397 680 408 706 430 723
L 227 895
C 204 910 176 894 176 866
Z" fill="url(#kwLeft)"/>
<path d="M 848 158
C 848 132 820 116 797 129
L 512 315
L 594 377
C 616 394 627 420 627 448
L 627 652
C 627 680 616 706 594 723
L 797 895
C 820 910 848 894 848 866
Z" fill="url(#kwRight)"/>
<path d="M430 723 L512 775 L594 723 L512 694 Z" fill="url(#kwLight)" opacity="0.9"/>
</g>
</g>
<text x="820" y="430" font-family="Noto Sans CJK SC, Source Han Sans SC, Microsoft YaHei, PingFang SC, sans-serif" font-weight="700" font-size="250" fill="#071B35" letter-spacing="18">开物</text>
<line x1="820" y1="590" x2="960" y2="590" stroke="#64748B" stroke-width="5" opacity="0.7"/>
<text x="1030" y="620" font-family="Noto Sans CJK SC, Source Han Sans SC, Microsoft YaHei, PingFang SC, sans-serif" font-weight="500" font-size="60" fill="#071B35" letter-spacing="18">AI 商 业 系 统</text>
<line x1="1520" y1="590" x2="1660" y2="590" stroke="#64748B" stroke-width="5" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -15,6 +15,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuth } from '../store/auth'; import { useAuth } from '../store/auth';
import { useTheme } from '../main'; import { useTheme } from '../main';
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
interface Props { interface Props {
onOpenPalette?: () => void; onOpenPalette?: () => void;
@ -67,9 +68,9 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="brand"> <div className="brand">
<span className="brand-mark">A</span> <img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
<span>Agent Studio</span> <span>AI</span>
<div style={{ flex: 1 }} /> <div className="sidebar-brand-spacer" />
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}> <Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
<button className="theme-toggle" onClick={toggle} aria-label="切换主题"> <button className="theme-toggle" onClick={toggle} aria-label="切换主题">
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />} {mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
@ -82,22 +83,16 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
onOpenPalette?.(); onOpenPalette?.();
onNavigate?.(); onNavigate?.();
}} }}
className="nav-item" className="nav-item sidebar-search-action"
style={{
cursor: 'pointer',
justifyContent: 'space-between',
background: 'var(--color-surface-2)',
marginBottom: 8
}}
> >
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <span className="sidebar-search-label">
<SearchOutlined className="nav-icon" /> <SearchOutlined className="nav-icon" />
<span></span> <span></span>
</span> </span>
<span className="kbd">{cmdKey} K</span> <span className="kbd">{cmdKey} K</span>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', marginRight: -4, paddingRight: 4 }}> <div className="sidebar-scroll">
{NAV_GROUPS.map((group) => ( {NAV_GROUPS.map((group) => (
<div key={group.label}> <div key={group.label}>
<div className="nav-section-label">{group.label}</div> <div className="nav-section-label">{group.label}</div>
@ -123,7 +118,7 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
items: [ items: [
{ {
key: 'name', key: 'name',
label: <span style={{ color: 'var(--color-text-tertiary)' }}>{user.email}</span>, label: <span className="sidebar-user-role">{user.email}</span>,
disabled: true disabled: true
}, },
{ type: 'divider' }, { type: 'divider' },
@ -147,28 +142,15 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
}} }}
placement="topLeft" placement="topLeft"
> >
<div className="sidebar-user" style={{ marginTop: 8 }}> <div className="sidebar-user">
<Avatar <Avatar size={32} className="sidebar-user-avatar">
size={32}
style={{ background: 'var(--gradient-brand)', flexShrink: 0 }}
>
{(user.name?.charAt(0) || '?').toUpperCase()} {(user.name?.charAt(0) || '?').toUpperCase()}
</Avatar> </Avatar>
<div style={{ flex: 1, minWidth: 0 }}> <div className="sidebar-user-main">
<div <div className="sidebar-user-name">
style={{
fontSize: 13,
color: 'var(--color-text)',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.005em'
}}
>
{user.name} {user.name}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}> <div className="sidebar-user-role">
{user.role === 'admin' ? '管理员' : '成员'} {user.role === 'admin' ? '管理员' : '成员'}
</div> </div>
</div> </div>

21
src/hooks/useIsMobile.ts Normal file
View File

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
const DEFAULT_MOBILE_BREAKPOINT = 768;
const getIsMobile = (breakpoint = DEFAULT_MOBILE_BREAKPOINT) => {
if (typeof window === 'undefined') return false;
return window.innerWidth < breakpoint;
};
export function useIsMobile(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(() => getIsMobile(breakpoint));
useEffect(() => {
const handleResize = () => setIsMobile(getIsMobile(breakpoint));
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
}

View File

@ -5,6 +5,8 @@ import zhCN from 'antd/locale/zh_CN';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
import './styles/aura-theme.css';
import './styles/app-shell-h5.css';
type ThemeMode = 'light' | 'dark'; type ThemeMode = 'light' | 'dark';
@ -40,17 +42,17 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
() => ({ () => ({
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { token: {
colorPrimary: isDark ? '#e07b3e' : '#c2541f', colorPrimary: isDark ? '#55a5ff' : '#1167ff',
colorInfo: isDark ? '#e07b3e' : '#c2541f', colorInfo: isDark ? '#72b7ff' : '#1e86ff',
colorBgBase: isDark ? '#1a1816' : '#faf9f5', colorBgBase: isDark ? '#071126' : '#f5f9ff',
colorBgContainer: isDark ? '#221f1c' : '#ffffff', colorBgContainer: isDark ? '#0c1730' : '#ffffff',
colorBgElevated: isDark ? '#2b2824' : '#ffffff', colorBgElevated: isDark ? '#111f3c' : '#ffffff',
colorBgLayout: isDark ? '#1a1816' : '#faf9f5', colorBgLayout: isDark ? '#071126' : '#f5f9ff',
colorBorder: isDark ? '#36322c' : '#ebe7da', colorBorder: isDark ? '#1f3764' : '#d6e5fb',
colorBorderSecondary: isDark ? '#2b2824' : '#f0ece0', colorBorderSecondary: isDark ? '#17294c' : '#e3efff',
colorText: isDark ? '#f3efe6' : '#2a2622', colorText: isDark ? '#edf5ff' : '#06143f',
colorTextSecondary: isDark ? '#b6afa3' : '#6b6660', colorTextSecondary: isDark ? '#b6c8e8' : '#405784',
colorTextTertiary: isDark ? '#7e7869' : '#a09a8e', colorTextTertiary: isDark ? '#7f93ba' : '#7c8caf',
colorSuccess: isDark ? '#7fb87d' : '#4f8a4d', colorSuccess: isDark ? '#7fb87d' : '#4f8a4d',
colorWarning: isDark ? '#d49a4a' : '#b8782a', colorWarning: isDark ? '#d49a4a' : '#b8782a',
colorError: isDark ? '#e07060' : '#c0392b', colorError: isDark ? '#e07060' : '#c0392b',

View File

@ -1,6 +1,6 @@
import { Modal, Button, Upload } from 'antd'; import { Modal, Button, Upload } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import { Agent, AgentAPI } from '../../../api'; import { Agent } from '../../../api';
import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants'; import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants';
interface AvatarSelectorProps { interface AvatarSelectorProps {
@ -21,43 +21,53 @@ export default function AvatarSelector({
onAvatarSelect, onAvatarSelect,
}: AvatarSelectorProps) { }: AvatarSelectorProps) {
return ( return (
<Modal title="选择头像形象" open={open} onCancel={onCancel} footer={null} width={600} centered> <Modal
<div className="py-4"> title="选择头像形象"
<div className="flex items-center justify-between gap-3 mb-4"> open={open}
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest"> onCancel={onCancel}
footer={null}
</div> width={600}
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadEditAvatar}> centered
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}> className="agent-editor-avatar-modal"
>
<div className="agent-editor-avatar-modal-content">
<div className="agent-editor-avatar-modal-toolbar">
<div className="agent-editor-avatar-heading"></div>
<Upload
accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
beforeUpload={beforeUploadEditAvatar}
>
<Button icon={<UploadOutlined />} loading={avatarUploading} className="agent-editor-upload-button">
</Button> </Button>
</Upload> </Upload>
</div> </div>
<div className="bg-gray-50 p-4 rounded-2xl max-h-[400px] overflow-y-auto monica-scrollbar" style={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px' }}> <div className="agent-editor-avatar-grid agent-editor-avatar-grid-panel monica-scrollbar">
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
<div const isSelected = agent?.avatar === url;
key={url}
onClick={async () => { return (
if (!agent?.id) return; <button
await onAvatarSelect(url); type="button"
onCancel(); key={url}
}} onClick={async () => {
className={`relative rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 mx-auto ${agent?.avatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`} if (!agent?.id) return;
style={{ width: 80, height: 80, minWidth: 80, borderColor: agent?.avatar === url ? 'var(--color-brand)' : 'transparent' }} await onAvatarSelect(url);
> onCancel();
<img src={url} className="w-full h-full object-cover" alt="preset" /> }}
{agent?.avatar === url && ( className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
<div aria-label="选择头像形象"
className="absolute inset-0 flex items-center justify-center" >
style={{ background: 'rgba(8, 145, 178, 0.10)' }} <img src={url} className="agent-editor-avatar-image" alt="preset" />
> {isSelected && (
<div className="bg-white rounded-full p-0.5 shadow-sm"> <span className="agent-editor-avatar-selected">
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} /> <span className="agent-editor-avatar-dot" />
</div> </span>
</div> )}
)} </button>
</div> );
))} })}
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -1,8 +1,10 @@
import { Form, Input, Select, Collapse, Button, List, Popconfirm, Tag, Switch, InputNumber } from 'antd'; import { Form } from 'antd';
import { DatabaseOutlined, RocketOutlined, SettingOutlined, ToolOutlined } from '@ant-design/icons'; import { Agent, Team } from '../../../api';
import { Agent, Team, AgentAPI } from '../../../api'; import AgentIdentityCard from './capability/AgentIdentityCard';
import { STATUS_TAG, isImageUrl, parseModelSelections } from '../constants'; import BasicSettingsPanel from './capability/BasicSettingsPanel';
import ModelCheckboxDropdown from './ModelCheckboxDropdown'; import KnowledgeSettingsPanel from './capability/KnowledgeSettingsPanel';
import ModelSettingsCard from './capability/ModelSettingsCard';
import WebSearchCard from './capability/WebSearchCard';
interface CapabilitySettingsProps { interface CapabilitySettingsProps {
form: any; form: any;
@ -41,258 +43,25 @@ export default function CapabilitySettings({
</p> </p>
</div> </div>
<span className="agent-editor-badge"> <span className="agent-editor-badge">Capability</span>
<ToolOutlined />
Capability
</span>
</div> </div>
<Form form={form} layout="vertical" onValuesChange={markDirty}> <Form form={form} layout="vertical" onValuesChange={markDirty}>
<div className="agent-editor-intro" style={{ marginBottom: 18 }}> <AgentIdentityCard
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> agent={agent}
<div currentName={currentName}
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto" agentName={agentName}
style={{ selectedAvatar={selectedAvatar}
width: 88, setAvatarSelectorOpen={setAvatarSelectorOpen}
height: 88,
background: isImageUrl(agent?.avatar) ? '#f3f4f6' : agent?.avatar,
border: '3px solid rgba(255,255,255,0.92)',
}}
onClick={() => setAvatarSelectorOpen(true)}
>
{isImageUrl(agent?.avatar) ? (
<img src={agent?.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="avatar" />
) : (
(agent?.name?.charAt(0) || agentName?.charAt(0) || '?').toUpperCase()
)}
<div className="avatar-overlay">
<span className="avatar-overlay-text"></span>
</div>
</div>
<div style={{ minWidth: 0 }}>
{currentName && (
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
{currentName}
</div>
)}
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{agent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}
</div>
<Button size="small" type="default" className="rounded-lg h-8" onClick={() => setAvatarSelectorOpen(true)}>
</Button>
</div>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse-bordered mb-4"
defaultActiveKey={['basic']}
items={[
{
key: 'basic',
label: (
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
</div>
),
children: (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-2">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
</div>
</div>
<Form.Item name="name" label="名称" rules={[{ required: true }]} className="mb-3">
<Input placeholder="智能体名称" style={{ borderRadius: 12, height: 42 }} />
</Form.Item>
<Form.Item name="description" label="描述" className="mb-3">
<Input.TextArea rows={3} placeholder="简短描述这个智能体是做什么的..." style={{ borderRadius: 12 }} />
</Form.Item>
<div className="flex gap-2">
<Form.Item name="visibility" label="可见性" className="flex-1 mb-0">
<Select
size="middle"
style={{ minHeight: 42 }}
options={[
{ value: 'private', label: '🔒 私有' },
{ value: 'team', label: '👥 团队' },
{ value: 'public', label: '🌐 公开' },
]}
/>
</Form.Item>
<Form.Item name="teamId" label="归属团队" className="flex-1 mb-0" tooltip="团队功能暂未开放">
<Select
size="middle"
placeholder="暂不开放"
disabled
allowClear
style={{ minHeight: 42 }}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</div>
</div>
),
},
]}
/> />
<BasicSettingsPanel teams={teams} />
<div className="monica-card !mb-4" style={{ borderRadius: 18 }}> <ModelSettingsCard models={models} />
<div className="flex items-center gap-2 mb-4 font-medium text-gray-700"> <KnowledgeSettingsPanel
<SettingOutlined style={{ color: 'var(--color-brand)' }} /> agent={agent}
beforeUploadKnowledge={beforeUploadKnowledge}
</div> onDeleteKnowledge={onDeleteKnowledge}
<div className="space-y-4">
<Form.Item
name="model"
label="模型"
required
rules={[
{
validator: async (_rule, value) => {
const selected = parseModelSelections(value);
if (selected.length > 0) {
return;
}
throw new Error('请选择至少一个模型');
},
},
]}
className="mb-0"
getValueProps={(value) => ({ value: parseModelSelections(value) })}
>
<ModelCheckboxDropdown models={models} />
</Form.Item>
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
<div className="flex items-center gap-4">
<InputNumber min={0} max={2} step={0.1} className="w-20" style={{ borderRadius: 12, height: 42 }} />
<span className="text-[11px] text-gray-400"></span>
</div>
</Form.Item>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse"
items={[
{
key: 'knowledge',
label: (
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<DatabaseOutlined style={{ color: 'var(--color-brand)' }} />
({agent?.knowledge?.length ?? 0})
</div>
),
children: (
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Input
type="file"
multiple
style={{ display: 'none' }}
id="knowledge-upload"
onChange={async (e) => {
const files = e.target.files;
if (files) {
for (let i = 0; i < files.length; i++) {
await beforeUploadKnowledge(files[i]);
}
}
}}
/>
<Button
type="primary"
size="small"
ghost
icon={<DatabaseOutlined />}
style={{ borderRadius: 10 }}
onClick={() => document.getElementById('knowledge-upload')?.click()}
>
</Button>
</div>
<List
size="small"
dataSource={agent?.knowledge ?? []}
renderItem={(item) => (
<List.Item
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
actions={[
<Popconfirm
key="del"
title="确认删除?"
onConfirm={() => onDeleteKnowledge(item.id)}
>
<Button type="text" danger size="small" style={{ borderRadius: 8 }}>
</Button>
</Popconfirm>,
]}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium truncate">{item.originalName}</span>
<span className="text-[10px] text-gray-400">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''} ·{' '}
{item.status === 'indexing' ? (
<Tag color="processing" className="m-0 text-[10px] px-1">
<span
className="inline-block"
style={{ animation: 'pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite' }}
>
</span>
</Tag>
) : (
<Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
{STATUS_TAG[(item.status || 'ready')].text}
</Tag>
)}
</span>
</div>
</List.Item>
)}
/>
</div>
),
},
{
key: 'skills',
collapsible: 'disabled',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-400 cursor-not-allowed"
title="技能功能开发中"
>
<ToolOutlined />
& ()
</div>
),
children: null,
},
]}
/> />
<WebSearchCard />
<div className="monica-card mt-6" style={{ borderRadius: 18 }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<Form.Item name="webSearchEnabled" valuePropName="checked" className="mb-0">
<Switch size="small" />
</Form.Item>
</div>
<div className="text-[11px] text-gray-400">
DuckDuckGo
</div>
</div>
</Form> </Form>
</div> </div>
</div> </div>

View File

@ -18,23 +18,22 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
type="text" type="text"
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
onClick={() => navigate('/agents')} onClick={() => navigate('/agents')}
className="hover:bg-gray-100" className="agent-editor-icon-action hover:bg-gray-100"
style={{ borderRadius: 12, width: 40, height: 40 }}
/> />
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RocketOutlined style={{ color: 'var(--color-brand)', fontSize: 18 }} /> <RocketOutlined className="agent-editor-header-icon" />
<span className="font-bold text-lg text-gray-800" style={{ color: 'var(--color-text)' }}> <span className="agent-editor-header-title font-bold text-lg text-gray-800">
{isNew ? '创建新智能体' : currentName || ''} {isNew ? '创建新智能体' : currentName || ''}
</span> </span>
</div> </div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginTop: 2 }}> <div className="agent-editor-header-subtitle">
AI AI
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-xs text-gray-400" style={{ color: 'var(--color-text-tertiary)' }}> <span className="agent-editor-save-state text-xs text-gray-400">
{autoSaveStatus === 'saving' {autoSaveStatus === 'saving'
? '正在保存...' ? '正在保存...'
: autoSaveStatus === 'dirty' : autoSaveStatus === 'dirty'
@ -43,7 +42,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
? '保存失败' ? '保存失败'
: '已保存'} : '已保存'}
</span> </span>
<Button icon={<FileTextOutlined />} style={{ borderRadius: 12, height: 40 }}> <Button icon={<FileTextOutlined />} className="agent-editor-header-action">
</Button> </Button>
<Button <Button
@ -51,7 +50,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
icon={<SaveOutlined />} icon={<SaveOutlined />}
loading={saving} loading={saving}
onClick={onSave} onClick={onSave}
style={{ borderRadius: 12, height: 40, paddingInline: 16, fontWeight: 600 }} className="agent-editor-save-action"
> >
</Button> </Button>

View File

@ -15,6 +15,8 @@ interface InitModalProps {
beforeUploadInitAvatar: (file: any) => Promise<boolean>; beforeUploadInitAvatar: (file: any) => Promise<boolean>;
} }
const PROFILE_SUGGESTIONS = ['客服助理', '内容创作', '数据分析', '私人教练'];
export default function InitModal({ export default function InitModal({
open, open,
onCancel, onCancel,
@ -28,6 +30,7 @@ export default function InitModal({
beforeUploadInitAvatar, beforeUploadInitAvatar,
}: InitModalProps) { }: InitModalProps) {
const [initForm] = Form.useForm(); const [initForm] = Form.useForm();
const selectedName = agentName || '你的新智能体';
return ( return (
<Modal <Modal
@ -39,61 +42,34 @@ export default function InitModal({
centered centered
maskClosable={false} maskClosable={false}
destroyOnHidden destroyOnHidden
className="agent-editor-init-modal"
> >
<div className="py-2"> <div className="agent-editor-init-content">
<div style={{ marginBottom: 18 }}> <div className="agent-editor-init-header">
<div <div className="agent-editor-init-badge">
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.08)',
color: 'var(--color-brand)',
fontSize: 12,
fontWeight: 600,
marginBottom: 14,
}}
>
<RocketOutlined /> <RocketOutlined />
· ·
</div> </div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}> <div className="agent-editor-init-title">
</div> </div>
<div style={{ fontSize: 14.5, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}> <div className="agent-editor-init-subtitle">
</div> </div>
</div> </div>
<div className="agent-editor-modal-hero"> <div className="agent-editor-modal-hero">
<div <div className="agent-editor-modal-card agent-editor-preview-card">
className="agent-editor-modal-card" <div className="agent-editor-avatar-preview">
style={{
padding: '22px 20px',
background: 'linear-gradient(180deg, rgba(236,253,245,0.96) 0%, rgba(240,249,255,0.96) 100%)',
}}
>
<div
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto"
style={{
width: 88,
height: 88,
background: isImageUrl(selectedAvatar) ? '#f3f4f6' : selectedAvatar,
}}
>
{isImageUrl(selectedAvatar) ? ( {isImageUrl(selectedAvatar) ? (
<img src={selectedAvatar} className="w-full h-full object-cover" alt="avatar" /> <img src={selectedAvatar} className="agent-editor-avatar-image" alt="avatar" />
) : ( ) : (
(agentName?.charAt(0) || '?').toUpperCase() selectedName.charAt(0).toUpperCase()
)} )}
</div> </div>
<div style={{ textAlign: 'center', marginTop: 16 }}> <div className="agent-editor-preview-text">
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}> <div className="agent-editor-preview-name">{selectedName}</div>
{agentName || '你的新智能体'} <div className="agent-editor-preview-caption">
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
</div> </div>
</div> </div>
@ -106,81 +82,54 @@ export default function InitModal({
onValuesChange={(changed) => { onValuesChange={(changed) => {
if (changed.name !== undefined) setAgentName(changed.name); if (changed.name !== undefined) setAgentName(changed.name);
}} }}
className="agent-editor-modal-card" className="agent-editor-modal-card agent-editor-init-form"
style={{ padding: 20 }}
> >
<Form.Item <Form.Item
name="name" name="name"
label={ label={<span className="agent-editor-form-label"></span>}
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
</span>
}
rules={[{ required: true, message: '请输入智能体名称' }]} rules={[{ required: true, message: '请输入智能体名称' }]}
> >
<Input <Input
placeholder="给你的智能体起个名字" placeholder="给你的智能体起个名字"
size="large" size="large"
autoFocus autoFocus
className="rounded-xl h-12 border-gray-200 focus:border-cyan-500" className="agent-editor-form-input"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="description" name="description"
label={ label={<span className="agent-editor-form-label"></span>}
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
</span>
}
> >
<Input.TextArea <Input.TextArea
placeholder="介绍一下这个智能体是做什么的..." placeholder="介绍一下这个智能体是做什么的..."
rows={3} rows={3}
className="rounded-xl border-gray-200 focus:border-cyan-500" className="agent-editor-form-textarea"
/> />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}> <div className="agent-editor-suggestion-list">
{['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => ( {PROFILE_SUGGESTIONS.map((label) => (
<span <button
type="button"
key={label} key={label}
onClick={() => initForm.setFieldsValue({ description: label })} onClick={() => initForm.setFieldsValue({ description: label })}
style={{ className="agent-editor-suggestion-chip"
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface-2)',
color: 'var(--color-text-secondary)',
fontSize: 12.5,
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-border)';
e.currentTarget.style.color = 'var(--color-text)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-surface-2)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
> >
{label} {label}
</span> </button>
))} ))}
</div> </div>
<div className="flex gap-4 pt-2"> <div className="agent-editor-init-actions">
<Button <Button onClick={onCancel} className="agent-editor-secondary-action">
onClick={onCancel}
className="flex-1 h-12 rounded-xl border-gray-200 text-gray-500 font-semibold hover:bg-gray-50"
>
</Button> </Button>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={saving} loading={saving}
className="flex-1 h-12 rounded-xl border-none font-semibold" className="agent-editor-primary-action"
> >
</Button> </Button>
@ -188,41 +137,43 @@ export default function InitModal({
</Form> </Form>
</div> </div>
<div> <div className="agent-editor-avatar-section">
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-3 px-1"> <div className="agent-editor-avatar-heading"></div>
<div className="agent-editor-avatar-toolbar">
</div> <div className="agent-editor-avatar-hint">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
使 使
</div> </div>
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadInitAvatar}> <Upload
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}> accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
beforeUpload={beforeUploadInitAvatar}
>
<Button icon={<UploadOutlined />} loading={avatarUploading} className="agent-editor-upload-button">
</Button> </Button>
</Upload> </Upload>
</div> </div>
<div className="agent-editor-avatar-grid monica-scrollbar" style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px' }}> <div className="agent-editor-avatar-grid monica-scrollbar">
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
<div const isSelected = selectedAvatar === url;
key={url}
onClick={() => setSelectedAvatar(url)} return (
className={`relative rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 mx-auto ${selectedAvatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`} <button
style={{ width: 80, height: 80, minWidth: 80, borderColor: selectedAvatar === url ? 'var(--color-brand)' : 'transparent' }} type="button"
> key={url}
<img src={url} className="w-full h-full object-cover" alt="preset" /> onClick={() => setSelectedAvatar(url)}
{selectedAvatar === url && ( className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
<div aria-label="选择智能体头像"
className="absolute inset-0 flex items-center justify-center" >
style={{ background: 'rgba(8, 145, 178, 0.10)' }} <img src={url} className="agent-editor-avatar-image" alt="preset" />
> {isSelected && (
<div className="bg-white rounded-full p-0.5 shadow-sm"> <span className="agent-editor-avatar-selected">
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} /> <span className="agent-editor-avatar-dot" />
</div> </span>
</div> )}
)} </button>
</div> );
))} })}
</div> </div>
</div> </div>
</div> </div>

View File

@ -23,13 +23,13 @@ export default function PreviewPane({ liveAgent, agentId }: PreviewPaneProps) {
Live Preview Live Preview
</span> </span>
</div> </div>
<div className="agent-editor-intro" style={{ marginBottom: 14 }}> <div className="agent-editor-intro agent-editor-preview-intro">
<div className="agent-editor-intro-title"></div> <div className="agent-editor-intro-title"></div>
<div className="agent-editor-intro-text"> <div className="agent-editor-intro-text">
{liveAgent?.name || '未命名智能体'} Prompt {liveAgent?.name || '未命名智能体'} Prompt
</div> </div>
</div> </div>
<div className="agent-editor-surface" style={{ flex: 1, overflow: 'hidden' }}> <div className="agent-editor-surface agent-editor-preview-surface">
<ChatPreview agent={liveAgent} agentId={agentId} /> <ChatPreview agent={liveAgent} agentId={agentId} />
</div> </div>
</div> </div>

View File

@ -36,7 +36,7 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
onValuesChange={markDirty} onValuesChange={markDirty}
> >
<div className="agent-editor-surface agent-editor-prompt-wrap"> <div className="agent-editor-surface agent-editor-prompt-wrap">
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10, paddingInline: 4 }}> <div className="agent-editor-prompt-hint">
</div> </div>
<div className="agent-editor-prompt"> <div className="agent-editor-prompt">
@ -44,13 +44,12 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
<Input.TextArea <Input.TextArea
rows={30} rows={30}
placeholder="在这里输入智能体的人设、技能、风格和输出规范..." placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
className="border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed" className="agent-editor-prompt-textarea border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
style={{ height: 'calc(100vh - 290px)', resize: 'none', borderRadius: 16 }}
/> />
</Form.Item> </Form.Item>
</div> </div>
</div> </div>
<div className="text-[11px] text-gray-400 mt-2 px-2" style={{ color: 'var(--color-text-tertiary)' }}> <div className="agent-editor-prompt-footer text-[11px] text-gray-400 mt-2 px-2">
</div> </div>
</Form> </Form>

View File

@ -0,0 +1,53 @@
import { Button } from 'antd';
import { Agent } from '../../../../api';
import { isImageUrl } from '../../constants';
interface AgentIdentityCardProps {
agent: Agent | null;
currentName?: string;
agentName: string;
selectedAvatar: string;
setAvatarSelectorOpen: (open: boolean) => void;
}
export default function AgentIdentityCard({
agent,
currentName,
agentName,
selectedAvatar,
setAvatarSelectorOpen,
}: AgentIdentityCardProps) {
const avatar = agent?.avatar || selectedAvatar;
const displayName = agent?.name || agentName || '?';
return (
<div className="agent-editor-intro agent-editor-identity-card">
<div className="agent-editor-identity-layout">
<button
type="button"
className="agent-editor-identity-avatar"
onClick={() => setAvatarSelectorOpen(true)}
aria-label="更换头像"
>
{isImageUrl(avatar) ? (
<img src={avatar} className="agent-editor-avatar-image" alt="avatar" />
) : (
displayName.charAt(0).toUpperCase()
)}
<span className="avatar-overlay">
<span className="avatar-overlay-text"></span>
</span>
</button>
<div className="agent-editor-identity-meta">
{currentName && <div className="agent-editor-identity-name">{currentName}</div>}
<div className="agent-editor-identity-description">
{agent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}
</div>
<Button size="small" type="default" className="agent-editor-small-action" onClick={() => setAvatarSelectorOpen(true)}>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { Form, Input, Select, Collapse } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { Team } from '../../../../api';
interface BasicSettingsPanelProps {
teams: Team[];
}
export default function BasicSettingsPanel({ teams }: BasicSettingsPanelProps) {
return (
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse-bordered mb-4"
defaultActiveKey={['basic']}
items={[
{
key: 'basic',
label: (
<div className="agent-editor-collapse-label">
<SettingOutlined className="agent-editor-label-icon" />
</div>
),
children: (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-2">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
</div>
</div>
<Form.Item name="name" label="名称" rules={[{ required: true }]} className="mb-3">
<Input placeholder="智能体名称" className="agent-editor-field-input" />
</Form.Item>
<Form.Item name="description" label="描述" className="mb-3">
<Input.TextArea rows={3} placeholder="简短描述这个智能体是做什么的..." className="agent-editor-field-textarea" />
</Form.Item>
<div className="flex gap-2">
<Form.Item name="visibility" label="可见性" className="flex-1 mb-0">
<Select
size="middle"
className="agent-editor-field-select"
options={[
{ value: 'private', label: '🔒 私有' },
{ value: 'team', label: '👥 团队' },
{ value: 'public', label: '🌐 公开' },
]}
/>
</Form.Item>
<Form.Item name="teamId" label="归属团队" className="flex-1 mb-0" tooltip="团队功能暂未开放">
<Select
size="middle"
placeholder="暂不开放"
disabled
allowClear
className="agent-editor-field-select"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</div>
</div>
),
},
]}
/>
);
}

View File

@ -0,0 +1,108 @@
import { Button, Collapse, Form, Input, List, Popconfirm, Tag } from 'antd';
import { DatabaseOutlined, ToolOutlined } from '@ant-design/icons';
import { Agent } from '../../../../api';
import { STATUS_TAG } from '../../constants';
interface KnowledgeSettingsPanelProps {
agent: Agent | null;
beforeUploadKnowledge: (file: any) => Promise<boolean>;
onDeleteKnowledge: (fileId: string) => Promise<void>;
}
export default function KnowledgeSettingsPanel({
agent,
beforeUploadKnowledge,
onDeleteKnowledge,
}: KnowledgeSettingsPanelProps) {
return (
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse"
items={[
{
key: 'knowledge',
label: (
<div className="agent-editor-collapse-label">
<DatabaseOutlined className="agent-editor-label-icon" />
({agent?.knowledge?.length ?? 0})
</div>
),
children: (
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Input
type="file"
multiple
className="agent-editor-file-input"
id="knowledge-upload"
onChange={async (e) => {
const files = e.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
await beforeUploadKnowledge(files[i]);
}
}}
/>
<Button
type="primary"
size="small"
ghost
icon={<DatabaseOutlined />}
className="agent-editor-small-action"
onClick={() => document.getElementById('knowledge-upload')?.click()}
>
</Button>
</div>
<List
size="small"
dataSource={agent?.knowledge ?? []}
renderItem={(item) => (
<List.Item
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
actions={[
<Popconfirm key="del" title="确认删除?" onConfirm={() => onDeleteKnowledge(item.id)}>
<Button type="text" danger size="small" className="agent-editor-danger-action">
</Button>
</Popconfirm>,
]}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium truncate">{item.originalName}</span>
<span className="text-[10px] text-gray-400">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''} ·{' '}
{item.status === 'indexing' ? (
<Tag color="processing" className="m-0 text-[10px] px-1">
<span className="agent-editor-indexing-label"></span>
</Tag>
) : (
<Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
{STATUS_TAG[(item.status || 'ready')].text}
</Tag>
)}
</span>
</div>
</List.Item>
)}
/>
</div>
),
},
{
key: 'skills',
collapsible: 'disabled',
label: (
<div className="agent-editor-disabled-label" title="技能功能开发中">
<ToolOutlined />
& ()
</div>
),
children: null,
},
]}
/>
);
}

View File

@ -0,0 +1,45 @@
import { Form, InputNumber } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { parseModelSelections } from '../../constants';
import ModelCheckboxDropdown from '../ModelCheckboxDropdown';
interface ModelSettingsCardProps {
models: any[];
}
export default function ModelSettingsCard({ models }: ModelSettingsCardProps) {
return (
<div className="monica-card agent-editor-section-card">
<div className="agent-editor-section-title">
<SettingOutlined className="agent-editor-label-icon" />
</div>
<div className="space-y-4">
<Form.Item
name="model"
label="模型"
required
rules={[
{
validator: async (_rule, value) => {
const selected = parseModelSelections(value);
if (selected.length > 0) return;
throw new Error('请选择至少一个模型');
},
},
]}
className="mb-0"
getValueProps={(value) => ({ value: parseModelSelections(value) })}
>
<ModelCheckboxDropdown models={models} />
</Form.Item>
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
<div className="flex items-center gap-4">
<InputNumber min={0} max={2} step={0.1} className="agent-editor-number-input" />
<span className="text-[11px] text-gray-400"></span>
</div>
</Form.Item>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { Form, Switch } from 'antd';
import { RocketOutlined } from '@ant-design/icons';
export default function WebSearchCard() {
return (
<div className="monica-card agent-editor-section-card agent-editor-web-search-card">
<div className="flex items-center justify-between mb-4">
<div className="agent-editor-section-title">
<RocketOutlined className="agent-editor-label-icon" />
</div>
<Form.Item name="webSearchEnabled" valuePropName="checked" className="mb-0">
<Switch size="small" />
</Form.Item>
</div>
<div className="text-[11px] text-gray-400">
DuckDuckGo
</div>
</div>
);
}

View File

@ -10,6 +10,10 @@ import CapabilitySettings from './components/CapabilitySettings';
import PreviewPane from './components/PreviewPane'; import PreviewPane from './components/PreviewPane';
import InitModal from './components/InitModal'; import InitModal from './components/InitModal';
import AvatarSelector from './components/AvatarSelector'; import AvatarSelector from './components/AvatarSelector';
import './styles/agent-editor-capability.css';
import './styles/agent-editor-panels.css';
import './styles/agent-editor-modal.css';
import './styles/agent-editor-h5.css';
export default function AgentEditor() { export default function AgentEditor() {
const { id } = useParams(); const { id } = useParams();
@ -52,7 +56,7 @@ export default function AgentEditor() {
return ( return (
<> <>
<div className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell" style={{ background: 'var(--color-bg)' }}> <div className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell">
<Header <Header
isNew={isNew} isNew={isNew}
currentName={currentName} currentName={currentName}
@ -62,7 +66,7 @@ export default function AgentEditor() {
onSave={handleSave} onSave={handleSave}
/> />
<div className="flex-1 overflow-hidden agent-editor-workbench" style={{ background: 'var(--color-bg)' }}> <div className="flex-1 overflow-hidden agent-editor-workbench">
<PromptEditor form={form} markDirty={markDirty} /> <PromptEditor form={form} markDirty={markDirty} />
<CapabilitySettings <CapabilitySettings
form={form} form={form}

View File

@ -0,0 +1,115 @@
.agent-editor-identity-card {
margin-bottom: 1.125rem;
}
.agent-editor-identity-layout {
display: flex;
align-items: center;
gap: 0.875rem;
}
.agent-editor-identity-avatar {
position: relative;
display: flex;
width: 5.5rem;
height: 5.5rem;
flex: 0 0 5.5rem;
align-items: center;
justify-content: center;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.92);
border-radius: 999px;
background: linear-gradient(135deg, #0046c8, #12b7ff);
box-shadow: 0 18px 32px rgba(0, 97, 255, 0.2);
color: #fff;
cursor: pointer;
font-size: 1.875rem;
font-weight: 800;
outline: 4px solid #fff;
}
.agent-editor-identity-meta {
min-width: 0;
}
.agent-editor-identity-name {
margin-bottom: 0.25rem;
color: var(--color-text);
font-size: 1rem;
font-weight: 800;
}
.agent-editor-identity-description {
margin-bottom: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.78125rem;
}
.agent-editor-small-action,
.agent-editor-danger-action {
border-radius: 0.625rem;
}
.agent-editor-collapse-label,
.agent-editor-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
font-weight: 700;
}
.agent-editor-disabled-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #99a6ba;
cursor: not-allowed;
font-weight: 700;
}
.agent-editor-label-icon {
color: var(--color-brand);
}
.agent-editor-field-input,
.agent-editor-number-input {
height: 2.625rem;
border-radius: 0.75rem;
}
.agent-editor-field-textarea {
border-radius: 0.75rem;
}
.agent-editor-field-select {
min-height: 2.625rem;
}
.agent-editor-section-card {
margin-bottom: 1rem;
border-radius: 1.125rem;
}
.agent-editor-number-input {
width: 5rem;
}
.agent-editor-file-input {
display: none;
}
.agent-editor-indexing-label {
display: inline-block;
animation: agent-editor-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.agent-editor-web-search-card {
margin-top: 1.5rem;
}
@keyframes agent-editor-pulse {
50% {
opacity: 0.45;
}
}

View File

@ -0,0 +1,60 @@
@media (max-width: 768px) {
.agent-editor-shell {
min-height: 100svh;
overflow: hidden;
}
.agent-editor-header {
height: auto;
min-height: 3.5rem;
padding: 0.625rem 0.75rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.agent-editor-workbench {
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
overflow-y: auto;
}
.agent-editor-pane {
flex: 0 0 auto;
min-height: 28rem;
border-radius: 1.125rem;
}
.agent-editor-pane-body {
padding: 1rem;
}
.agent-editor-pane-header {
flex-direction: column;
gap: 0.75rem;
}
.agent-editor-preview-shell {
min-height: 32rem;
}
.agent-editor-init-modal .agent-editor-modal-hero {
grid-template-columns: minmax(0, 1fr);
}
.agent-editor-init-modal .agent-editor-avatar-grid,
.agent-editor-avatar-modal .agent-editor-avatar-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.agent-editor-avatar-toolbar,
.agent-editor-avatar-modal-toolbar {
align-items: stretch;
flex-direction: column;
}
.agent-editor-init-modal .agent-editor-init-actions {
flex-direction: column;
gap: 0.75rem;
}
}

View File

@ -0,0 +1,248 @@
.agent-editor-init-modal,
.agent-editor-avatar-modal {
max-width: calc(100vw - 1rem);
}
.agent-editor-init-content {
padding: 0.5rem 0;
}
.agent-editor-init-header {
margin-bottom: 1.125rem;
}
.agent-editor-init-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.875rem;
padding: 0.375rem 0.75rem;
border-radius: 999px;
background: rgba(0, 97, 255, 0.08);
color: var(--color-brand);
font-size: 0.75rem;
font-weight: 700;
}
.agent-editor-init-title {
margin-bottom: 0.5rem;
color: var(--color-text);
font-size: 1.75rem;
font-weight: 800;
}
.agent-editor-init-subtitle {
color: var(--color-text-secondary);
font-size: 0.90625rem;
line-height: 1.75;
}
.agent-editor-modal-hero {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
}
.agent-editor-modal-card {
border: 1px solid rgba(150, 177, 217, 0.32);
border-radius: 1.25rem;
background: var(--color-surface);
box-shadow: 0 18px 42px rgba(10, 28, 72, 0.08);
}
.agent-editor-preview-card {
padding: 1.375rem 1.25rem;
background: linear-gradient(180deg, rgba(235, 246, 255, 0.98), rgba(247, 251, 255, 0.98));
}
.agent-editor-avatar-preview {
position: relative;
display: flex;
width: 5.5rem;
height: 5.5rem;
align-items: center;
justify-content: center;
margin: 0 auto;
overflow: hidden;
border-radius: 999px;
background: linear-gradient(135deg, #0046c8, #12b7ff);
box-shadow: 0 18px 32px rgba(0, 97, 255, 0.22);
color: #fff;
font-size: 1.875rem;
font-weight: 800;
outline: 4px solid #fff;
}
.agent-editor-avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.agent-editor-preview-text {
margin-top: 1rem;
text-align: center;
}
.agent-editor-preview-name {
margin-bottom: 0.25rem;
color: var(--color-text);
font-size: 1.0625rem;
font-weight: 800;
}
.agent-editor-preview-caption {
color: var(--color-text-secondary);
font-size: 0.78125rem;
line-height: 1.7;
}
.agent-editor-init-form {
padding: 1.25rem;
}
.agent-editor-form-label {
color: var(--color-text-secondary);
font-weight: 700;
}
.agent-editor-form-input,
.agent-editor-form-textarea {
border-color: rgba(150, 177, 217, 0.45);
border-radius: 0.75rem;
}
.agent-editor-form-input {
height: 3rem;
}
.agent-editor-suggestion-list {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
margin-bottom: 1rem;
}
.agent-editor-suggestion-chip {
border: 0;
border-radius: 999px;
padding: 0.375rem 0.625rem;
background: var(--color-surface-2);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.78125rem;
transition: background 0.2s ease, color 0.2s ease;
}
.agent-editor-suggestion-chip:hover {
background: rgba(0, 97, 255, 0.1);
color: var(--color-brand);
}
.agent-editor-init-actions {
display: flex;
gap: 1rem;
padding-top: 0.5rem;
}
.agent-editor-secondary-action,
.agent-editor-primary-action {
flex: 1;
height: 3rem;
border-radius: 0.75rem;
font-weight: 700;
}
.agent-editor-primary-action {
border: 0;
}
.agent-editor-avatar-section {
margin-top: 1rem;
}
.agent-editor-avatar-heading {
margin-bottom: 0.75rem;
padding: 0 0.25rem;
color: #8a9ab8;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent-editor-avatar-toolbar,
.agent-editor-avatar-modal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.agent-editor-avatar-hint {
color: var(--color-text-secondary);
font-size: 0.78125rem;
}
.agent-editor-upload-button {
border-radius: 0.625rem;
}
.agent-editor-avatar-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.75rem;
}
.agent-editor-avatar-grid-panel {
max-height: 25rem;
overflow-y: auto;
border-radius: 1rem;
background: var(--color-surface-2);
padding: 1rem;
}
.agent-editor-avatar-option {
position: relative;
width: 5rem;
height: 5rem;
min-width: 5rem;
margin: 0 auto;
overflow: hidden;
border: 2px solid transparent;
border-radius: 999px;
background: #f3f7ff;
cursor: pointer;
opacity: 0.72;
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.agent-editor-avatar-option:hover,
.agent-editor-avatar-option-active {
opacity: 1;
transform: scale(1.06);
}
.agent-editor-avatar-option-active {
z-index: 1;
border-color: var(--color-brand);
box-shadow: 0 12px 26px rgba(0, 97, 255, 0.18);
}
.agent-editor-avatar-selected {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 97, 255, 0.1);
}
.agent-editor-avatar-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
background: #fff;
box-shadow: inset 0 0 0 0.25rem var(--color-brand);
}

View File

@ -0,0 +1,61 @@
.agent-editor-icon-action {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
}
.agent-editor-header-icon {
color: var(--color-brand);
font-size: 1.125rem;
}
.agent-editor-header-title {
color: var(--color-text);
}
.agent-editor-header-subtitle {
margin-top: 0.125rem;
color: var(--color-text-secondary);
font-size: 0.78125rem;
}
.agent-editor-save-state {
color: var(--color-text-tertiary);
}
.agent-editor-header-action,
.agent-editor-save-action {
height: 2.5rem;
border-radius: 0.75rem;
}
.agent-editor-save-action {
padding-inline: 1rem;
font-weight: 700;
}
.agent-editor-prompt-hint {
margin-bottom: 0.625rem;
padding-inline: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.78125rem;
}
.agent-editor-prompt-textarea {
height: calc(100vh - 18.125rem);
resize: none;
border-radius: 1rem;
}
.agent-editor-prompt-footer {
color: var(--color-text-tertiary);
}
.agent-editor-preview-intro {
margin-bottom: 0.875rem;
}
.agent-editor-preview-surface {
flex: 1;
overflow: hidden;
}

View File

@ -1,170 +1,13 @@
import { useState } from 'react'; import { useIsMobile } from '../hooks/useIsMobile';
import { Form, Input, Button, Tabs, App as AntApp, Alert } from 'antd'; import { useLoginPageLogic } from './LoginPage/LoginPageLogic';
import { useNavigate, useSearchParams } from 'react-router-dom'; import LoginPageH5 from './LoginPage/components/LoginPageH5';
import { useAuth } from '../store/auth'; import LoginPageWeb from './LoginPage/components/LoginPageWeb';
import './LoginPage/styles/login-page-web.css';
import './LoginPage/styles/login-page-h5.css';
export default function LoginPage() { export default function LoginPage() {
const [tab, setTab] = useState<'login' | 'register'>('login'); const logic = useLoginPageLogic();
const navigate = useNavigate(); const isMobile = useIsMobile(1100);
const [params] = useSearchParams();
const next = params.get('next') || '/';
const { login, register } = useAuth();
const { message } = AntApp.useApp();
const [loading, setLoading] = useState(false);
const onLogin = async (values: any) => { return isMobile ? <LoginPageH5 logic={logic} /> : <LoginPageWeb logic={logic} />;
setLoading(true);
try {
await login(values.email, values.password);
message.success('登录成功');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '登录失败');
} finally {
setLoading(false);
}
};
const onRegister = async (values: any) => {
setLoading(true);
try {
await register({
email: values.email,
password: values.password,
name: values.name,
inviteCode: values.inviteCode || undefined
});
message.success('注册成功,已自动登录');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-deco login-deco-1" />
<div className="login-deco login-deco-2" />
<div className="login-content">
<div className="login-brand-panel">
<div className="login-brand-header">
<div className="login-brand-mark">A</div>
<span className="login-brand-name">Agent Studio</span>
</div>
<h1 className="login-title">
<br />
<span className="login-title-highlight"> AI </span>
</h1>
<p className="login-subtitle">
AI
</p>
<div className="login-features">
{[
{ icon: '✦', text: '多模型即插即用' },
{ icon: '✦', text: '知识库 + RAG 检索' },
{ icon: '✦', text: '可分享智能体' }
].map((it) => (
<div key={it.text} className="login-feature-item">
<span className="login-feature-icon">{it.icon}</span>
{it.text}
</div>
))}
</div>
</div>
<div className="login-form-panel">
<div className="login-card">
<div className="login-card-header">
<h2 className="login-card-title"></h2>
<div className="login-card-subtitle">使</div>
</div>
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as any)}
items={[
{
key: 'login',
label: '登录',
children: (
<Form layout="vertical" onFinish={onLogin} className="login-form">
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, type: 'email', message: '请填写合法邮箱' }]}
>
<Input placeholder="you@example.com" size="large" autoFocus />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
className="login-submit-btn"
>
</Button>
</Form>
)
},
{
key: 'register',
label: '注册',
children: (
<Form layout="vertical" onFinish={onRegister} className="login-form">
<Alert
className="login-register-alert"
type="info"
showIcon
message="第一个注册的用户自动成为管理员;之后需要邀请码"
/>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item name="name" label="昵称" rules={[{ required: true }]}>
<Input placeholder="张三" size="large" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, min: 6, message: '至少 6 位' }]}
>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Form.Item name="inviteCode" label="邀请码(可选)">
<Input placeholder="如果你是被邀请的用户" size="large" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
className="login-submit-btn"
>
</Button>
</Form>
)
}
]}
/>
<div className="login-footer"></div>
</div>
</div>
</div>
</div>
);
} }

View File

@ -0,0 +1,57 @@
import { useState } from 'react';
import { App as AntApp } from 'antd';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../store/auth';
export type LoginTab = 'login' | 'register';
export function useLoginPageLogic() {
const [tab, setTab] = useState<LoginTab>('login');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [params] = useSearchParams();
const next = params.get('next') || '/';
const { login, register } = useAuth();
const { message } = AntApp.useApp();
const onLogin = async (values: any) => {
setLoading(true);
try {
await login(values.email, values.password);
message.success('登录成功');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '登录失败');
} finally {
setLoading(false);
}
};
const onRegister = async (values: any) => {
setLoading(true);
try {
await register({
email: values.email,
password: values.password,
name: values.name,
inviteCode: values.inviteCode || undefined
});
message.success('注册成功,已自动登录');
navigate(next, { replace: true });
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '注册失败');
} finally {
setLoading(false);
}
};
return {
tab,
setTab,
loading,
onLogin,
onRegister
};
}
export type LoginPageLogic = ReturnType<typeof useLoginPageLogic>;

View File

@ -0,0 +1,70 @@
import { Alert, Button, Form, Input, Tabs } from 'antd';
import type { LoginPageLogic, LoginTab } from '../LoginPageLogic';
export default function LoginFormCard({ logic }: { logic: LoginPageLogic }) {
const { tab, setTab, loading, onLogin, onRegister } = logic;
return (
<div className="login-card">
<div className="login-card-header">
<h2 className="login-card-title"></h2>
<div className="login-card-subtitle">使</div>
</div>
<Tabs
activeKey={tab}
onChange={(key) => setTab(key as LoginTab)}
items={[
{
key: 'login',
label: '登录',
children: (
<Form layout="vertical" onFinish={onLogin} className="login-form">
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email', message: '请填写合法邮箱' }]}>
<Input placeholder="you@example.com" size="large" autoFocus />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
</Button>
</Form>
)
},
{
key: 'register',
label: '注册',
children: (
<Form layout="vertical" onFinish={onRegister} className="login-form">
<Alert
className="login-register-alert"
type="info"
showIcon
message="第一个注册的用户自动成为管理员;之后需要邀请码"
/>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item name="name" label="昵称" rules={[{ required: true }]}>
<Input placeholder="张三" size="large" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
<Input.Password placeholder="••••••" size="large" />
</Form.Item>
<Form.Item name="inviteCode" label="邀请码(可选)">
<Input placeholder="如果你是被邀请的用户" size="large" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
</Button>
</Form>
)
}
]}
/>
<div className="login-footer"></div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { CheckCircleOutlined } from '@ant-design/icons';
import type { LoginPageLogic } from '../LoginPageLogic';
import LoginFormCard from './LoginFormCard';
import kaiwuIcon from '../../../assets/brand/kaiwu-icon-gradient-transparent.png';
const H5_FEATURES = ['知识库', '代理端', '额度账户', '数据看板'];
export default function LoginPageH5({ logic }: { logic: LoginPageLogic }) {
return (
<div className="login-page login-page-h5">
<div className="login-h5-hero">
<div className="login-brand-header login-h5-brand-header">
<img src={kaiwuIcon} alt="鲸域AI" className="login-brand-logo" />
<span className="login-brand-name">AI</span>
</div>
<h1 className="login-h5-title">使</h1>
<p className="login-h5-subtitle">使</p>
<div className="login-h5-flow">
{H5_FEATURES.map((text, index) => (
<div key={text} className="login-h5-flow-item">
<span className="login-h5-flow-index">{index + 1}</span>
<span>{text}</span>
</div>
))}
</div>
<div className="login-h5-proof">
<CheckCircleOutlined />
<span> VI · </span>
</div>
</div>
<div className="login-h5-card-wrap">
<LoginFormCard logic={logic} />
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import { CheckCircleOutlined } from '@ant-design/icons';
import type { LoginPageLogic } from '../LoginPageLogic';
import LoginFormCard from './LoginFormCard';
import kaiwuIcon from '../../../assets/brand/kaiwu-icon-gradient-transparent.png';
const WEB_FEATURES = ['多模型即插即用', '知识库与工具闭环', '可分享智能体'];
export default function LoginPageWeb({ logic }: { logic: LoginPageLogic }) {
return (
<div className="login-page login-page-web">
<div className="login-deco login-deco-1" />
<div className="login-deco login-deco-2" />
<div className="login-content login-page-web-content">
<div className="login-brand-panel">
<div className="login-brand-header">
<img src={kaiwuIcon} alt="鲸域AI" className="login-brand-logo" />
<span className="login-brand-name">AI</span>
</div>
<h1 className="login-title">
<br />
<span className="login-title-highlight"> AI </span>
</h1>
<p className="login-subtitle">
</p>
<div className="login-features">
{WEB_FEATURES.map((text) => (
<div key={text} className="login-feature-item">
<CheckCircleOutlined className="login-feature-icon" />
{text}
</div>
))}
</div>
</div>
<div className="login-form-panel">
<LoginFormCard logic={logic} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
.login-page.login-page-h5 {
min-height: 100svh;
display: block;
justify-content: initial;
overflow-y: auto;
overflow-x: hidden;
padding: 1.25rem 1rem 1.5rem;
background: var(--gradient-hero);
}
.login-h5-hero {
position: relative;
z-index: 1;
width: 100%;
max-width: 44rem;
margin: 0 auto;
padding: 0.25rem 0 1rem;
}
.login-h5-brand-header {
margin-bottom: 1.5rem;
}
.login-h5-title {
margin: 0;
color: var(--color-text);
font-size: 2rem;
line-height: 1.18;
font-weight: 900;
}
.login-h5-subtitle {
margin: 0.875rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9375rem;
line-height: 1.7;
}
.login-h5-flow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
margin-top: 1rem;
}
.login-h5-flow-item {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.625rem;
border: 1px solid rgba(17, 103, 255, 0.14);
border-radius: 0.875rem;
background: rgba(255, 255, 255, 0.78);
color: var(--color-text);
font-weight: 700;
}
.login-h5-flow-index {
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #ffffff;
background: var(--gradient-brand);
font-size: 0.75rem;
flex: 0 0 auto;
}
.login-h5-proof {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
color: var(--color-brand);
font-size: 0.8125rem;
font-weight: 700;
}
.login-h5-card-wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: 26rem;
margin: 0.25rem auto 0;
}
.login-page-h5 .login-card {
max-width: none;
padding: 1.25rem;
border-radius: 1.125rem;
box-shadow: var(--shadow-lg);
}

View File

@ -0,0 +1,87 @@
.login-page-web {
align-items: stretch;
}
.login-page-web .login-deco-1 {
background: radial-gradient(circle, rgba(17, 103, 255, 0.18), transparent 60%);
}
.login-page-web .login-deco-2 {
background: radial-gradient(circle, rgba(34, 166, 255, 0.16), transparent 60%);
}
.login-page-web-content {
min-height: 100vh;
}
.login-page-web .login-title {
letter-spacing: 0;
}
.login-page-web .login-title-highlight {
background: var(--gradient-brand);
background-clip: text;
-webkit-background-clip: text;
}
@media (max-width: 1100px) {
.login-page.login-page-web {
display: block;
min-height: 100svh;
justify-content: initial;
overflow-y: auto;
overflow-x: hidden;
padding: 1.25rem 1rem 1.5rem;
background: var(--gradient-hero);
}
.login-page-web .login-page-web-content {
width: 100%;
min-height: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-page-web .login-brand-panel {
padding: 0.25rem 0 0;
}
.login-page-web .login-brand-header {
margin-bottom: 1.5rem;
}
.login-page-web .login-title {
max-width: none;
font-size: 2rem;
line-height: 1.18;
font-weight: 900;
}
.login-page-web .login-subtitle {
max-width: none;
margin-top: 0.875rem;
font-size: 0.9375rem;
}
.login-page-web .login-features {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
margin-top: 1rem;
}
.login-page-web .login-feature-item {
min-height: 2.75rem;
border: 1px solid rgba(17, 103, 255, 0.14);
border-radius: 0.875rem;
padding: 0.625rem;
background: rgba(255, 255, 255, 0.78);
font-weight: 700;
}
.login-page-web .login-form-panel {
width: 100%;
padding: 0;
}
}

View File

@ -1,24 +1,11 @@
import { useEffect, useState } from 'react'; import { useIsMobile } from '../../hooks/useIsMobile';
import { usePointsMallPageLogic } from './PointsMallPageLogic'; import { usePointsMallPageLogic } from './PointsMallPageLogic';
import PointsMallPageWeb from './components/PointsMallPageWeb'; import PointsMallPageWeb from './components/PointsMallPageWeb';
import PointsMallPageH5 from './components/PointsMallPageH5'; import PointsMallPageH5 from './components/PointsMallPageH5';
const isMobileDevice = () => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
};
export default function PointsMallPage() { export default function PointsMallPage() {
const logic = usePointsMallPageLogic(); const logic = usePointsMallPageLogic();
const [isMobile, setIsMobile] = useState(isMobileDevice()); const isMobile = useIsMobile();
useEffect(() => {
const handleResize = () => {
setIsMobile(isMobileDevice());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />; return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
} }

View File

@ -16,35 +16,35 @@ export default function ConfirmExchangeModal(props: {
const canAfford = product ? userPoints >= totalPoints : false; const canAfford = product ? userPoints >= totalPoints : false;
return ( return (
<Modal title="确认兑换" open={open} onCancel={onCancel} footer={null} width={460} destroyOnClose> <Modal title="确认兑换" open={open} onCancel={onCancel} footer={null} width={460} destroyOnHidden>
{!product ? null : ( {!product ? null : (
<div> <div>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 10 }}>{product.name}</div> <div className="points-confirm-product-name">{product.name}</div>
<Space size={8} wrap style={{ marginBottom: 16 }}> <Space size={8} wrap className="points-confirm-tags">
<Tag color="processing" style={{ marginInlineEnd: 0 }}> <Tag color="processing" className="points-confirm-tag">
{product.pointsPrice.toLocaleString()} {product.pointsPrice.toLocaleString()}
</Tag> </Tag>
<Tag color="default" style={{ marginInlineEnd: 0 }}> <Tag color="default" className="points-confirm-tag">
{userPoints.toLocaleString()} {userPoints.toLocaleString()}
</Tag> </Tag>
<Tag color={canAfford ? 'success' : 'error'} style={{ marginInlineEnd: 0 }}> <Tag color={canAfford ? 'success' : 'error'} className="points-confirm-tag">
{totalPoints.toLocaleString()} {totalPoints.toLocaleString()}
</Tag> </Tag>
</Space> </Space>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}> <div className="points-confirm-quantity-row">
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}></div> <div className="points-confirm-label"></div>
<InputNumber <InputNumber
min={1} min={1}
max={Math.max(1, product.stock || 1)} max={Math.max(1, product.stock || 1)}
value={quantity} value={quantity}
disabled={loading} disabled={loading}
onChange={(v) => onQuantityChange(Number(v || 1))} onChange={(v) => onQuantityChange(Number(v || 1))}
style={{ width: 140 }} className="points-confirm-quantity-input"
/> />
</div> </div>
<div style={{ color: 'var(--color-text-tertiary)', fontSize: 12.5, lineHeight: 1.6, marginBottom: 16 }}> <div className="points-confirm-tip">
/ /
</div> </div>
@ -56,4 +56,3 @@ export default function ConfirmExchangeModal(props: {
</Modal> </Modal>
); );
} }

View File

@ -68,7 +68,7 @@ export default function ExchangeModal(props: {
}, [province, city, region.provinces]); }, [province, city, region.provinces]);
return ( return (
<Modal title="兑换商品" open={open} onCancel={onCancel} footer={null} width={500} className="points-exchange-modal" destroyOnClose> <Modal title="兑换商品" open={open} onCancel={onCancel} footer={null} width={500} className="points-exchange-modal" destroyOnHidden>
{product && ( {product && (
<> <>
<div className="points-exchange-modal-header"> <div className="points-exchange-modal-header">

View File

@ -0,0 +1,137 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Card, Empty, Input, Select, Space, Spin, Tag } from 'antd';
import type { PointsMallProduct } from '../../../api';
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
interface Props {
logic: PointsMallPageLogicOutput;
}
export default function PointsMallH5Products({ logic }: Props) {
const {
q,
sort,
page,
pageSize,
productsLoading,
products,
total,
userPoints,
exchangeLoading,
setQ,
setSort,
setPage,
setPageSize,
handleExchangeClick
} = logic;
return (
<Card className="stats-page-chart-card h5-stats-page-chart-card h5-points-mall-products-card">
<div className="points-mall-filters-row h5-points-mall-filters-row">
<div className="points-mall-filter-search">
<Input
value={q}
onChange={(e) => {
setPage(1);
setQ(e.target.value);
}}
prefix={<SearchOutlined className="points-mall-input-icon" />}
placeholder="搜索商品"
allowClear
className="points-mall-search-input"
/>
</div>
<div className="points-mall-filter-selects">
<Select
value={sort}
className="points-mall-filter-select"
onChange={(v) => {
setPage(1);
setSort(v);
}}
options={[
{ value: 'popular', label: '热度优先' },
{ value: 'newest', label: '最新上架' },
{ value: 'price_asc', label: '积分从低到高' },
{ value: 'price_desc', label: '积分从高到低' }
]}
/>
<Select
value={pageSize}
className="points-mall-filter-select-small"
onChange={(v) => {
setPage(1);
setPageSize(v);
}}
options={[
{ value: 12, label: '每页 12' },
{ value: 24, label: '每页 24' }
]}
/>
</div>
<div className="points-mall-total-text"> {total.toLocaleString()} </div>
</div>
{productsLoading ? (
<Spin className="points-mall-state-spin" />
) : products.length === 0 ? (
<Empty description="暂无商品" className="points-mall-empty-state" />
) : (
<div className="points-mall-products-grid h5-points-mall-products-grid">
{products.map((p: PointsMallProduct) => (
<div key={p.id} className="points-mall-product-card h5-points-mall-product-card">
<div className="points-mall-product-cover" />
<div className="points-mall-product-body">
<div className="points-mall-product-header">
<div className="points-mall-product-info">
<div className="points-mall-product-name">{p.name}</div>
<div className="points-mall-product-desc">{p.subtitle}</div>
</div>
{p.tags?.length ? (
<Tag bordered={false} className="points-mall-product-tag">
{p.tags[0]}
</Tag>
) : null}
</div>
<div className="points-mall-product-price-row h5-points-mall-product-price-row">
<div>
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
<span className="points-mall-product-price-label"></span>
</div>
<Button
type="primary"
className="points-mall-exchange-btn points-mall-product-exchange-btn"
disabled={userPoints < p.pointsPrice || exchangeLoading}
onClick={() => handleExchangeClick(p)}
>
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
</Button>
</div>
<div className="points-mall-product-footer">
<span> {p.stock}</span>
<span> {p.sold}</span>
</div>
</div>
</div>
))}
</div>
)}
{!!total && (
<div className="points-mall-pagination h5-points-mall-pagination">
<Space size={8}>
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn" size="small">
</Button>
<Tag bordered={false} className="points-mall-pagination-tag">
{page}
</Tag>
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn" size="small">
</Button>
</Space>
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,106 @@
import { Button, Card, Spin, Tag } from 'antd';
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
interface Props {
logic: PointsMallPageLogicOutput;
}
export default function PointsMallH5Top({ logic }: Props) {
const {
overview,
overviewLoading,
categories,
categoryId,
userPoints,
totalSpentUSD,
banner,
promoEntries,
setCategoryId,
setPage
} = logic;
return (
<>
<div className="points-mall-hero h5-points-mall-hero">
<div className="points-mall-header h5-points-mall-header">
<div className="points-mall-title-section">
<h1 className="page-title stats-page-title h5-page-title"></h1>
<p className="page-subtitle stats-page-subtitle h5-page-subtitle">
使<br /> API 1 = 1000
</p>
</div>
<div className="points-mall-balance-card h5-points-mall-balance-card">
{overviewLoading ? (
<Spin />
) : (
<div className="points-balance-row h5-points-balance-row">
<div className="points-balance-main">
<div className="points-balance-label"></div>
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
<div className="points-balance-subtext">
${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
</div>
</div>
<Tag bordered={false} className="points-mall-level-tag">
{String(overview?.me?.level || 'Lv.0')}
</Tag>
</div>
)}
</div>
</div>
</div>
<Card className="points-mall-category-card h5-points-mall-category-card">
<div className="points-mall-category-body">
<div className="points-mall-category-row h5-points-mall-category-row">
<span className="points-mall-category-label h5-points-mall-category-label"></span>
{categories.map((c) => (
<Button
key={c.id}
size="small"
type={c.id === categoryId ? 'primary' : 'default'}
className="points-mall-category-button"
onClick={() => {
setPage(1);
setCategoryId(c.id);
}}
>
{c.name}
</Button>
))}
</div>
</div>
</Card>
<div className="points-mall-banner-section h5-points-mall-banner-section">
<div className="points-mall-banner-card h5-points-mall-banner-card">
<div className="points-mall-banner-header h5-points-mall-banner-header">
<div>
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
</div>
<Button type="primary" className="points-mall-banner-action">
</Button>
</div>
<div className="points-mall-banner-footer">banner </div>
</div>
<div className="points-mall-promo-grid h5-points-mall-promo-grid">
{promoEntries.slice(0, 2).map((p) => (
<div key={p.id} className="points-mall-promo-card h5-points-mall-promo-card">
<div className="points-mall-promo-body">
<div className="points-mall-promo-title">{p.title}</div>
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
</div>
<Button size="small" className="points-mall-exchange-btn points-mall-promo-action">
</Button>
</div>
))}
{promoEntries.length < 2 && <div className="points-mall-promo-empty"></div>}
</div>
</div>
</>
);
}

View File

@ -1,10 +1,11 @@
import { Button, Card, Empty, Form, Input, Select, Space, Spin, Tag } from 'antd'; import { Form } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import type { PointsMallProduct } from '../../../api';
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic'; import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
import ExchangeModal from '../components/ExchangeModal'; import ConfirmExchangeModal from './ConfirmExchangeModal';
import ConfirmExchangeModal from '../components/ConfirmExchangeModal'; import ExchangeModal from './ExchangeModal';
import PointsMallH5Products from './PointsMallH5Products';
import PointsMallH5Top from './PointsMallH5Top';
import type { ExchangeFormValues } from '../types'; import type { ExchangeFormValues } from '../types';
import '../styles/points-mall-h5.css';
interface Props { interface Props {
logic: PointsMallPageLogicOutput; logic: PointsMallPageLogicOutput;
@ -13,234 +14,21 @@ interface Props {
export default function PointsMallPageH5({ logic }: Props) { export default function PointsMallPageH5({ logic }: Props) {
const [exchangeForm] = Form.useForm<ExchangeFormValues>(); const [exchangeForm] = Form.useForm<ExchangeFormValues>();
const { const {
overview,
overviewLoading,
categories,
categoryId,
q,
sort,
page,
pageSize,
productsLoading,
products,
total,
userPoints,
totalSpentUSD,
banner,
promoEntries,
exchangeModalVisible, exchangeModalVisible,
confirmModalVisible, confirmModalVisible,
selectedProduct, selectedProduct,
exchangeQuantity, exchangeQuantity,
pendingExpiresAt, pendingExpiresAt,
exchangeLoading, exchangeLoading,
setCategoryId, userPoints,
setQ,
setSort,
setPage,
setPageSize,
handleExchangeClick,
setExchangeModalVisible, setExchangeModalVisible,
setConfirmModalVisible, setConfirmModalVisible
} = logic; } = logic;
return ( return (
<div className="page-container h5-page-container" style={{ paddingLeft: 8, paddingRight: 8 }}> <div className="page-container h5-page-container points-mall-page-h5">
<div className="points-mall-hero h5-points-mall-hero" style={{ padding: '16px 12px' }}> <PointsMallH5Top logic={logic} />
<div className="points-mall-header h5-points-mall-header" style={{ flexDirection: 'column', gap: 12 }}> <PointsMallH5Products logic={logic} />
<div className="points-mall-title-section">
<h1 className="page-title stats-page-title h5-page-title" style={{ fontSize: 24, marginBottom: 6 }}></h1>
<p className="page-subtitle stats-page-subtitle h5-page-subtitle" style={{ fontSize: 13, lineHeight: 1.5 }}>
使<br /> API 1 = 1000
</p>
</div>
<div className="points-mall-balance-card h5-points-mall-balance-card" style={{ width: '100%', padding: 12 }}>
{overviewLoading ? (
<Spin />
) : (
<div className="points-balance-row h5-points-balance-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: '100%' }}>
<div className="points-balance-label" style={{ fontSize: 12 }}></div>
<div className="points-balance-value" style={{ fontSize: 28 }}>{userPoints.toLocaleString()}</div>
<div className="points-balance-subtext" style={{ fontSize: 12 }}>
${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
</div>
</div>
<Tag
bordered={false}
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', fontSize: 12 }}
>
{String(overview?.me?.level || 'Lv.0')}
</Tag>
</div>
)}
</div>
</div>
</div>
<Card className="points-mall-category-card h5-points-mall-category-card" bodyStyle={{ padding: 0 }}>
<div className="points-mall-category-body">
<div className="points-mall-category-row h5-points-mall-category-row" style={{ flexWrap: 'wrap', gap: 6 }}>
<span className="points-mall-category-label" style={{ width: '100%', marginBottom: 8 }}></span>
{categories.map((c) => (
<Button
key={c.id}
size="small"
type={c.id === categoryId ? 'primary' : 'default'}
style={{ borderRadius: 999, fontSize: 12 }}
onClick={() => {
setPage(1);
setCategoryId(c.id);
}}
>
{c.name}
</Button>
))}
</div>
</div>
</Card>
<div className="points-mall-banner-section h5-points-mall-banner-section" style={{ flexDirection: 'column', gap: 12 }}>
<div className="points-mall-banner-card h5-points-mall-banner-card" style={{ width: '100%' }}>
<div className="points-mall-banner-header" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div>
<div className="points-mall-banner-title" style={{ fontSize: 16 }}>{banner?.title || '本期活动'}</div>
<div className="points-mall-banner-subtitle" style={{ fontSize: 12 }}>{banner?.subtitle || 'Up to 25% Off'}</div>
</div>
<Button type="primary" style={{ borderRadius: 10, height: 36, fontWeight: 600, width: '100%' }}>
</Button>
</div>
<div className="points-mall-banner-footer">banner </div>
</div>
<div className="points-mall-promo-grid h5-points-mall-promo-grid" style={{ gridTemplateColumns: '1fr', gap: 8 }}>
{promoEntries.slice(0, 2).map((p) => (
<div key={p.id} className="points-mall-promo-card" style={{ padding: 12 }}>
<div style={{ minWidth: 0 }}>
<div className="points-mall-promo-title" style={{ fontSize: 14 }}>{p.title}</div>
<div className="points-mall-promo-subtitle" style={{ fontSize: 12 }}>{p.subtitle}</div>
</div>
<Button size="small" className="points-mall-exchange-btn" style={{ marginTop: 8 }}>
</Button>
</div>
))}
{promoEntries.length < 2 && <div className="points-mall-promo-empty"></div>}
</div>
</div>
<Card className="stats-page-chart-card h5-stats-page-chart-card">
<div className="points-mall-filters-row h5-points-mall-filters-row" style={{ flexDirection: 'column', gap: 10 }}>
<div style={{ width: '100%' }}>
<Input
value={q}
onChange={(e) => {
setPage(1);
setQ(e.target.value);
}}
prefix={<SearchOutlined />}
placeholder="搜索商品"
allowClear
className="points-mall-search-input"
style={{ height: 36 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Select
value={sort}
className="points-mall-filter-select"
style={{ flex: 1 }}
onChange={(v) => {
setPage(1);
setSort(v);
}}
options={[
{ value: 'popular', label: '热度优先' },
{ value: 'newest', label: '最新上架' },
{ value: 'price_asc', label: '积分从低到高' },
{ value: 'price_desc', label: '积分从高到低' },
]}
/>
<Select
value={pageSize}
className="points-mall-filter-select-small"
style={{ width: 80 }}
onChange={(v) => {
setPage(1);
setPageSize(v);
}}
options={[
{ value: 12, label: '每页 12' },
{ value: 24, label: '每页 24' },
]}
/>
</div>
<div className="points-mall-total-text" style={{ textAlign: 'left' }}> {total.toLocaleString()} </div>
</div>
{productsLoading ? (
<Spin style={{ marginTop: 30, display: 'block' }} />
) : products.length === 0 ? (
<Empty description="暂无商品" style={{ marginTop: 40 }} />
) : (
<div className="points-mall-products-grid h5-points-mall-products-grid" style={{ gridTemplateColumns: '1fr', gap: 12 }}>
{products.map((p: PointsMallProduct) => (
<div key={p.id} className="points-mall-product-card h5-points-mall-product-card" style={{ padding: 12 }}>
<div className="points-mall-product-cover" />
<div className="points-mall-product-body">
<div className="points-mall-product-header">
<div className="points-mall-product-info">
<div className="points-mall-product-name" style={{ fontSize: 15 }}>{p.name}</div>
<div className="points-mall-product-desc" style={{ fontSize: 12 }}>{p.subtitle}</div>
</div>
{p.tags?.length ? (
<Tag bordered={false} className="points-mall-product-tag" style={{ fontSize: 11 }}>
{p.tags[0]}
</Tag>
) : null}
</div>
<div className="points-mall-product-price-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div>
<span className="points-mall-product-price" style={{ fontSize: 20 }}>{Number(p.pointsPrice).toLocaleString()}</span>
<span className="points-mall-product-price-label"></span>
</div>
<Button
type="primary"
className="points-mall-exchange-btn points-mall-product-exchange-btn"
disabled={userPoints < p.pointsPrice || exchangeLoading}
onClick={() => handleExchangeClick(p)}
style={{ width: '100%' }}
>
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
</Button>
</div>
<div className="points-mall-product-footer" style={{ fontSize: 12 }}>
<span> {p.stock}</span>
<span> {p.sold}</span>
</div>
</div>
</div>
))}
</div>
)}
{!!total && (
<div className="points-mall-pagination h5-points-mall-pagination" style={{ marginTop: 16 }}>
<Space size={8}>
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn" size="small">
</Button>
<Tag bordered={false} className="points-mall-pagination-tag">
{page}
</Tag>
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn" size="small">
</Button>
</Space>
</div>
)}
</Card>
<ExchangeModal <ExchangeModal
open={exchangeModalVisible} open={exchangeModalVisible}

View File

@ -0,0 +1,245 @@
.points-mall-page-h5 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.points-mall-page-h5 .h5-points-mall-hero {
padding: 1rem 0.75rem;
border-radius: 1.125rem;
background: var(--gradient-hero);
}
.points-mall-page-h5 .h5-points-mall-header,
.points-mall-page-h5 .h5-points-balance-row,
.points-mall-page-h5 .h5-points-mall-banner-header,
.points-mall-page-h5 .h5-points-mall-product-price-row {
flex-direction: column;
align-items: flex-start;
}
.points-mall-page-h5 .h5-points-mall-header,
.points-mall-page-h5 .h5-points-mall-banner-section {
gap: 0.75rem;
}
.points-mall-page-h5 .h5-page-title {
margin-bottom: 0.375rem;
font-size: 1.5rem;
}
.points-mall-page-h5 .h5-page-subtitle {
font-size: 0.8125rem;
line-height: 1.5;
}
.points-mall-page-h5 .h5-points-mall-balance-card,
.points-mall-page-h5 .points-balance-main,
.points-mall-page-h5 .points-mall-banner-action,
.points-mall-page-h5 .points-mall-product-exchange-btn {
width: 100%;
}
.points-mall-page-h5 .h5-points-mall-balance-card,
.points-mall-page-h5 .h5-points-mall-product-card {
padding: 0.75rem;
}
.points-mall-page-h5 .h5-points-balance-row {
gap: 0.5rem;
}
.points-mall-page-h5 .points-balance-label,
.points-mall-page-h5 .points-balance-subtext,
.points-mall-page-h5 .points-mall-banner-subtitle,
.points-mall-page-h5 .points-mall-promo-subtitle,
.points-mall-page-h5 .points-mall-product-footer {
font-size: 0.75rem;
}
.points-mall-page-h5 .points-balance-value {
font-size: 1.75rem;
}
.points-mall-level-tag {
margin: 0;
border-radius: 999px;
background: var(--color-brand-soft);
color: var(--color-brand);
font-size: 0.75rem;
}
.points-mall-page-h5 .h5-points-mall-category-card .ant-card-body {
padding: 0;
}
.points-mall-page-h5 .h5-points-mall-category-row {
gap: 0.375rem;
}
.points-mall-page-h5 .h5-points-mall-category-label {
width: 100%;
margin-bottom: 0.5rem;
}
.points-mall-category-button {
border-radius: 999px;
font-size: 0.75rem;
}
.points-mall-page-h5 .h5-points-mall-banner-section,
.points-mall-page-h5 .h5-points-mall-promo-grid,
.points-mall-page-h5 .h5-points-mall-products-grid {
display: grid;
grid-template-columns: 1fr;
}
.points-mall-page-h5 .h5-points-mall-banner-card {
width: 100%;
min-height: 10rem;
padding: 1rem;
}
.points-mall-page-h5 .h5-points-mall-banner-header,
.points-mall-page-h5 .h5-points-mall-product-price-row {
gap: 0.5rem;
}
.points-mall-page-h5 .points-mall-banner-title {
font-size: 1rem;
}
.points-mall-page-h5 .points-mall-banner-action {
height: 2.25rem;
border-radius: 0.625rem;
font-weight: 700;
}
.points-mall-page-h5 .h5-points-mall-promo-grid {
grid-template-rows: none;
gap: 0.5rem;
}
.points-mall-page-h5 .h5-points-mall-promo-card {
padding: 0.75rem;
}
.points-mall-promo-body {
min-width: 0;
}
.points-mall-page-h5 .points-mall-promo-title {
font-size: 0.875rem;
}
.points-mall-page-h5 .points-mall-promo-action {
margin-top: 0.5rem;
}
.points-mall-page-h5 .h5-points-mall-filters-row {
flex-direction: column;
gap: 0.625rem;
}
.points-mall-filter-search,
.points-mall-filter-selects,
.points-mall-page-h5 .points-mall-search-input,
.points-mall-page-h5 .points-mall-filter-select,
.points-mall-page-h5 .points-mall-filter-select-small {
width: 100%;
}
.points-mall-filter-selects {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
.points-mall-page-h5 .points-mall-search-input {
height: 2.25rem;
}
.points-mall-input-icon {
color: var(--color-text-tertiary);
}
.points-mall-page-h5 .points-mall-total-text {
text-align: left;
}
.points-mall-state-spin {
display: block;
margin-top: 1.875rem;
}
.points-mall-empty-state {
margin-top: 2.5rem;
}
.points-mall-page-h5 .h5-points-mall-products-grid {
gap: 0.75rem;
}
.points-mall-page-h5 .points-mall-product-name {
font-size: 0.9375rem;
}
.points-mall-page-h5 .points-mall-product-desc {
font-size: 0.75rem;
}
.points-mall-page-h5 .points-mall-product-tag {
font-size: 0.6875rem;
}
.points-mall-page-h5 .points-mall-product-price {
font-size: 1.25rem;
}
.points-mall-page-h5 .h5-points-mall-pagination {
margin-top: 1rem;
}
.points-confirm-product-name {
margin-bottom: 0.625rem;
color: var(--color-text);
font-size: 0.875rem;
font-weight: 700;
}
.points-confirm-tags {
margin-bottom: 1rem;
}
.points-confirm-tag {
margin-inline-end: 0;
}
.points-confirm-quantity-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.points-confirm-label {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.points-confirm-quantity-input {
width: 8.75rem;
}
.points-confirm-tip {
margin-bottom: 1rem;
color: var(--color-text-tertiary);
font-size: 0.78125rem;
line-height: 1.6;
}
@media (max-width: 768px) {
.points-mall-address-grid {
grid-template-columns: 1fr;
}
}

View File

@ -5,9 +5,15 @@ import { PromptTemplateAPI } from '../../api';
import PromptLibraryHero from './components/PromptLibraryHero'; import PromptLibraryHero from './components/PromptLibraryHero';
import PromptTemplateGrid from './components/PromptTemplateGrid'; import PromptTemplateGrid from './components/PromptTemplateGrid';
import PromptTemplateEditorModal from './components/PromptTemplateEditorModal'; import PromptTemplateEditorModal from './components/PromptTemplateEditorModal';
import { useIsMobile } from '../../hooks/useIsMobile';
import './styles/prompt-library.css';
import './styles/prompt-library-card.css';
import './styles/prompt-library-tones.css';
import './styles/prompt-library-h5.css';
export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) { export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) {
const { onSelect } = props; const { onSelect } = props;
const isMobile = useIsMobile();
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all'); const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
const [q, setQ] = useState(''); const [q, setQ] = useState('');
@ -90,7 +96,7 @@ export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTempla
}); });
return ( return (
<div className="feature-cover-container"> <div className={`feature-cover-container prompt-library-page-${isMobile ? 'h5' : 'web'}`}>
<div className="page-container"> <div className="page-container">
<PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} /> <PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} />
<PromptTemplateGrid <PromptTemplateGrid

View File

@ -13,70 +13,46 @@ export default function PromptLibraryHero(props: {
const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props; const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props;
return ( return (
<div <div className="prompt-library-hero">
style={{ <div className="prompt-library-hero-header">
borderRadius: 24, <div className="prompt-library-hero-copy">
padding: '28px 28px 24px', <div className="prompt-library-badge">
background: <AppstoreOutlined className="prompt-library-badge-icon" />
'linear-gradient(135deg, rgba(255,255,255,0.96) 0%, rgba(236,253,245,0.92) 42%, rgba(240,249,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap', marginBottom: 22 }}>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<AppstoreOutlined style={{ color: 'var(--color-brand)' }} />
</div> </div>
<h1 className="page-title" style={{ marginBottom: 10 }}> <h1 className="page-title prompt-library-title">
Prompt Prompt
</h1> </h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}> <div className="page-subtitle prompt-library-subtitle">
使 使
</div> </div>
</div> </div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}> <Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} className="prompt-library-create-btn">
</Button> </Button>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.5fr) auto', gap: 14, alignItems: 'center' }}> <div className="prompt-library-toolbar">
<Input <Input
placeholder="搜索标题、分类或正文..." placeholder="搜索标题、分类或正文..."
value={q} value={q}
onChange={(e) => onChangeQ(e.target.value)} onChange={(e) => onChangeQ(e.target.value)}
onPressEnter={onSearch} onPressEnter={onSearch}
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />} prefix={<SearchOutlined className="prompt-library-search-icon" />}
suffix={ suffix={
q ? ( q ? (
<Button type="link" size="small" onClick={onSearch} style={{ padding: 0, height: 20 }}> <Button type="link" size="small" onClick={onSearch} className="prompt-library-search-btn">
</Button> </Button>
) : null ) : null
} }
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }} className="prompt-library-search-input"
allowClear allowClear
/> />
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <div className="prompt-library-scope-list">
{SCOPE_OPTIONS.map((item) => { {SCOPE_OPTIONS.map((item) => {
const active = scope === item.key; const active = scope === item.key;
return ( return (
@ -84,17 +60,7 @@ export default function PromptLibraryHero(props: {
key={item.key} key={item.key}
type="button" type="button"
onClick={() => onChangeScope(item.key)} onClick={() => onChangeScope(item.key)}
style={{ className={`prompt-library-scope-btn${active ? ' active' : ''}`}
border: '1px solid',
borderColor: active ? 'rgba(8, 145, 178, 0.18)' : 'var(--color-border)',
background: active ? 'rgba(8, 145, 178, 0.10)' : 'rgba(255,255,255,0.72)',
color: active ? 'var(--color-brand)' : 'var(--color-text-secondary)',
borderRadius: 999,
padding: '10px 14px',
fontSize: 13,
fontWeight: active ? 600 : 500,
cursor: 'pointer'
}}
> >
{item.label} {item.label}
</button> </button>
@ -105,4 +71,3 @@ export default function PromptLibraryHero(props: {
</div> </div>
); );
} }

View File

@ -15,131 +15,59 @@ export default function PromptTemplateCard(props: {
const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他']; const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他'];
return ( return (
<div <div className="prompt-template-card">
style={{ <div className="prompt-template-card-header">
borderRadius: 20, <div className="prompt-template-tags">
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.045)',
padding: 20,
display: 'flex',
flexDirection: 'column',
minHeight: 286
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag <Tag
bordered={false} bordered={false}
style={{ className={`prompt-template-tag prompt-category-${tone}`}
margin: 0,
borderRadius: 999,
paddingInline: 10,
height: 28,
lineHeight: '28px',
fontSize: 12,
fontWeight: 600,
background: tone.bg,
color: tone.text
}}
> >
{t.category || '其他'} {t.category || '其他'}
</Tag> </Tag>
<Tag <Tag
bordered={false} bordered={false}
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />} icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
style={{ className="prompt-template-tag prompt-template-visibility"
margin: 0,
borderRadius: 999,
paddingInline: 10,
height: 28,
lineHeight: '28px',
fontSize: 12,
background: 'var(--color-surface-2)',
color: 'var(--color-text-secondary)'
}}
> >
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'} {t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
</Tag> </Tag>
</div> </div>
<Tooltip title={hasOnSelect ? '插入到当前对话' : '复制到剪贴板'}> <Tooltip title={hasOnSelect ? '插入到当前对话' : '复制到剪贴板'}>
<Button type="primary" icon={<CopyOutlined />} onClick={onUse} style={{ borderRadius: 12, height: 38, fontWeight: 600 }}> <Button type="primary" icon={<CopyOutlined />} onClick={onUse} className="prompt-template-use-btn">
{hasOnSelect ? '使用' : '复制'} {hasOnSelect ? '使用' : '复制'}
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 14 }}> <div className="prompt-template-title-row">
<div style={{ minWidth: 0 }}> <div className="prompt-template-title-block">
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', letterSpacing: '-0.02em', marginBottom: 6 }}>{t.title}</div> <div className="prompt-template-title">{t.title}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>by {t.ownerName || '我'}</div> <div className="prompt-template-owner">by {t.ownerName || '我'}</div>
</div> </div>
<div <div className="prompt-template-use-count">
style={{
flexShrink: 0,
padding: '6px 10px',
borderRadius: 12,
background: 'rgba(15, 23, 42, 0.03)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600
}}
>
使 {t.useCount} 使 {t.useCount}
</div> </div>
</div> </div>
<div <div className={`prompt-template-body-card prompt-category-border-${tone}`}>
style={{ <div className="prompt-template-body">
position: 'relative',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
padding: '16px 16px 18px',
minHeight: 132,
overflow: 'hidden'
}}
>
<div style={{ position: 'absolute', inset: '0 auto 0 0', width: 4, background: tone.text, opacity: 0.18 }} />
<div
style={{
whiteSpace: 'pre-wrap',
fontSize: 13.5,
color: 'var(--color-text-secondary)',
lineHeight: 1.8,
minHeight: 96,
maxHeight: 130,
overflow: 'hidden'
}}
>
{t.body.slice(0, 220)} {t.body.slice(0, 220)}
{t.body.length > 220 ? '…' : ''} {t.body.length > 220 ? '…' : ''}
</div> </div>
</div> </div>
<div <div className="prompt-template-footer">
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 'auto',
paddingTop: 16,
borderTop: '1px solid var(--color-border)',
fontSize: 12.5,
color: 'var(--color-text-tertiary)'
}}
>
<ClockCircleOutlined /> <ClockCircleOutlined />
<span> {formatDate(t.updatedAt)}</span> <span> {formatDate(t.updatedAt)}</span>
<span style={{ flex: 1 }} /> <span className="prompt-template-footer-spacer" />
<Space size={2}> <Space size={2}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} style={{ borderRadius: 10 }}> <Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} className="prompt-template-text-btn">
</Button> </Button>
<Popconfirm title="删除此模板?" onConfirm={onDelete}> <Popconfirm title="删除此模板?" onConfirm={onDelete}>
<Button size="small" type="text" danger style={{ borderRadius: 10 }}> <Button size="small" type="text" danger className="prompt-template-text-btn">
</Button> </Button>
</Popconfirm> </Popconfirm>
@ -148,4 +76,3 @@ export default function PromptTemplateCard(props: {
</div> </div>
); );
} }

View File

@ -15,17 +15,7 @@ export default function PromptTemplateGrid(props: {
if (loading) { if (loading) {
return ( return (
<div <div className="prompt-template-state-card">
style={{
minHeight: 280,
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@ -33,14 +23,14 @@ export default function PromptTemplateGrid(props: {
if (list.length === 0) { if (list.length === 0) {
return ( return (
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}> <div className="prompt-template-empty-card">
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" /> <Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
</div> </div>
); );
} }
return ( return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}> <div className="prompt-template-grid">
{list.map((t) => ( {list.map((t) => (
<PromptTemplateCard <PromptTemplateCard
key={t.id} key={t.id}
@ -55,4 +45,3 @@ export default function PromptTemplateGrid(props: {
</div> </div>
); );
} }

View File

@ -6,13 +6,12 @@ export const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: strin
{ key: 'public', label: '公开灵感' } { key: 'public', label: '公开灵感' }
]; ];
export const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = { export const CATEGORY_STYLES: Record<string, string> = {
: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' }, : 'general',
: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' }, : 'code',
: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' }, : 'writing',
: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' }, : 'translate',
: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' }, : 'analysis',
: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' }, : 'service',
: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' } : 'other'
}; };

View File

@ -0,0 +1,120 @@
.prompt-template-card {
border-radius: 1.25rem;
border: 1px solid var(--color-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(252, 252, 253, 1));
box-shadow: var(--shadow-sm);
padding: 1.25rem;
display: flex;
flex-direction: column;
min-height: 17.875rem;
}
.prompt-template-card-header,
.prompt-template-title-row {
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.875rem;
}
.prompt-template-card-header {
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.prompt-template-tag {
margin: 0;
border-radius: 999px;
padding-inline: 0.625rem;
height: 1.75rem;
line-height: 1.75rem;
font-size: 0.75rem;
font-weight: 700;
}
.prompt-template-visibility {
background: var(--color-surface-2);
color: var(--color-text-secondary);
}
.prompt-template-use-btn {
border-radius: 0.75rem;
height: 2.375rem;
font-weight: 700;
}
.prompt-template-title-block {
min-width: 0;
}
.prompt-template-title {
font-size: 1.1875rem;
font-weight: 800;
color: var(--color-text);
margin-bottom: 0.375rem;
}
.prompt-template-owner,
.prompt-template-footer {
color: var(--color-text-tertiary);
}
.prompt-template-owner {
font-size: 0.78125rem;
}
.prompt-template-use-count {
flex-shrink: 0;
padding: 0.375rem 0.625rem;
border-radius: 0.75rem;
background: rgba(15, 23, 42, 0.03);
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 700;
}
.prompt-template-body-card {
position: relative;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(255, 255, 255, 0.92));
border: 1px solid rgba(148, 163, 184, 0.14);
padding: 1rem 1rem 1.125rem;
min-height: 8.25rem;
overflow: hidden;
}
.prompt-template-body-card::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 0.25rem;
opacity: 0.18;
background: var(--prompt-category-color);
}
.prompt-template-body {
white-space: pre-wrap;
font-size: 0.84375rem;
color: var(--color-text-secondary);
line-height: 1.8;
min-height: 6rem;
max-height: 8.125rem;
overflow: hidden;
}
.prompt-template-footer {
align-items: center;
gap: 0.625rem;
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
font-size: 0.78125rem;
}
.prompt-template-footer-spacer {
flex: 1;
}
.prompt-template-text-btn {
border-radius: 0.625rem;
}

View File

@ -0,0 +1,23 @@
@media (max-width: 768px) {
.prompt-library-page-h5 .page-container {
padding: 1rem;
}
.prompt-library-page-h5 .prompt-library-hero {
border-radius: 1.125rem;
padding: 1.125rem;
}
.prompt-library-page-h5 .prompt-library-toolbar,
.prompt-library-page-h5 .prompt-template-grid {
grid-template-columns: 1fr;
}
.prompt-library-page-h5 .prompt-library-create-btn {
width: 100%;
}
.prompt-library-page-h5 .prompt-library-scope-list {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,59 @@
.prompt-category-general {
background: rgba(17, 103, 255, 0.1);
color: var(--color-brand);
}
.prompt-category-code {
background: rgba(30, 134, 255, 0.1);
color: #1d4ed8;
}
.prompt-category-writing {
background: rgba(126, 87, 255, 0.1);
color: #5b35d5;
}
.prompt-category-translate {
background: rgba(183, 121, 31, 0.12);
color: var(--color-warning);
}
.prompt-category-analysis {
background: rgba(34, 166, 255, 0.12);
color: #0369a1;
}
.prompt-category-service {
background: rgba(13, 159, 110, 0.12);
color: var(--color-success);
}
.prompt-category-other {
background: rgba(100, 116, 139, 0.12);
color: #475569;
}
.prompt-category-border-general,
.prompt-category-border-code {
--prompt-category-color: var(--color-brand);
}
.prompt-category-border-writing {
--prompt-category-color: #5b35d5;
}
.prompt-category-border-translate {
--prompt-category-color: var(--color-warning);
}
.prompt-category-border-analysis {
--prompt-category-color: #0369a1;
}
.prompt-category-border-service {
--prompt-category-color: var(--color-success);
}
.prompt-category-border-other {
--prompt-category-color: #475569;
}

View File

@ -0,0 +1,134 @@
.prompt-library-hero {
border-radius: 1.5rem;
padding: 1.75rem 1.75rem 1.5rem;
background: var(--gradient-hero);
border: 1px solid rgba(17, 103, 255, 0.12);
box-shadow: var(--shadow-md);
margin-bottom: 1.5rem;
}
.prompt-library-hero-header,
.prompt-template-card-header,
.prompt-template-title-row,
.prompt-template-footer {
display: flex;
align-items: flex-start;
}
.prompt-library-hero-header {
justify-content: space-between;
gap: 1.25rem;
flex-wrap: wrap;
margin-bottom: 1.375rem;
}
.prompt-library-hero-copy {
max-width: 38.75rem;
}
.prompt-library-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(17, 103, 255, 0.1);
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 700;
margin-bottom: 1rem;
}
.prompt-library-badge-icon,
.prompt-library-search-icon {
color: var(--color-brand);
}
.prompt-library-title {
margin-bottom: 0.625rem;
}
.prompt-library-subtitle {
margin-top: 0;
max-width: 35rem;
font-size: 0.9375rem;
line-height: 1.75;
}
.prompt-library-create-btn {
height: 2.875rem;
padding: 0 1.125rem;
font-weight: 700;
}
.prompt-library-toolbar {
display: grid;
grid-template-columns: minmax(0, 1.5fr) auto;
gap: 0.875rem;
align-items: center;
}
.prompt-library-search-input {
height: 3rem;
border-radius: 0.875rem;
background: rgba(255, 255, 255, 0.88);
}
.prompt-library-search-btn {
padding: 0;
height: 1.25rem;
}
.prompt-library-scope-list,
.prompt-template-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.prompt-library-scope-list {
justify-content: flex-end;
}
.prompt-library-scope-btn {
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-secondary);
border-radius: 999px;
padding: 0.625rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.prompt-library-scope-btn.active {
border-color: rgba(17, 103, 255, 0.18);
background: rgba(17, 103, 255, 0.1);
color: var(--color-brand);
font-weight: 700;
}
.prompt-template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21.25rem, 1fr));
gap: 1.125rem;
}
.prompt-template-state-card,
.prompt-template-empty-card {
border-radius: 1.375rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.prompt-template-state-card {
min-height: 17.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.prompt-template-empty-card {
padding: 3.375rem 1.5rem;
}

View File

@ -0,0 +1,123 @@
.shared-session-shell {
min-height: 100vh;
background: var(--color-bg);
}
.shared-session-container {
max-width: 56.25rem;
min-height: 100vh;
margin: 0 auto;
padding: 2rem 1.5rem;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.shared-session-spin {
display: block;
margin-top: 5rem;
}
.shared-session-empty {
margin-top: 5rem;
}
.shared-session-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.shared-session-eyebrow,
.shared-message-meta,
.shared-session-date {
color: var(--color-text-tertiary);
font-size: 0.75rem;
}
.shared-session-eyebrow {
margin-bottom: 0.25rem;
}
.shared-session-title {
margin: 0;
color: var(--color-text);
font-size: 1.5rem;
}
.shared-session-meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 0.75rem;
margin-top: 0.5rem;
color: var(--color-text-secondary);
}
.shared-session-description {
margin-top: 0.375rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.shared-message {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 1.125rem;
}
.shared-message-user {
align-items: flex-end;
}
.shared-message-meta {
margin-bottom: 0.25rem;
}
.shared-message-bubble {
max-width: 85%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.875rem;
background: var(--color-surface);
color: var(--color-text);
}
.shared-message-assistant .shared-message-bubble {
box-shadow: var(--shadow-xs);
}
.shared-message-user .shared-message-bubble {
background: var(--color-brand-soft);
}
.shared-message-text {
white-space: pre-wrap;
}
.shared-session-footer {
margin-top: 2.5rem;
color: var(--color-text-tertiary);
font-size: 0.75rem;
text-align: center;
}
.shared-session-footer a {
color: var(--color-brand);
}
@media (max-width: 768px) {
.shared-session-container {
width: 100%;
padding: 1.25rem 1rem;
box-shadow: none;
}
.shared-session-title {
font-size: 1.25rem;
line-height: 1.35;
}
.shared-message-bubble {
max-width: 100%;
}
}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Empty, Spin, App as AntApp } from 'antd'; import { Empty, Spin } from 'antd';
import { SharedAPI } from '../api'; import { SharedAPI } from '../api';
import Markdown from '../components/Markdown'; import Markdown from '../components/Markdown';
import './SharedSessionPage.css';
interface Data { interface Data {
agent: { name: string; description: string }; agent: { name: string; description: string };
@ -15,60 +16,46 @@ export default function SharedSessionPage() {
const [data, setData] = useState<Data | null>(null); const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>(''); const [err, setErr] = useState<string>('');
const { message } = AntApp.useApp();
const loginHref = `${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}/login`; const loginHref = `${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}/login`;
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) {
setErr('会话不存在或已失效');
setLoading(false);
return;
}
SharedAPI.get(token) SharedAPI.get(token)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
const msg = e?.response?.data?.error ?? e?.message ?? '加载失败'; const msg = e?.response?.data?.error ?? e?.message ?? '加载失败';
setErr(msg); setErr(msg);
message.error(msg);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]); }, [token]);
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />; if (loading) return <Spin className="shared-session-spin" />;
if (err || !data) if (err || !data)
return ( return (
<Empty <Empty
description={err || '会话不存在或已失效'} description={err || '会话不存在或已失效'}
style={{ marginTop: 80 }} className="shared-session-empty"
/> />
); );
return ( return (
<div style={{ minHeight: '100vh', background: 'var(--color-bg)' }}> <div className="shared-session-shell">
<div <div className="shared-session-container">
style={{ <div className="shared-session-header">
maxWidth: 900, <div className="shared-session-eyebrow"></div>
margin: '0 auto', <h1 className="shared-session-title">{data.session.title}</h1>
padding: '32px 24px', <div className="shared-session-meta">
background: 'var(--color-surface)', <span>🤖 {data.agent.name} · {data.messages.length} </span>
minHeight: '100vh', <span className="shared-session-date">
boxShadow: 'var(--shadow-lg)'
}}
>
<div
style={{
paddingBottom: 16,
borderBottom: '1px solid var(--color-border)',
marginBottom: 24
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div>
<h1 style={{ margin: 0, fontSize: 24, color: 'var(--color-text)' }}>{data.session.title}</h1>
<div style={{ marginTop: 8, color: 'var(--color-text-secondary)' }}>
🤖 {data.agent.name} · {data.messages.length}
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{new Date(data.session.updatedAt).toLocaleString('zh-CN')} {new Date(data.session.updatedAt).toLocaleString('zh-CN')}
</span> </span>
</div> </div>
{data.agent.description && ( {data.agent.description && (
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-secondary)' }}> <div className="shared-session-description">
{data.agent.description} {data.agent.description}
</div> </div>
)} )}
@ -77,52 +64,24 @@ export default function SharedSessionPage() {
{data.messages.map((m) => ( {data.messages.map((m) => (
<div <div
key={m.id} key={m.id}
style={{ className={`shared-message shared-message-${m.role}`}
marginBottom: 18,
display: 'flex',
flexDirection: 'column',
alignItems: m.role === 'user' ? 'flex-end' : 'flex-start'
}}
> >
<div <div className="shared-message-meta">
style={{
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginBottom: 4
}}
>
{m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '} {m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '}
{new Date(m.createdAt).toLocaleString('zh-CN')} {new Date(m.createdAt).toLocaleString('zh-CN')}
</div> </div>
<div <div className="shared-message-bubble">
style={{
maxWidth: '85%',
padding: '12px 16px',
borderRadius: 14,
background: m.role === 'user' ? 'var(--color-brand-soft)' : 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
boxShadow: m.role === 'assistant' ? 'var(--shadow-xs)' : 'none'
}}
>
{m.role === 'assistant' ? ( {m.role === 'assistant' ? (
<Markdown>{m.content}</Markdown> <Markdown>{m.content}</Markdown>
) : ( ) : (
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div> <div className="shared-message-text">{m.content}</div>
)} )}
</div> </div>
</div> </div>
))} ))}
<div <div className="shared-session-footer">
style={{ AI · <a href={loginHref}> agent</a>
textAlign: 'center',
color: 'var(--color-text-tertiary)',
fontSize: 12,
marginTop: 40
}}
>
Agent Studio · <a href={loginHref} style={{ color: 'var(--color-brand)' }}> agent</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,384 +1,14 @@
import { useEffect, useState } from 'react'; import { useIsMobile } from '../hooks/useIsMobile';
import { BarChartOutlined, DollarOutlined, GiftOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons'; import { useStatsPageLogic } from './StatsPage/StatsPageLogic';
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd'; import StatsPageH5 from './StatsPage/components/StatsPageH5';
import { Link } from 'react-router-dom'; import StatsPageWeb from './StatsPage/components/StatsPageWeb';
import { AgentTokenStats, StatsAPI, StatsOverview } from '../api'; import './StatsPage/styles/stats-page-shared.css';
import './StatsPage/styles/stats-page-web.css';
type StatsOverviewWithPoints = StatsOverview & { import './StatsPage/styles/stats-page-h5.css';
totalPoints?: number;
pointsEarned?: number;
};
let statsOverviewCache:
| { ts: number; data: StatsOverview | null; inFlight: Promise<StatsOverview> | null }
| undefined;
const getStatsOverviewCached = () => {
if (!statsOverviewCache) statsOverviewCache = { ts: 0, data: null, inFlight: null };
if (statsOverviewCache.inFlight) return statsOverviewCache.inFlight;
if (statsOverviewCache.data && Date.now() - statsOverviewCache.ts < 5000) return Promise.resolve(statsOverviewCache.data);
statsOverviewCache.inFlight = StatsAPI.overview()
.then((res) => {
statsOverviewCache!.data = res;
statsOverviewCache!.ts = Date.now();
return res;
})
.finally(() => {
if (statsOverviewCache) statsOverviewCache.inFlight = null;
});
return statsOverviewCache.inFlight;
};
export default function StatsPage() { export default function StatsPage() {
const { message } = AntApp.useApp(); const logic = useStatsPageLogic();
const [data, setData] = useState<StatsOverviewWithPoints | null>(null); const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
const [tokenAgentId, setTokenAgentId] = useState<string>('');
const [tokenDays, setTokenDays] = useState<number>(30);
const [tokenLimit, setTokenLimit] = useState<number>(10);
useEffect(() => { return isMobile ? <StatsPageH5 logic={logic} /> : <StatsPageWeb logic={logic} />;
setLoading(true);
let alive = true;
getStatsOverviewCached()
.then((res) => {
if (!alive) return;
const enhanced = res as StatsOverviewWithPoints;
const spent = enhanced.costUSD || 0;
enhanced.totalPoints = Math.floor(spent * 1000);
enhanced.pointsEarned = enhanced.totalPoints;
setData(enhanced);
if (!tokenAgentId && res.topAgents?.[0]?.id) {
setTokenAgentId(res.topAgents[0].id);
}
})
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
.finally(() => {
if (alive) setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (!tokenAgentId) return;
setTokenLoading(true);
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
.then((res) => {
setTokenData(res);
})
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
.finally(() => setTokenLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokenAgentId, tokenDays, tokenLimit]);
if (loading) return <Spin style={{ marginTop: 80, display: 'block' }} />;
if (!data) return <Empty description="暂无数据" style={{ marginTop: 80 }} />;
const maxDaily = Math.max(1, ...data.daily.map((d) => d.total));
const maxAgent = Math.max(1, ...data.topAgents.map((a) => a.messageCount));
const last7Days = data.daily.slice(-7).reduce((sum, item) => sum + item.total, 0);
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
const formatUSD = (v?: number | null) =>
`$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
const formatPoints = (v?: number | null) => Number(v || 0).toLocaleString();
const totalSpentUSD = data.costUSD || 0;
const totalPoints = Math.floor(totalSpentUSD * 1000);
return (
<div className="page-container">
<div className="stats-page-hero">
<div className="stats-page-header">
<div className="stats-page-title-section">
<div className="stats-page-badge">
<BarChartOutlined className="stats-page-badge-icon" />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<p className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
使
1 1000
</p>
</div>
<div className="stats-page-summary-card">
<div className="stats-page-summary-label"> 7 </div>
<div className="stats-page-summary-value">{last7Days}</div>
<div className="stats-page-summary-desc"> {avgSessionMessages} </div>
</div>
</div>
<div className="stats-page-cards-grid">
{[
{ icon: <RobotOutlined />, label: '智能体数量', value: data.agentCount, bgColor: 'rgba(8, 145, 178, 0.10)', textColor: 'var(--color-brand)' },
{ icon: <LineChartOutlined />, label: '会话总数', value: data.sessionCount, bgColor: 'rgba(14, 165, 233, 0.10)', textColor: 'var(--color-info)' },
{ icon: <MessageOutlined />, label: '消息总数', value: data.messageCount, bgColor: 'rgba(34, 197, 94, 0.10)', textColor: 'var(--color-success)' }
].map((item) => (
<div key={item.label} className="stats-page-card">
<div className="stats-page-card-label">
<span className="stats-page-card-icon" style={{ background: item.bgColor, color: item.textColor }}>
{item.icon}
</span>
{item.label}
</div>
<div className="stats-page-card-value">{item.value}</div>
</div>
))}
</div>
</div>
{/* 积分与消费关联展示 */}
<div className="points-integration-section">
<Card
title="消费与积分"
className="points-integration-card"
extra={
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
1 USD = 1000
</Tag>
}
>
<div className="points-integration-row">
<div className="points-integration-item">
<div className="points-integration-label">
<DollarOutlined style={{ color: 'var(--color-warning)' }} />
(USD)
</div>
<div className="points-integration-value">{formatUSD(totalSpentUSD)}</div>
<div className="points-integration-desc">API </div>
</div>
<div className="points-integration-item">
<div className="points-integration-label">
<GiftOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<div className="points-integration-value">{formatPoints(totalPoints)}</div>
<div className="points-integration-desc"></div>
</div>
<div className="points-integration-item">
<div className="points-integration-label">
<BarChartOutlined style={{ color: 'var(--color-success)' }} />
</div>
<div className="points-integration-value">1:1000</div>
<div className="points-integration-desc"> 1 1000 </div>
</div>
</div>
</Card>
</div>
<div className="stats-page-main-grid" style={{ marginTop: 18 }}>
<Card
title="最近 30 天消息走势"
className="stats-page-chart-card"
extra={<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}></Tag>}
>
<div className="stats-page-chart-desc">
绿
</div>
{data.daily.length === 0 ? (
<Empty description="近期无对话" />
) : (
<div className="stats-page-chart-container">
{data.daily.map((d) => {
const userH = (d.user / maxDaily) * 180;
const aH = (d.assistant / maxDaily) * 180;
return (
<div
key={d.day}
className="stats-page-chart-bar-group"
title={`${d.day}\n用户 ${d.user} · 助手 ${d.assistant}`}
>
<div className="stats-page-chart-bars">
<div className="stats-page-chart-bar-user" style={{ height: userH }} />
<div className="stats-page-chart-bar-assistant" style={{ height: aH }} />
</div>
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
</div>
);
})}
</div>
)}
<div className="stats-page-chart-legend">
<span className="stats-page-chart-legend-item">
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-brand)' }} />
</span>
<span className="stats-page-chart-legend-item">
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-success)' }} />
</span>
</div>
</Card>
<Card title="智能体活跃排行" className="stats-page-chart-card">
<div className="stats-page-chart-desc">
使
</div>
{data.topAgents.length === 0 ? (
<Empty description="暂无" />
) : (
<div>
{data.topAgents.map((a, index) => (
<div key={a.id} className="stats-page-agent-item">
<div className="stats-page-agent-header">
<div className="stats-page-agent-rank">{index + 1}</div>
<Link to={`/agents/${a.id}/chat`} className="stats-page-agent-name">
{a.name}
</Link>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
{a.messageCount}
</Tag>
</div>
<div className="stats-page-agent-progress">
<div className="stats-page-agent-progress-bar" style={{ width: `${(a.messageCount / maxAgent) * 100}%` }} />
</div>
</div>
))}
</div>
)}
</Card>
</div>
<div className="stats-page-token-section">
<Card
title="Token 使用量"
className="stats-page-chart-card"
extra={
<Space size={10} wrap>
<Select
size="small"
value={tokenAgentId || undefined}
style={{ minWidth: 220 }}
placeholder="选择智能体"
onChange={setTokenAgentId}
options={data.topAgents.map((a) => ({ value: a.id, label: a.name }))}
/>
<Select
size="small"
value={tokenDays}
style={{ width: 120 }}
onChange={setTokenDays}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' }
]}
/>
<Select
size="small"
value={tokenLimit}
style={{ width: 120 }}
onChange={setTokenLimit}
options={[
{ value: 5, label: 'Top 5' },
{ value: 10, label: 'Top 10' },
{ value: 20, label: 'Top 20' }
]}
/>
</Space>
}
>
{tokenLoading ? (
<Spin style={{ marginTop: 20, display: 'block' }} />
) : !tokenAgentId ? (
<Empty description="暂无可统计的智能体" style={{ marginTop: 20 }} />
) : !tokenData ? (
<Empty description="暂无 Token 统计数据" style={{ marginTop: 20 }} />
) : (
<>
<div className="stats-page-token-cards-grid">
{[
{ label: '总 Token', value: tokenData.totalTokens, color: 'var(--color-brand)' },
{ label: '输入 Token', value: tokenData.promptTokens, color: 'var(--color-info)' },
{ label: '输出 Token', value: tokenData.completionTokens, color: 'var(--color-success)' },
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), color: 'var(--color-warning)' }
].map((item) => (
<div key={item.label} className="stats-page-token-card">
<div className="stats-page-token-label">
<span className="stats-page-token-dot" style={{ background: item.color }} />
{item.label}
</div>
<div className="stats-page-token-value">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</div>
</div>
))}
</div>
<div className="stats-page-token-charts-grid">
<Card
size="small"
title="近 N 天趋势(总 Token"
className="stats-page-token-chart-card"
extra={
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
days={tokenData.days}
</Tag>
}
>
{tokenData.daily.length === 0 ? (
<Empty description="暂无" />
) : (
<div className="stats-page-token-chart-container">
{(() => {
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((d) => d.totalTokens));
return tokenData.daily.map((d) => {
const h = (d.totalTokens / maxTokenDaily) * 170;
return (
<div
key={d.day}
className="stats-page-chart-bar-group"
title={`${d.day}\nCalls ${d.calls}\nTotalTokens ${d.totalTokens}\nCost ${formatUSD(d.costUSD)}`}
>
<div className="stats-page-token-chart-bar" style={{ height: h }} />
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
</div>
);
});
})()}
</div>
)}
</Card>
<Card
size="small"
title="模型消耗排行"
className="stats-page-token-chart-card"
>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.providerKind}:${r.model}`}
dataSource={tokenData.byModel || []}
columns={[
{ title: 'Provider', dataIndex: 'providerKind', width: 110 },
{ title: 'Model', dataIndex: 'model' },
{ title: 'Calls', dataIndex: 'calls', width: 80 },
{
title: 'Tokens',
dataIndex: 'totalTokens',
width: 110,
render: (v) => Number(v || 0).toLocaleString()
},
{
title: 'Cost',
dataIndex: 'costUSD',
width: 110,
render: (v) => formatUSD(v)
}
]}
/>
</Card>
</div>
</>
)}
</Card>
</div>
</div>
);
} }

View File

@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { App as AntApp } from 'antd';
import { AgentTokenStats, StatsAPI, StatsOverview } from '../../api';
type StatsOverviewWithPoints = StatsOverview & {
totalPoints?: number;
pointsEarned?: number;
};
let statsOverviewCache:
| { ts: number; data: StatsOverview | null; inFlight: Promise<StatsOverview> | null }
| undefined;
const getStatsOverviewCached = () => {
if (!statsOverviewCache) statsOverviewCache = { ts: 0, data: null, inFlight: null };
if (statsOverviewCache.inFlight) return statsOverviewCache.inFlight;
if (statsOverviewCache.data && Date.now() - statsOverviewCache.ts < 5000) {
return Promise.resolve(statsOverviewCache.data);
}
statsOverviewCache.inFlight = StatsAPI.overview()
.then((res) => {
statsOverviewCache!.data = res;
statsOverviewCache!.ts = Date.now();
return res;
})
.finally(() => {
if (statsOverviewCache) statsOverviewCache.inFlight = null;
});
return statsOverviewCache.inFlight;
};
const clampLevel = (value: number, max: number) => {
if (max <= 0) return 0;
return Math.max(1, Math.min(10, Math.ceil((value / max) * 10)));
};
export function useStatsPageLogic() {
const { message } = AntApp.useApp();
const [data, setData] = useState<StatsOverviewWithPoints | null>(null);
const [loading, setLoading] = useState(false);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
const [tokenAgentId, setTokenAgentId] = useState('');
const [tokenDays, setTokenDays] = useState(30);
const [tokenLimit, setTokenLimit] = useState(10);
useEffect(() => {
setLoading(true);
let alive = true;
getStatsOverviewCached()
.then((res) => {
if (!alive) return;
const enhanced = res as StatsOverviewWithPoints;
const spent = enhanced.costUSD || 0;
enhanced.totalPoints = Math.floor(spent * 1000);
enhanced.pointsEarned = enhanced.totalPoints;
setData(enhanced);
if (!tokenAgentId && res.topAgents?.[0]?.id) {
setTokenAgentId(res.topAgents[0].id);
}
})
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (!tokenAgentId) return;
setTokenLoading(true);
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
.then(setTokenData)
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
.finally(() => setTokenLoading(false));
}, [message, tokenAgentId, tokenDays, tokenLimit]);
const maxDaily = Math.max(1, ...(data?.daily || []).map((d) => d.total));
const maxAgent = Math.max(1, ...(data?.topAgents || []).map((a) => a.messageCount));
const last7Days = (data?.daily || []).slice(-7).reduce((sum, item) => sum + item.total, 0);
const avgSessionMessages = data && data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
const totalSpentUSD = data?.costUSD || 0;
const totalPoints = Math.floor(totalSpentUSD * 1000);
const formatUSD = (value?: number | null) =>
`$${Number(value || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
const formatPoints = (value?: number | null) => Number(value || 0).toLocaleString();
return {
data,
loading,
tokenLoading,
tokenData,
tokenAgentId,
tokenDays,
tokenLimit,
setTokenAgentId,
setTokenDays,
setTokenLimit,
maxDaily,
maxAgent,
last7Days,
avgSessionMessages,
totalSpentUSD,
totalPoints,
formatUSD,
formatPoints,
getLevel: clampLevel
};
}
export type StatsPageLogic = ReturnType<typeof useStatsPageLogic>;

View File

@ -0,0 +1,27 @@
import { LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
import type { StatsPageLogic } from '../StatsPageLogic';
const CARD_META = [
{ key: 'agentCount', icon: <RobotOutlined />, label: '智能体数量', tone: 'brand' },
{ key: 'sessionCount', icon: <LineChartOutlined />, label: '会话总数', tone: 'info' },
{ key: 'messageCount', icon: <MessageOutlined />, label: '消息总数', tone: 'success' }
] as const;
export default function StatsMetricCards({ logic }: { logic: StatsPageLogic }) {
const { data } = logic;
if (!data) return null;
return (
<div className="stats-page-cards-grid">
{CARD_META.map((item) => (
<div key={item.key} className="stats-page-card">
<div className="stats-page-card-label">
<span className={`stats-page-card-icon stats-tone-${item.tone}`}>{item.icon}</span>
{item.label}
</div>
<div className="stats-page-card-value">{data[item.key].toLocaleString()}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,37 @@
import { BarChartOutlined } from '@ant-design/icons';
import { Empty, Spin } from 'antd';
import type { StatsPageLogic } from '../StatsPageLogic';
import StatsMetricCards from './StatsMetricCards';
import StatsPointsCard from './StatsPointsCard';
import StatsTokenSection from './StatsTokenSection';
import StatsTopAgentsCard from './StatsTopAgentsCard';
import StatsTrendCard from './StatsTrendCard';
export default function StatsPageH5({ logic }: { logic: StatsPageLogic }) {
if (logic.loading) return <Spin className="stats-state-spin" />;
if (!logic.data) return <Empty description="暂无数据" className="stats-state-empty" />;
return (
<div className="stats-page-h5">
<div className="stats-h5-hero">
<div className="stats-page-badge">
<BarChartOutlined className="stats-page-badge-icon" />
</div>
<h1 className="stats-h5-title"></h1>
<p className="stats-h5-subtitle"></p>
<div className="stats-h5-summary">
<span> 7 </span>
<strong>{logic.last7Days}</strong>
<span></span>
</div>
</div>
<StatsMetricCards logic={logic} />
<StatsPointsCard logic={logic} />
<StatsTrendCard logic={logic} />
<StatsTopAgentsCard logic={logic} />
<StatsTokenSection logic={logic} />
</div>
);
}

View File

@ -0,0 +1,49 @@
import { BarChartOutlined } from '@ant-design/icons';
import { Empty, Spin } from 'antd';
import type { StatsPageLogic } from '../StatsPageLogic';
import StatsMetricCards from './StatsMetricCards';
import StatsPointsCard from './StatsPointsCard';
import StatsTokenSection from './StatsTokenSection';
import StatsTopAgentsCard from './StatsTopAgentsCard';
import StatsTrendCard from './StatsTrendCard';
export default function StatsPageWeb({ logic }: { logic: StatsPageLogic }) {
if (logic.loading) return <Spin className="stats-state-spin" />;
if (!logic.data) return <Empty description="暂无数据" className="stats-state-empty" />;
return (
<div className="page-container stats-page-web">
<div className="stats-page-hero">
<div className="stats-page-header">
<div className="stats-page-title-section">
<div className="stats-page-badge">
<BarChartOutlined className="stats-page-badge-icon" />
</div>
<h1 className="page-title stats-page-title"></h1>
<p className="page-subtitle stats-page-subtitle">
使
1 1000
</p>
</div>
<div className="stats-page-summary-card">
<div className="stats-page-summary-label"> 7 </div>
<div className="stats-page-summary-value">{logic.last7Days}</div>
<div className="stats-page-summary-desc"> {logic.avgSessionMessages} </div>
</div>
</div>
<StatsMetricCards logic={logic} />
</div>
<StatsPointsCard logic={logic} />
<div className="stats-page-main-grid stats-page-mt-18">
<StatsTrendCard logic={logic} />
<StatsTopAgentsCard logic={logic} />
</div>
<StatsTokenSection logic={logic} />
</div>
);
}

View File

@ -0,0 +1,42 @@
import { BarChartOutlined, DollarOutlined, GiftOutlined } from '@ant-design/icons';
import { Card, Tag } from 'antd';
import type { StatsPageLogic } from '../StatsPageLogic';
export default function StatsPointsCard({ logic }: { logic: StatsPageLogic }) {
return (
<div className="points-integration-section">
<Card
title="消费与积分"
className="points-integration-card"
extra={<Tag bordered={false} className="stats-soft-tag">1 USD = 1000 </Tag>}
>
<div className="points-integration-row">
<div className="points-integration-item">
<div className="points-integration-label">
<DollarOutlined className="stats-tone-warning-text" />
(USD)
</div>
<div className="points-integration-value">{logic.formatUSD(logic.totalSpentUSD)}</div>
<div className="points-integration-desc">API </div>
</div>
<div className="points-integration-item">
<div className="points-integration-label">
<GiftOutlined className="stats-tone-brand-text" />
</div>
<div className="points-integration-value">{logic.formatPoints(logic.totalPoints)}</div>
<div className="points-integration-desc"></div>
</div>
<div className="points-integration-item">
<div className="points-integration-label">
<BarChartOutlined className="stats-tone-success-text" />
</div>
<div className="points-integration-value">1:1000</div>
<div className="points-integration-desc"> 1 1000 </div>
</div>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { Card, Empty, Select, Space, Spin, Table, Tag } from 'antd';
import type { StatsPageLogic } from '../StatsPageLogic';
export default function StatsTokenSection({ logic }: { logic: StatsPageLogic }) {
const { data, tokenData, tokenLoading } = logic;
if (!data) return null;
return (
<div className="stats-page-token-section">
<Card title="Token 使用量" className="stats-page-chart-card" extra={<TokenControls logic={logic} />}>
{tokenLoading ? (
<Spin className="stats-state-spin-small" />
) : !logic.tokenAgentId ? (
<Empty description="暂无可统计的智能体" className="stats-state-empty-small" />
) : !tokenData ? (
<Empty description="暂无 Token 统计数据" className="stats-state-empty-small" />
) : (
<>
<div className="stats-page-token-cards-grid">
{[
{ label: '总 Token', value: tokenData.totalTokens, tone: 'brand' },
{ label: '输入 Token', value: tokenData.promptTokens, tone: 'info' },
{ label: '输出 Token', value: tokenData.completionTokens, tone: 'success' },
{ label: '费用 (USD)', value: logic.formatUSD(tokenData.costUSD), tone: 'warning' }
].map((item) => (
<div key={item.label} className="stats-page-token-card">
<div className="stats-page-token-label">
<span className={`stats-page-token-dot stats-dot-${item.tone}`} />
{item.label}
</div>
<div className="stats-page-token-value">{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}</div>
</div>
))}
</div>
<TokenCharts logic={logic} />
</>
)}
</Card>
</div>
);
}
function TokenControls({ logic }: { logic: StatsPageLogic }) {
return (
<Space size={10} wrap className="stats-token-controls">
<Select
size="small"
value={logic.tokenAgentId || undefined}
className="stats-token-agent-select"
placeholder="选择智能体"
onChange={logic.setTokenAgentId}
options={(logic.data?.topAgents || []).map((agent) => ({ value: agent.id, label: agent.name }))}
/>
<Select
size="small"
value={logic.tokenDays}
className="stats-token-small-select"
onChange={logic.setTokenDays}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' }
]}
/>
<Select
size="small"
value={logic.tokenLimit}
className="stats-token-small-select"
onChange={logic.setTokenLimit}
options={[
{ value: 5, label: 'Top 5' },
{ value: 10, label: 'Top 10' },
{ value: 20, label: 'Top 20' }
]}
/>
</Space>
);
}
function TokenCharts({ logic }: { logic: StatsPageLogic }) {
const tokenData = logic.tokenData;
if (!tokenData) return null;
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((item) => item.totalTokens));
return (
<div className="stats-page-token-charts-grid">
<Card size="small" title="近 N 天趋势(总 Token" className="stats-page-token-chart-card" extra={<Tag bordered={false} className="stats-neutral-tag">days={tokenData.days}</Tag>}>
{tokenData.daily.length === 0 ? (
<Empty description="暂无" />
) : (
<div className="stats-page-token-chart-container">
{tokenData.daily.map((item) => (
<div key={item.day} className="stats-page-chart-bar-group" title={`${item.day}\nCalls ${item.calls}\nTotalTokens ${item.totalTokens}\nCost ${logic.formatUSD(item.costUSD)}`}>
<div className={`stats-page-token-chart-bar stats-bar-level-${logic.getLevel(item.totalTokens, maxTokenDaily)}`} />
<div className="stats-page-chart-label">{item.day.slice(5)}</div>
</div>
))}
</div>
)}
</Card>
<Card size="small" title="模型消耗排行" className="stats-page-token-chart-card">
<Table
size="small"
pagination={false}
rowKey={(row) => `${row.providerKind}:${row.model}`}
dataSource={tokenData.byModel || []}
columns={[
{ title: 'Provider', dataIndex: 'providerKind', width: 110 },
{ title: 'Model', dataIndex: 'model' },
{ title: 'Calls', dataIndex: 'calls', width: 80 },
{ title: 'Tokens', dataIndex: 'totalTokens', width: 110, render: (value) => Number(value || 0).toLocaleString() },
{ title: 'Cost', dataIndex: 'costUSD', width: 110, render: (value) => logic.formatUSD(value) }
]}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { Card, Empty, Tag } from 'antd';
import { Link } from 'react-router-dom';
import type { StatsPageLogic } from '../StatsPageLogic';
export default function StatsTopAgentsCard({ logic }: { logic: StatsPageLogic }) {
const { data, maxAgent, getLevel } = logic;
if (!data) return null;
return (
<Card title="智能体活跃排行" className="stats-page-chart-card">
<div className="stats-page-chart-desc">使</div>
{data.topAgents.length === 0 ? (
<Empty description="暂无" />
) : (
<div>
{data.topAgents.map((agent, index) => (
<div key={agent.id} className="stats-page-agent-item">
<div className="stats-page-agent-header">
<div className="stats-page-agent-rank">{index + 1}</div>
<Link to={`/agents/${agent.id}/chat`} className="stats-page-agent-name">
{agent.name}
</Link>
<Tag bordered={false} className="stats-neutral-tag">
{agent.messageCount}
</Tag>
</div>
<div className="stats-page-agent-progress">
<div className={`stats-page-agent-progress-bar stats-progress-level-${getLevel(agent.messageCount, maxAgent)}`} />
</div>
</div>
))}
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,42 @@
import { Card, Empty, Tag } from 'antd';
import type { StatsPageLogic } from '../StatsPageLogic';
export default function StatsTrendCard({ logic }: { logic: StatsPageLogic }) {
const { data, maxDaily, getLevel } = logic;
if (!data) return null;
return (
<Card
title="最近 30 天消息走势"
className="stats-page-chart-card"
extra={<Tag bordered={false} className="stats-soft-tag"></Tag>}
>
<div className="stats-page-chart-desc"></div>
{data.daily.length === 0 ? (
<Empty description="近期无对话" />
) : (
<div className="stats-page-chart-container">
{data.daily.map((item) => (
<div key={item.day} className="stats-page-chart-bar-group" title={`${item.day}\n用户 ${item.user} · 助手 ${item.assistant}`}>
<div className="stats-page-chart-bars">
<div className={`stats-page-chart-bar-user stats-bar-level-${getLevel(item.user, maxDaily)}`} />
<div className={`stats-page-chart-bar-assistant stats-bar-level-${getLevel(item.assistant, maxDaily)}`} />
</div>
<div className="stats-page-chart-label">{item.day.slice(5)}</div>
</div>
))}
</div>
)}
<div className="stats-page-chart-legend">
<span className="stats-page-chart-legend-item">
<span className="stats-page-chart-legend-dot stats-dot-brand" />
</span>
<span className="stats-page-chart-legend-item">
<span className="stats-page-chart-legend-dot stats-dot-success" />
</span>
</div>
</Card>
);
}

View File

@ -0,0 +1,76 @@
.stats-page-h5 {
padding: 1rem;
background: var(--color-bg);
}
.stats-h5-hero {
padding: 1.125rem;
border: 1px solid var(--color-border);
border-radius: 1.125rem;
background: var(--gradient-hero);
box-shadow: var(--shadow-sm);
}
.stats-h5-title {
margin: 0.875rem 0 0;
color: var(--color-text);
font-size: 1.875rem;
line-height: 1.15;
font-weight: 900;
}
.stats-h5-subtitle {
margin: 0.75rem 0 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.7;
}
.stats-h5-summary {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin-top: 1rem;
padding: 0.75rem;
border-radius: 0.875rem;
background: rgba(255, 255, 255, 0.78);
color: var(--color-text-secondary);
}
.stats-h5-summary strong {
color: var(--color-text);
font-size: 1.75rem;
}
.stats-page-h5 .stats-page-cards-grid,
.stats-page-h5 .points-integration-row,
.stats-page-h5 .stats-page-token-cards-grid,
.stats-page-h5 .stats-page-token-charts-grid {
grid-template-columns: 1fr;
}
.stats-page-h5 .stats-page-cards-grid,
.stats-page-h5 .points-integration-section,
.stats-page-h5 .stats-page-chart-card,
.stats-page-h5 .stats-page-token-section {
margin-top: 0.875rem;
}
.stats-page-h5 .stats-page-chart-container,
.stats-page-h5 .stats-page-token-chart-container {
overflow-x: auto;
padding-bottom: 0.25rem;
}
.stats-page-h5 .stats-page-chart-bar-group {
min-width: 1.625rem;
}
.stats-page-h5 .stats-token-controls {
width: 100%;
}
.stats-page-h5 .stats-token-agent-select,
.stats-page-h5 .stats-token-small-select {
width: 100%;
}

View File

@ -0,0 +1,152 @@
.stats-state-spin,
.stats-state-empty {
display: block;
margin-top: 5rem;
}
.stats-state-spin-small,
.stats-state-empty-small {
display: block;
margin-top: 1.25rem;
}
.stats-soft-tag,
.stats-neutral-tag {
border-radius: 999px;
}
.stats-soft-tag {
background: var(--color-brand-soft);
color: var(--color-brand);
}
.stats-neutral-tag {
background: var(--color-surface-2);
color: var(--color-text-secondary);
}
.stats-tone-brand,
.stats-dot-brand {
background: rgba(17, 103, 255, 0.1);
color: var(--color-brand);
}
.stats-tone-info,
.stats-dot-info {
background: rgba(30, 134, 255, 0.1);
color: var(--color-info);
}
.stats-tone-success,
.stats-dot-success {
background: rgba(13, 159, 110, 0.1);
color: var(--color-success);
}
.stats-dot-warning {
background: rgba(183, 121, 31, 0.12);
}
.stats-tone-brand-text {
color: var(--color-brand);
}
.stats-tone-success-text {
color: var(--color-success);
}
.stats-tone-warning-text {
color: var(--color-warning);
}
.stats-bar-level-0 {
height: 0.5rem;
}
.stats-bar-level-1 {
height: 1.125rem;
}
.stats-bar-level-2 {
height: 2rem;
}
.stats-bar-level-3 {
height: 3rem;
}
.stats-bar-level-4 {
height: 4rem;
}
.stats-bar-level-5 {
height: 5rem;
}
.stats-bar-level-6 {
height: 6.25rem;
}
.stats-bar-level-7 {
height: 7.5rem;
}
.stats-bar-level-8 {
height: 8.75rem;
}
.stats-bar-level-9 {
height: 10rem;
}
.stats-bar-level-10 {
height: 11.25rem;
}
.stats-progress-level-1 {
width: 10%;
}
.stats-progress-level-2 {
width: 20%;
}
.stats-progress-level-3 {
width: 30%;
}
.stats-progress-level-4 {
width: 40%;
}
.stats-progress-level-5 {
width: 50%;
}
.stats-progress-level-6 {
width: 60%;
}
.stats-progress-level-7 {
width: 70%;
}
.stats-progress-level-8 {
width: 80%;
}
.stats-progress-level-9 {
width: 90%;
}
.stats-progress-level-10 {
width: 100%;
}
.stats-token-agent-select {
min-width: 13.75rem;
}
.stats-token-small-select {
width: 7.5rem;
}

View File

@ -0,0 +1,21 @@
.stats-page-web {
max-width: 1400px;
}
.stats-page-web .stats-page-title {
margin-bottom: 0.625rem;
}
.stats-page-web .stats-page-subtitle {
margin-top: 0;
font-size: 0.9375rem;
line-height: 1.75;
}
.stats-page-web .stats-page-main-grid {
margin-top: 1.125rem;
}
.stats-page-web .stats-page-card-icon {
background: var(--color-brand-soft);
}

View File

@ -1,287 +1,36 @@
import { useEffect, useState } from 'react'; import { useIsMobile } from '../../hooks/useIsMobile';
import { ApartmentOutlined, ClockCircleOutlined, PlayCircleOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { useWorkflowsPageLogic } from './WorkflowsPageLogic';
import { Button, Empty, Tag, App as AntApp, Popconfirm } from 'antd';
import type { Workflow } from '../../api';
import { WorkflowAPI } from '../../api';
import { SAMPLE_GRAPH } from './constants';
import WorkflowEditorDrawer from './components/WorkflowEditorDrawer';
import RunsDrawer from './components/RunsDrawer'; import RunsDrawer from './components/RunsDrawer';
import WorkflowEditorDrawer from './components/WorkflowEditorDrawer';
import WorkflowsPageH5 from './components/WorkflowsPageH5';
import WorkflowsPageWeb from './components/WorkflowsPageWeb';
import './styles/workflows-page-shared.css';
import './styles/workflows-drawer.css';
import './styles/workflows-page-web.css';
import './styles/workflows-page-h5.css';
export default function WorkflowsPage() { export default function WorkflowsPage() {
const { message } = AntApp.useApp(); const isMobile = useIsMobile();
const [list, setList] = useState<Workflow[]>([]); const logic = useWorkflowsPageLogic({ enabled: false });
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<Workflow | null>(null);
const [runsOpen, setRunsOpen] = useState(false);
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
const load = async () => {
setLoading(true);
try {
const data = await WorkflowAPI.list();
setList(data);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const onCreate = () => {
setEditing({
id: '',
name: '新工作流',
description: '',
graph: SAMPLE_GRAPH,
scheduleCron: '',
scheduleEnabled: false,
enabled: true,
lastRunAt: 0,
runCount: 0,
createdAt: 0,
updatedAt: 0
});
setEditorOpen(true);
};
const onEdit = (w: Workflow) => {
setEditing(w);
setEditorOpen(true);
};
const onDelete = async (w: Workflow) => {
await WorkflowAPI.remove(w.id);
message.success('已删除');
load();
};
return ( return (
<div className="feature-cover-container"> <div className="feature-cover-container workflows-page-shell">
<div className="page-container" style={{ maxWidth: 1400 }}> {isMobile ? <WorkflowsPageH5 logic={logic} /> : <WorkflowsPageWeb logic={logic} />}
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap', marginBottom: 20 }}>
<div style={{ maxWidth: 680 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<ApartmentOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}>
</h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AgentHTTP
</div>
</div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}> {logic.editing && (
{[ <WorkflowEditorDrawer
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' }, open={logic.editorOpen}
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }, workflow={logic.editing}
{ onClose={() => logic.setEditorOpen(false)}
label: '定时运行', onSaved={logic.handleSaved}
value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, />
tone: 'rgba(249, 115, 22, 0.10)', )}
color: 'var(--color-warning)'
}
].map((item) => (
<div
key={item.label}
style={{ borderRadius: 18, padding: '16px 18px', background: 'rgba(255,255,255,0.72)', border: '1px solid rgba(255,255,255,0.7)' }}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span style={{ borderRadius: 999, padding: '4px 8px', background: item.tone, color: item.color, fontSize: 12, fontWeight: 600 }}>
</span>
</div>
</div>
))}
</div>
</div>
{loading ? null : list.length === 0 ? ( {logic.runsFor && (
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}> <RunsDrawer open={logic.runsOpen} workflow={logic.runsFor} onClose={() => logic.setRunsOpen(false)} />
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" /> )}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
{list.map((r) => (
<div
key={r.id}
style={{
borderRadius: 20,
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
padding: 20,
minHeight: 308,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
</div>
</div>
<Tag
bordered={false}
style={{
margin: 0,
borderRadius: 999,
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
height: 28,
lineHeight: '28px'
}}
>
{r.enabled ? '已启用' : '已停用'}
</Tag>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}> <div className="feature-cover"></div>
<div
style={{
borderRadius: 16,
padding: '14px 14px 12px',
background: 'rgba(8, 145, 178, 0.06)',
border: '1px solid rgba(8, 145, 178, 0.10)'
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.graph?.nodes?.length ?? 0}</div>
</div>
<div
style={{
borderRadius: 16,
padding: '14px 14px 12px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.10)'
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.runCount}</div>
</div>
<div
style={{
borderRadius: 16,
padding: '14px 14px 12px',
background: 'rgba(249, 115, 22, 0.06)',
border: '1px solid rgba(249, 115, 22, 0.10)'
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}</div>
</div>
</div>
<div
style={{
borderRadius: 16,
padding: '16px 16px 14px',
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
marginBottom: 16
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
<ClockCircleOutlined />
</div>
{r.scheduleEnabled && r.scheduleCron ? (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
cron: {r.scheduleCron}
</Tag>
) : (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
</Tag>
)}
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
{r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
<Button
icon={<PlayCircleOutlined />}
onClick={() => {
setRunsFor(r);
setRunsOpen(true);
}}
style={{ borderRadius: 12, height: 40 }}
>
/
</Button>
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
<Button danger style={{ borderRadius: 12, height: 40 }}>
</Button>
</Popconfirm>
</div>
</div>
))}
</div>
)}
{editing && (
<WorkflowEditorDrawer
open={editorOpen}
workflow={editing}
onClose={() => setEditorOpen(false)}
onSaved={() => {
setEditorOpen(false);
load();
}}
/>
)}
{runsFor && <RunsDrawer open={runsOpen} workflow={runsFor} onClose={() => setRunsOpen(false)} />}
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { App as AntApp } from 'antd';
import type { Workflow } from '../../api';
import { WorkflowAPI } from '../../api';
import { SAMPLE_GRAPH } from './constants';
interface WorkflowsPageLogicOptions {
enabled?: boolean;
}
export function useWorkflowsPageLogic(options: WorkflowsPageLogicOptions = {}) {
const enabled = options.enabled ?? true;
const { message } = AntApp.useApp();
const [list, setList] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<Workflow | null>(null);
const [runsOpen, setRunsOpen] = useState(false);
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
const load = async () => {
if (!enabled) return;
setLoading(true);
try {
const data = await WorkflowAPI.list();
setList(data);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (enabled) load();
}, [enabled]);
const onCreate = () => {
setEditing({
id: '',
name: '新工作流',
description: '',
graph: SAMPLE_GRAPH,
scheduleCron: '',
scheduleEnabled: false,
enabled: true,
lastRunAt: 0,
runCount: 0,
createdAt: 0,
updatedAt: 0
});
setEditorOpen(true);
};
const onEdit = (workflow: Workflow) => {
setEditing(workflow);
setEditorOpen(true);
};
const onDelete = async (workflow: Workflow) => {
await WorkflowAPI.remove(workflow.id);
message.success('已删除');
load();
};
const openRuns = (workflow: Workflow) => {
setRunsFor(workflow);
setRunsOpen(true);
};
const handleSaved = () => {
setEditorOpen(false);
load();
};
const stats = useMemo(
() => ({
total: list.length,
enabled: list.filter((item) => item.enabled).length,
scheduled: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length
}),
[list]
);
return {
list,
loading,
editorOpen,
editing,
runsOpen,
runsFor,
stats,
setEditorOpen,
setRunsOpen,
onCreate,
onEdit,
onDelete,
openRuns,
handleSaved
};
}
export type WorkflowsPageLogic = ReturnType<typeof useWorkflowsPageLogic>;

View File

@ -85,7 +85,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
setDetail(null); setDetail(null);
onClose(); onClose();
}} }}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }} className="workflow-drawer"
> >
<Tabs <Tabs
activeKey={tab} activeKey={tab}
@ -101,12 +101,12 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
<Input.TextArea rows={4} value={inputJson} onChange={(e) => setInputJson(e.target.value)} placeholder='{"text":"hello"}' /> <Input.TextArea rows={4} value={inputJson} onChange={(e) => setInputJson(e.target.value)} placeholder='{"text":"hello"}' />
</Form.Item> </Form.Item>
</Form> </Form>
<Space style={{ marginBottom: 12 }}> <Space className="workflow-drawer-switch-row">
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}> <Button type="primary" loading={streaming} onClick={onRunStream} className="workflow-drawer-save">
</Button> </Button>
<Button <Button
style={{ borderRadius: 10 }} className="workflow-drawer-save"
onClick={async () => { onClick={async () => {
try { try {
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}')); const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
@ -122,9 +122,9 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
</Space> </Space>
{steps.length > 0 && ( {steps.length > 0 && (
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}> <Card size="small" title="实时进度" className="workflow-drawer-card workflow-drawer-card-bottom">
{steps.map((s, i) => ( {steps.map((s, i) => (
<div key={i} style={{ marginBottom: 8 }}> <div key={i} className="workflow-run-step">
<Space> <Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag> <Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text> <Text strong>{s.nodeId}</Text>
@ -132,12 +132,12 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>} {s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
</Space> </Space>
{s.error && ( {s.error && (
<Paragraph type="danger" style={{ marginBottom: 0 }}> <Paragraph type="danger" className="workflow-run-error">
{s.error} {s.error}
</Paragraph> </Paragraph>
)} )}
{s.output != null && ( {s.output != null && (
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}> <pre className="workflow-run-pre workflow-run-pre-compact">
{JSON.stringify(s.output, null, 2)} {JSON.stringify(s.output, null, 2)}
</pre> </pre>
)} )}
@ -147,8 +147,8 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
)} )}
{finalRun && ( {finalRun && (
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}> <Card size="small" title="最终结果" className="workflow-drawer-card">
<pre style={{ fontSize: 12, margin: 0 }}>{JSON.stringify(finalRun, null, 2)}</pre> <pre className="workflow-run-pre workflow-run-pre-plain">{JSON.stringify(finalRun, null, 2)}</pre>
</Card> </Card>
)} )}
</div> </div>
@ -159,7 +159,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
label: `历史 (${runs.length})`, label: `历史 (${runs.length})`,
children: ( children: (
<div> <div>
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}> <Button size="small" onClick={loadRuns} className="workflow-history-refresh">
</Button> </Button>
<Table<WorkflowRun> <Table<WorkflowRun>
@ -181,7 +181,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
<b></b> <b></b>
<Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag> <Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>
{detail.error && ( {detail.error && (
<Text type="danger" style={{ marginLeft: 8 }}> <Text type="danger" className="workflow-detail-error">
{detail.error} {detail.error}
</Text> </Text>
)} )}
@ -193,21 +193,21 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
<p> <p>
<b>Input</b> <b>Input</b>
</p> </p>
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre> <pre className="workflow-run-pre">{JSON.stringify(detail.input, null, 2)}</pre>
<p> <p>
<b>Steps</b> <b>Steps</b>
</p> </p>
{detail.steps.map((s) => ( {detail.steps.map((s) => (
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}> <Card size="small" key={s.id} className="workflow-drawer-card workflow-drawer-card-bottom">
<Space> <Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag> <Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text> <Text strong>{s.nodeId}</Text>
<Text type="secondary">{s.nodeType}</Text> <Text type="secondary">{s.nodeType}</Text>
<Text type="secondary">{s.durationMs} ms</Text> <Text type="secondary">{s.durationMs} ms</Text>
</Space> </Space>
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>} {s.error && <p className="workflow-run-error-text">{s.error}</p>}
{s.output != null && ( {s.output != null && (
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>{JSON.stringify(s.output, null, 2)}</pre> <pre className="workflow-run-pre workflow-run-pre-detail">{JSON.stringify(s.output, null, 2)}</pre>
)} )}
</Card> </Card>
))} ))}
@ -221,4 +221,3 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
</Drawer> </Drawer>
); );
} }

View File

@ -0,0 +1,69 @@
import { ClockCircleOutlined, PlayCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Button, Popconfirm, Tag } from 'antd';
import type { Workflow } from '../../../api';
export default function WorkflowCard(props: {
workflow: Workflow;
compact?: boolean;
onEdit: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
onOpenRuns: (workflow: Workflow) => void;
}) {
const { workflow, compact, onEdit, onDelete, onOpenRuns } = props;
return (
<div className={`workflow-card${compact ? ' workflow-card-h5' : ''}`}>
<div className="workflow-card-header">
<div className="workflow-card-title-block">
<div className="workflow-card-title">{workflow.name}</div>
<div className="workflow-card-desc">{workflow.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}</div>
</div>
<Tag bordered={false} className={workflow.enabled ? 'workflow-status workflow-status-on' : 'workflow-status'}>
{workflow.enabled ? '已启用' : '已停用'}
</Tag>
</div>
<div className="workflow-card-metrics">
<Metric label="节点数" value={workflow.graph?.nodes?.length ?? 0} tone="brand" />
<Metric label="运行次数" value={workflow.runCount} tone="success" />
<Metric label="触发方式" value={workflow.scheduleEnabled && workflow.scheduleCron ? '定时' : '手动'} tone="warning" />
</div>
<div className="workflow-card-schedule">
<div className="workflow-card-schedule-label">
<ClockCircleOutlined />
</div>
{workflow.scheduleEnabled && workflow.scheduleCron ? (
<Tag bordered={false} className="workflow-status workflow-status-brand">cron: {workflow.scheduleCron}</Tag>
) : (
<Tag bordered={false} className="workflow-status"></Tag>
)}
<div className="workflow-card-schedule-time">
{workflow.lastRunAt ? new Date(workflow.lastRunAt).toLocaleString() : '尚未运行'}
</div>
</div>
<div className="workflow-card-actions">
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(workflow)} className="workflow-action-primary">
</Button>
<Button icon={<PlayCircleOutlined />} onClick={() => onOpenRuns(workflow)} className="workflow-action-secondary">
/
</Button>
<Popconfirm title="删除?" onConfirm={() => onDelete(workflow)}>
<Button danger className="workflow-action-secondary"></Button>
</Popconfirm>
</div>
</div>
);
}
function Metric({ label, value, tone }: { label: string; value: number | string; tone: string }) {
return (
<div className={`workflow-metric workflow-metric-${tone}`}>
<div className="workflow-metric-label">{label}</div>
<div className="workflow-metric-value">{value}</div>
</div>
);
}

View File

@ -65,9 +65,9 @@ export default function WorkflowEditorDrawer(props: {
width={840} width={840}
open={open} open={open}
onClose={onClose} onClose={onClose}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }} className="workflow-drawer"
extra={ extra={
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}> <Button type="primary" loading={saving} onClick={onSave} className="workflow-drawer-save">
</Button> </Button>
} }
@ -79,7 +79,7 @@ export default function WorkflowEditorDrawer(props: {
<Form.Item label="描述" name="description"> <Form.Item label="描述" name="description">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Space size="large" style={{ marginBottom: 12 }}> <Space size="large" className="workflow-drawer-switch-row">
<Form.Item name="enabled" valuePropName="checked" noStyle> <Form.Item name="enabled" valuePropName="checked" noStyle>
<Switch checkedChildren="启用" unCheckedChildren="禁用" /> <Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item> </Form.Item>
@ -91,7 +91,7 @@ export default function WorkflowEditorDrawer(props: {
label={ label={
<Space> <Space>
<span>Cron </span> <span>Cron </span>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" className="workflow-drawer-help">
5 \"*/30 * * * *\" 5 \"*/30 * * * *\"
</Text> </Text>
</Space> </Space>
@ -118,10 +118,10 @@ export default function WorkflowEditorDrawer(props: {
<Card <Card
size="small" size="small"
title={<Text strong>Graph JSON</Text>} title={<Text strong>Graph JSON</Text>}
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }} className="workflow-drawer-card workflow-drawer-card-spaced"
extra={ extra={
<Tooltip title="还原为示例"> <Tooltip title="还原为示例">
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}> <Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} className="workflow-drawer-small-btn">
</Button> </Button>
</Tooltip> </Tooltip>
@ -131,9 +131,9 @@ export default function WorkflowEditorDrawer(props: {
rows={20} rows={20}
value={graphText} value={graphText}
onChange={(e) => setGraphText(e.target.value)} onChange={(e) => setGraphText(e.target.value)}
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }} className="workflow-graph-textarea"
/> />
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}> <Paragraph type="secondary" className="workflow-drawer-help-block">
<code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code> <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>
<br /> <br />
agent / skill / http / transform / branchbranch <code>condition</code> bool JS next/elseNext agent / skill / http / transform / branchbranch <code>condition</code> bool JS next/elseNext
@ -147,19 +147,19 @@ function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
const [type, setType] = useState<WorkflowNodeType>('agent'); const [type, setType] = useState<WorkflowNodeType>('agent');
const [id, setId] = useState(''); const [id, setId] = useState('');
return ( return (
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}> <Card size="small" title="快速追加节点" className="workflow-drawer-card workflow-drawer-card-spaced">
<Space wrap> <Space wrap>
<Select <Select
value={type} value={type}
style={{ width: 140 }} className="workflow-node-type-select"
onChange={(v) => setType(v as WorkflowNodeType)} onChange={(v) => setType(v as WorkflowNodeType)}
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({ value: k, label }))} options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({ value: k, label }))}
/> />
<Input placeholder="节点 id如 step1" value={id} onChange={(e) => setId(e.target.value)} style={{ width: 200 }} /> <Input placeholder="节点 id如 step1" value={id} onChange={(e) => setId(e.target.value)} className="workflow-node-id-input" />
<Button <Button
type="primary" type="primary"
disabled={!id.trim()} disabled={!id.trim()}
style={{ borderRadius: 10 }} className="workflow-drawer-save"
onClick={() => { onClick={() => {
const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' }; const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' };
if (type === 'branch') base.elseNext = ''; if (type === 'branch') base.elseNext = '';
@ -188,4 +188,3 @@ function defaultConfig(type: WorkflowNodeType): Record<string, any> {
return { condition: 'steps.prev?.output?.value === true' }; return { condition: 'steps.prev?.output?.value === true' };
} }
} }

View File

@ -0,0 +1,23 @@
import type { WorkflowsPageLogic } from '../WorkflowsPageLogic';
const STATS = [
{ key: 'total', label: '工作流数量', tone: 'brand' },
{ key: 'enabled', label: '启用中', tone: 'success' },
{ key: 'scheduled', label: '定时运行', tone: 'warning' }
] as const;
export default function WorkflowStatsStrip({ logic }: { logic: WorkflowsPageLogic }) {
return (
<div className="workflows-stats-grid">
{STATS.map((item) => (
<div key={item.key} className="workflows-stat-card">
<div className="workflows-stat-label">{item.label}</div>
<div className="workflows-stat-value-row">
<span className="workflows-stat-value">{logic.stats[item.key]}</span>
<span className={`workflows-stat-chip workflows-tone-${item.tone}`}></span>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,46 @@
import { ApartmentOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Empty, Spin } from 'antd';
import type { WorkflowsPageLogic } from '../WorkflowsPageLogic';
import WorkflowCard from './WorkflowCard';
import WorkflowStatsStrip from './WorkflowStatsStrip';
export default function WorkflowsPageH5({ logic }: { logic: WorkflowsPageLogic }) {
return (
<div className="workflows-page-h5">
<div className="workflows-h5-hero">
<div className="workflows-badge">
<ApartmentOutlined />
</div>
<h1 className="workflows-h5-title"></h1>
<p className="workflows-h5-subtitle"></p>
<Button type="primary" icon={<PlusOutlined />} onClick={logic.onCreate} className="workflows-h5-create-btn">
</Button>
</div>
<WorkflowStatsStrip logic={logic} />
{logic.loading ? (
<Spin className="workflows-state-spin" />
) : logic.list.length === 0 ? (
<div className="workflows-empty-card">
<Empty description="还没有工作流" />
</div>
) : (
<div className="workflows-card-list">
{logic.list.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
compact
onEdit={logic.onEdit}
onDelete={logic.onDelete}
onOpenRuns={logic.openRuns}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,51 @@
import { ApartmentOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Empty, Spin } from 'antd';
import type { WorkflowsPageLogic } from '../WorkflowsPageLogic';
import WorkflowCard from './WorkflowCard';
import WorkflowStatsStrip from './WorkflowStatsStrip';
export default function WorkflowsPageWeb({ logic }: { logic: WorkflowsPageLogic }) {
return (
<div className="page-container workflows-page-web">
<div className="workflows-hero">
<div className="workflows-hero-header">
<div className="workflows-hero-copy">
<div className="workflows-badge">
<ApartmentOutlined />
</div>
<h1 className="page-title workflows-title"></h1>
<div className="page-subtitle workflows-subtitle">
AgentHTTP
</div>
</div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={logic.onCreate} className="workflows-create-btn">
</Button>
</div>
<WorkflowStatsStrip logic={logic} />
</div>
{logic.loading ? (
<Spin className="workflows-state-spin" />
) : logic.list.length === 0 ? (
<div className="workflows-empty-card">
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
</div>
) : (
<div className="workflows-card-grid">
{logic.list.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
onEdit={logic.onEdit}
onDelete={logic.onDelete}
onOpenRuns={logic.openRuns}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,97 @@
.workflow-drawer .ant-drawer-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.workflow-drawer .ant-drawer-body {
background: #fcfcfd;
}
.workflow-drawer-save {
border-radius: 0.625rem;
}
.workflow-drawer-small-btn {
border-radius: 0.5rem;
}
.workflow-drawer-switch-row,
.workflow-drawer-card-bottom {
margin-bottom: 0.75rem;
}
.workflow-drawer-help,
.workflow-drawer-help-block {
font-size: 0.75rem;
}
.workflow-drawer-help-block {
margin-top: 0.5rem;
}
.workflow-drawer-card {
border-radius: 1rem;
box-shadow: var(--shadow-xs);
}
.workflow-drawer-card-spaced {
margin-top: 0.75rem;
}
.workflow-graph-textarea {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
.workflow-node-type-select {
width: 8.75rem;
}
.workflow-node-id-input {
width: 12.5rem;
}
.workflow-run-step {
margin-bottom: 0.5rem;
}
.workflow-run-error {
margin-bottom: 0;
}
.workflow-run-error-text {
color: var(--color-danger);
margin-top: 0.25rem;
}
.workflow-run-pre {
background: #f5f7fb;
padding: 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
}
.workflow-run-pre-compact {
margin-top: 0.25rem;
max-height: 10rem;
overflow: auto;
}
.workflow-run-pre-detail {
margin-top: 0.25rem;
max-height: 12.5rem;
overflow: auto;
}
.workflow-run-pre-plain {
margin: 0;
}
.workflow-history-refresh {
margin-bottom: 0.5rem;
border-radius: 0.5rem;
}
.workflow-detail-error {
margin-left: 0.5rem;
}

View File

@ -0,0 +1,75 @@
.workflows-page-h5 {
padding: 1rem;
background: var(--color-bg);
}
.workflows-h5-hero {
border-radius: 1.125rem;
padding: 1.125rem;
background: var(--gradient-hero);
}
.workflows-h5-title {
margin: 0.875rem 0 0;
color: var(--color-text);
font-size: 1.875rem;
line-height: 1.15;
font-weight: 900;
}
.workflows-h5-subtitle {
margin: 0.75rem 0 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.7;
}
.workflows-h5-create-btn {
width: 100%;
height: 2.75rem;
margin-top: 1rem;
font-weight: 700;
}
.workflows-page-h5 .workflows-stats-grid {
grid-template-columns: 1fr;
margin-top: 0.875rem;
}
.workflows-card-list {
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
.workflows-page-h5 .workflows-empty-card,
.workflows-page-h5 .workflows-state-spin {
margin-top: 0.875rem;
}
.workflow-card-h5 {
min-height: auto;
padding: 1rem;
}
.workflow-card-h5 .workflow-card-header {
align-items: flex-start;
}
.workflow-card-h5 .workflow-card-metrics {
grid-template-columns: 1fr;
}
.workflow-card-h5 .workflow-card-schedule {
margin-bottom: 0.875rem;
}
.workflow-card-h5 .workflow-card-actions {
display: grid;
grid-template-columns: 1fr;
}
.workflow-card-h5 .workflow-action-primary,
.workflow-card-h5 .workflow-action-secondary {
width: 100%;
}

View File

@ -0,0 +1,154 @@
.workflows-page-shell .feature-cover {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: 700;
}
.workflows-hero,
.workflows-h5-hero,
.workflow-card,
.workflows-empty-card {
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.workflows-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(17, 103, 255, 0.12);
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 700;
}
.workflows-stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.875rem;
}
.workflows-stat-card {
border-radius: 1.125rem;
padding: 1rem 1.125rem;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(255, 255, 255, 0.72);
}
.workflows-stat-label,
.workflow-metric-label {
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.workflows-stat-value-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.625rem;
}
.workflows-stat-value {
color: var(--color-text);
font-size: 1.875rem;
font-weight: 800;
}
.workflows-stat-chip,
.workflow-status {
border-radius: 999px;
margin: 0;
}
.workflows-tone-brand,
.workflow-status-brand {
background: var(--color-brand-soft);
color: var(--color-brand);
}
.workflows-tone-success,
.workflow-status-on {
background: var(--color-success-soft);
color: var(--color-success);
}
.workflows-tone-warning {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.workflow-card {
border-radius: 1.25rem;
padding: 1.25rem;
min-height: 19.25rem;
display: flex;
flex-direction: column;
}
.workflow-card-header,
.workflow-card-actions,
.workflow-card-schedule-label {
display: flex;
align-items: center;
}
.workflow-card-header {
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.workflow-card-title-block {
min-width: 0;
}
.workflow-card-title {
color: var(--color-text);
font-size: 1.1875rem;
font-weight: 800;
}
.workflow-card-desc {
margin-top: 0.375rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
line-height: 1.7;
}
.workflow-card-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.workflow-metric {
border-radius: 1rem;
padding: 0.875rem;
border: 1px solid rgba(17, 103, 255, 0.1);
}
.workflow-metric-brand {
background: rgba(17, 103, 255, 0.06);
}
.workflow-metric-success {
background: rgba(13, 159, 110, 0.06);
}
.workflow-metric-warning {
background: rgba(183, 121, 31, 0.06);
}
.workflow-metric-value {
margin-top: 0.5rem;
color: var(--color-text);
font-size: 1.5rem;
font-weight: 800;
}

View File

@ -0,0 +1,92 @@
.workflows-page-web {
max-width: 1400px;
}
.workflows-hero {
border-radius: 1.5rem;
padding: 1.875rem;
margin-bottom: 1.5rem;
background: var(--gradient-hero);
}
.workflows-hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.25rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.workflows-hero-copy {
max-width: 42.5rem;
}
.workflows-title {
margin: 1rem 0 0.625rem;
}
.workflows-subtitle {
margin-top: 0;
font-size: 0.9375rem;
line-height: 1.75;
}
.workflows-create-btn {
height: 2.875rem;
padding: 0 1.125rem;
font-weight: 700;
}
.workflows-state-spin {
display: block;
margin-top: 2rem;
}
.workflows-empty-card {
border-radius: 1.375rem;
padding: 3.375rem 1.5rem;
}
.workflows-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21.25rem, 1fr));
gap: 1.125rem;
}
.workflow-card-schedule {
border-radius: 1rem;
padding: 1rem;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.92), rgba(255, 255, 255, 0.96));
border: 1px solid rgba(148, 163, 184, 0.14);
margin-bottom: 1rem;
}
.workflow-card-schedule-label {
gap: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
margin-bottom: 0.625rem;
}
.workflow-card-schedule-time {
color: var(--color-text-tertiary);
font-size: 0.78125rem;
margin-top: 0.625rem;
}
.workflow-card-actions {
gap: 0.5rem;
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.workflow-action-primary,
.workflow-action-secondary {
height: 2.5rem;
}
.workflow-action-primary {
font-weight: 700;
}

View File

@ -146,18 +146,12 @@ body {
margin-bottom: 40px; margin-bottom: 40px;
} }
.login-brand-mark { .login-brand-logo {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 12px; flex: 0 0 auto;
background: var(--gradient-brand); object-fit: contain;
color: #fff; filter: drop-shadow(0 12px 22px rgba(17, 103, 255, 0.16));
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
box-shadow: var(--shadow-md);
} }
.login-brand-name { .login-brand-name {
@ -307,17 +301,12 @@ body {
gap: 10px; gap: 10px;
} }
.sidebar .brand .brand-mark { .sidebar .brand .brand-logo {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 8px; flex: 0 0 auto;
background: var(--gradient-brand); object-fit: contain;
color: #fff; filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: var(--shadow-sm);
} }
.sidebar .nav-section-label { .sidebar .nav-section-label {
@ -714,21 +703,18 @@ body {
} }
.chat-input-toolbar { .chat-input-toolbar {
flex-direction: column; flex-wrap: wrap;
align-items: stretch;
gap: 0.5rem; gap: 0.5rem;
} }
.chat-input-toolbar-left { .chat-input-toolbar-left {
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.chat-input-toolbar .chat-send-button { .chat-input-toolbar .chat-send-button {
margin-left: 0; margin-left: auto;
align-self: flex-end;
} }
.chat-model-select .ant-select-selector { .chat-model-select .ant-select-selector {
@ -736,14 +722,7 @@ body {
} }
.chat-model-select .ant-select-selection-item { .chat-model-select .ant-select-selection-item {
max-width: 9rem; max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-model-select .ant-select-selection-placeholder {
max-width: 9rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@ -0,0 +1,87 @@
.mobile-topbar {
height: 3.25rem;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.625rem;
border-bottom: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(18px);
}
.mobile-topbar-title {
flex: 1;
min-width: 0;
font-weight: 800;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-topbar-logo {
width: 1.75rem;
height: 1.75rem;
flex: 0 0 auto;
object-fit: contain;
}
.mobile-nav-drawer .ant-drawer-body {
padding: 0;
background: var(--color-surface);
}
.sidebar-brand-spacer,
.sidebar-scroll,
.sidebar-search-label,
.sidebar-user-main {
min-width: 0;
}
.sidebar-brand-spacer {
flex: 1;
}
.sidebar-search-action {
cursor: pointer;
justify-content: space-between;
background: var(--color-surface-2);
margin-bottom: 8px;
}
.sidebar-search-label {
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
margin-right: -4px;
padding-right: 4px;
}
.sidebar-user {
margin-top: 8px;
}
.sidebar-user-avatar {
background: var(--gradient-brand);
flex-shrink: 0;
}
.sidebar-user-name {
font-size: 13px;
color: var(--color-text);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-user-role {
font-size: 11px;
color: var(--color-text-tertiary);
}

58
src/styles/aura-theme.css Normal file
View File

@ -0,0 +1,58 @@
:root,
[data-theme='light'] {
--color-bg: #f5f9ff;
--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%);
}
[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%);
}