refactor: batch convert mobile layout from px to rem for better h5 adaptation
parent
780ce6edba
commit
d3009ef2f8
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"hash": "42f20ac0",
|
||||
"configHash": "2e77f0d9",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "a53d060b",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>驷马智能</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>鲸域AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
29
src/App.tsx
29
src/App.tsx
|
|
@ -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 { Button, Drawer, Spin } from 'antd';
|
||||
import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
|
@ -14,9 +14,11 @@ import TeamsPage from './pages/TeamsPage';
|
|||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||
import StatsPage from './pages/StatsPage';
|
||||
import SharedSessionPage from './pages/SharedSessionPage';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import { useAuth } from './store/auth';
|
||||
import { AgentAPI } from './api';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import { useAuth } from './store/auth';
|
||||
import { AgentAPI } from './api';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import kaiwuIcon from './assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||
|
||||
function HomeRedirect() {
|
||||
return <Navigate to="/chat" replace />;
|
||||
|
|
@ -24,10 +26,10 @@ function HomeRedirect() {
|
|||
|
||||
export default function App() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => (typeof window === 'undefined' ? false : window.innerWidth < 768));
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 全局快捷键 Ctrl/⌘ + K
|
||||
useEffect(() => {
|
||||
|
|
@ -42,13 +44,7 @@ export default function App() {
|
|||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const mainContent = (
|
||||
const mainContent = (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRedirect />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
|
@ -89,7 +85,8 @@ export default function App() {
|
|||
{isMobile && (
|
||||
<div className="mobile-topbar">
|
||||
<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)} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -103,7 +100,7 @@ export default function App() {
|
|||
open={mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
width={280}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
className="mobile-nav-drawer"
|
||||
>
|
||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} />
|
||||
</Drawer>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -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 |
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { useTheme } from '../main';
|
||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||
|
||||
interface Props {
|
||||
onOpenPalette?: () => void;
|
||||
|
|
@ -67,9 +68,9 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">A</span>
|
||||
<span>Agent Studio</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
|
||||
<span>鲸域AI</span>
|
||||
<div className="sidebar-brand-spacer" />
|
||||
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
||||
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
||||
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||
|
|
@ -82,22 +83,16 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
onOpenPalette?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className="nav-item"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--color-surface-2)',
|
||||
marginBottom: 8
|
||||
}}
|
||||
className="nav-item sidebar-search-action"
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="sidebar-search-label">
|
||||
<SearchOutlined className="nav-icon" />
|
||||
<span>快速搜索</span>
|
||||
</span>
|
||||
<span className="kbd">{cmdKey} K</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', marginRight: -4, paddingRight: 4 }}>
|
||||
<div className="sidebar-scroll">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="nav-section-label">{group.label}</div>
|
||||
|
|
@ -123,7 +118,7 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
items: [
|
||||
{
|
||||
key: 'name',
|
||||
label: <span style={{ color: 'var(--color-text-tertiary)' }}>{user.email}</span>,
|
||||
label: <span className="sidebar-user-role">{user.email}</span>,
|
||||
disabled: true
|
||||
},
|
||||
{ type: 'divider' },
|
||||
|
|
@ -147,28 +142,15 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
|||
}}
|
||||
placement="topLeft"
|
||||
>
|
||||
<div className="sidebar-user" style={{ marginTop: 8 }}>
|
||||
<Avatar
|
||||
size={32}
|
||||
style={{ background: 'var(--gradient-brand)', flexShrink: 0 }}
|
||||
>
|
||||
<div className="sidebar-user">
|
||||
<Avatar size={32} className="sidebar-user-avatar">
|
||||
{(user.name?.charAt(0) || '?').toUpperCase()}
|
||||
</Avatar>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.005em'
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-user-main">
|
||||
<div className="sidebar-user-name">
|
||||
{user.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
|
||||
<div className="sidebar-user-role">
|
||||
{user.role === 'admin' ? '管理员' : '成员'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
24
src/main.tsx
24
src/main.tsx
|
|
@ -5,6 +5,8 @@ import zhCN from 'antd/locale/zh_CN';
|
|||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
import './styles/aura-theme.css';
|
||||
import './styles/app-shell-h5.css';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
|
|
@ -40,17 +42,17 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||
() => ({
|
||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: isDark ? '#e07b3e' : '#c2541f',
|
||||
colorInfo: isDark ? '#e07b3e' : '#c2541f',
|
||||
colorBgBase: isDark ? '#1a1816' : '#faf9f5',
|
||||
colorBgContainer: isDark ? '#221f1c' : '#ffffff',
|
||||
colorBgElevated: isDark ? '#2b2824' : '#ffffff',
|
||||
colorBgLayout: isDark ? '#1a1816' : '#faf9f5',
|
||||
colorBorder: isDark ? '#36322c' : '#ebe7da',
|
||||
colorBorderSecondary: isDark ? '#2b2824' : '#f0ece0',
|
||||
colorText: isDark ? '#f3efe6' : '#2a2622',
|
||||
colorTextSecondary: isDark ? '#b6afa3' : '#6b6660',
|
||||
colorTextTertiary: isDark ? '#7e7869' : '#a09a8e',
|
||||
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
|
||||
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
|
||||
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
||||
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
||||
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
||||
colorBgLayout: isDark ? '#071126' : '#f5f9ff',
|
||||
colorBorder: isDark ? '#1f3764' : '#d6e5fb',
|
||||
colorBorderSecondary: isDark ? '#17294c' : '#e3efff',
|
||||
colorText: isDark ? '#edf5ff' : '#06143f',
|
||||
colorTextSecondary: isDark ? '#b6c8e8' : '#405784',
|
||||
colorTextTertiary: isDark ? '#7f93ba' : '#7c8caf',
|
||||
colorSuccess: isDark ? '#7fb87d' : '#4f8a4d',
|
||||
colorWarning: isDark ? '#d49a4a' : '#b8782a',
|
||||
colorError: isDark ? '#e07060' : '#c0392b',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Modal, Button, Upload } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { Agent, AgentAPI } from '../../../api';
|
||||
import { Agent } from '../../../api';
|
||||
import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants';
|
||||
|
||||
interface AvatarSelectorProps {
|
||||
|
|
@ -21,43 +21,53 @@ export default function AvatarSelector({
|
|||
onAvatarSelect,
|
||||
}: AvatarSelectorProps) {
|
||||
return (
|
||||
<Modal title="选择头像形象" open={open} onCancel={onCancel} footer={null} width={600} centered>
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
点击即可更换
|
||||
</div>
|
||||
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadEditAvatar}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
|
||||
<Modal
|
||||
title="选择头像形象"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
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>
|
||||
</Upload>
|
||||
</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' }}>
|
||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
onClick={async () => {
|
||||
if (!agent?.id) return;
|
||||
await onAvatarSelect(url);
|
||||
onCancel();
|
||||
}}
|
||||
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'}`}
|
||||
style={{ width: 80, height: 80, minWidth: 80, borderColor: agent?.avatar === url ? 'var(--color-brand)' : 'transparent' }}
|
||||
>
|
||||
<img src={url} className="w-full h-full object-cover" alt="preset" />
|
||||
{agent?.avatar === url && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
|
||||
>
|
||||
<div className="bg-white rounded-full p-0.5 shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="agent-editor-avatar-grid agent-editor-avatar-grid-panel monica-scrollbar">
|
||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
|
||||
const isSelected = agent?.avatar === url;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={url}
|
||||
onClick={async () => {
|
||||
if (!agent?.id) return;
|
||||
await onAvatarSelect(url);
|
||||
onCancel();
|
||||
}}
|
||||
className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
|
||||
aria-label="选择头像形象"
|
||||
>
|
||||
<img src={url} className="agent-editor-avatar-image" alt="preset" />
|
||||
{isSelected && (
|
||||
<span className="agent-editor-avatar-selected">
|
||||
<span className="agent-editor-avatar-dot" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Form, Input, Select, Collapse, Button, List, Popconfirm, Tag, Switch, InputNumber } from 'antd';
|
||||
import { DatabaseOutlined, RocketOutlined, SettingOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import { Agent, Team, AgentAPI } from '../../../api';
|
||||
import { STATUS_TAG, isImageUrl, parseModelSelections } from '../constants';
|
||||
import ModelCheckboxDropdown from './ModelCheckboxDropdown';
|
||||
import { Form } from 'antd';
|
||||
import { Agent, Team } from '../../../api';
|
||||
import AgentIdentityCard from './capability/AgentIdentityCard';
|
||||
import BasicSettingsPanel from './capability/BasicSettingsPanel';
|
||||
import KnowledgeSettingsPanel from './capability/KnowledgeSettingsPanel';
|
||||
import ModelSettingsCard from './capability/ModelSettingsCard';
|
||||
import WebSearchCard from './capability/WebSearchCard';
|
||||
|
||||
interface CapabilitySettingsProps {
|
||||
form: any;
|
||||
|
|
@ -41,258 +43,25 @@ export default function CapabilitySettings({
|
|||
这里决定它用什么模型、拥有哪些知识和技能,以及它会以怎样的方式被别人看到。
|
||||
</p>
|
||||
</div>
|
||||
<span className="agent-editor-badge">
|
||||
<ToolOutlined />
|
||||
Capability
|
||||
</span>
|
||||
<span className="agent-editor-badge">Capability</span>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" onValuesChange={markDirty}>
|
||||
<div className="agent-editor-intro" style={{ marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<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(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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
<AgentIdentityCard
|
||||
agent={agent}
|
||||
currentName={currentName}
|
||||
agentName={agentName}
|
||||
selectedAvatar={selectedAvatar}
|
||||
setAvatarSelectorOpen={setAvatarSelectorOpen}
|
||||
/>
|
||||
|
||||
<div className="monica-card !mb-4" style={{ borderRadius: 18 }}>
|
||||
<div className="flex items-center gap-2 mb-4 font-medium text-gray-700">
|
||||
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
|
||||
模型设置
|
||||
</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="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,
|
||||
},
|
||||
]}
|
||||
<BasicSettingsPanel teams={teams} />
|
||||
<ModelSettingsCard models={models} />
|
||||
<KnowledgeSettingsPanel
|
||||
agent={agent}
|
||||
beforeUploadKnowledge={beforeUploadKnowledge}
|
||||
onDeleteKnowledge={onDeleteKnowledge}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<WebSearchCard />
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,23 +18,22 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
|||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/agents')}
|
||||
className="hover:bg-gray-100"
|
||||
style={{ borderRadius: 12, width: 40, height: 40 }}
|
||||
className="agent-editor-icon-action hover:bg-gray-100"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RocketOutlined style={{ color: 'var(--color-brand)', fontSize: 18 }} />
|
||||
<span className="font-bold text-lg text-gray-800" style={{ color: 'var(--color-text)' }}>
|
||||
<RocketOutlined className="agent-editor-header-icon" />
|
||||
<span className="agent-editor-header-title font-bold text-lg text-gray-800">
|
||||
{isNew ? '创建新智能体' : currentName || ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginTop: 2 }}>
|
||||
<div className="agent-editor-header-subtitle">
|
||||
围绕个性化、能力配置与实时预览,搭建一个更完整的 AI 工作台。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 === 'dirty'
|
||||
|
|
@ -43,7 +42,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
|||
? '保存失败'
|
||||
: '已保存'}
|
||||
</span>
|
||||
<Button icon={<FileTextOutlined />} style={{ borderRadius: 12, height: 40 }}>
|
||||
<Button icon={<FileTextOutlined />} className="agent-editor-header-action">
|
||||
文档
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -51,7 +50,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
|||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={onSave}
|
||||
style={{ borderRadius: 12, height: 40, paddingInline: 16, fontWeight: 600 }}
|
||||
className="agent-editor-save-action"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ interface InitModalProps {
|
|||
beforeUploadInitAvatar: (file: any) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const PROFILE_SUGGESTIONS = ['客服助理', '内容创作', '数据分析', '私人教练'];
|
||||
|
||||
export default function InitModal({
|
||||
open,
|
||||
onCancel,
|
||||
|
|
@ -28,6 +30,7 @@ export default function InitModal({
|
|||
beforeUploadInitAvatar,
|
||||
}: InitModalProps) {
|
||||
const [initForm] = Form.useForm();
|
||||
const selectedName = agentName || '你的新智能体';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -39,61 +42,34 @@ export default function InitModal({
|
|||
centered
|
||||
maskClosable={false}
|
||||
destroyOnHidden
|
||||
className="agent-editor-init-modal"
|
||||
>
|
||||
<div className="py-2">
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div className="agent-editor-init-content">
|
||||
<div className="agent-editor-init-header">
|
||||
<div className="agent-editor-init-badge">
|
||||
<RocketOutlined />
|
||||
第一步 · 定义智能体形象
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>
|
||||
<div className="agent-editor-init-title">
|
||||
先给你的智能体一个更完整的开场
|
||||
</div>
|
||||
<div style={{ fontSize: 14.5, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
|
||||
<div className="agent-editor-init-subtitle">
|
||||
先决定它的形象、名字和一句话定位。确认后会进入三栏工作台,继续完成个性化、能力配置和实时预览。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-editor-modal-hero">
|
||||
<div
|
||||
className="agent-editor-modal-card"
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div className="agent-editor-modal-card agent-editor-preview-card">
|
||||
<div className="agent-editor-avatar-preview">
|
||||
{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 style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
|
||||
{agentName || '你的新智能体'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
||||
<div className="agent-editor-preview-text">
|
||||
<div className="agent-editor-preview-name">{selectedName}</div>
|
||||
<div className="agent-editor-preview-caption">
|
||||
这里会实时映射你输入的名字与选择的形象。
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,81 +82,54 @@ export default function InitModal({
|
|||
onValuesChange={(changed) => {
|
||||
if (changed.name !== undefined) setAgentName(changed.name);
|
||||
}}
|
||||
className="agent-editor-modal-card"
|
||||
style={{ padding: 20 }}
|
||||
className="agent-editor-modal-card agent-editor-init-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={
|
||||
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
智能体名称
|
||||
</span>
|
||||
}
|
||||
label={<span className="agent-editor-form-label">智能体名称</span>}
|
||||
rules={[{ required: true, message: '请输入智能体名称' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="给你的智能体起个名字"
|
||||
size="large"
|
||||
autoFocus
|
||||
className="rounded-xl h-12 border-gray-200 focus:border-cyan-500"
|
||||
className="agent-editor-form-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={
|
||||
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
描述(选填)
|
||||
</span>
|
||||
}
|
||||
label={<span className="agent-editor-form-label">描述(选填)</span>}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="介绍一下这个智能体是做什么的..."
|
||||
rows={3}
|
||||
className="rounded-xl border-gray-200 focus:border-cyan-500"
|
||||
className="agent-editor-form-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => (
|
||||
<span
|
||||
<div className="agent-editor-suggestion-list">
|
||||
{PROFILE_SUGGESTIONS.map((label) => (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
onClick={() => initForm.setFieldsValue({ description: label })}
|
||||
style={{
|
||||
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)';
|
||||
}}
|
||||
className="agent-editor-suggestion-chip"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className="flex-1 h-12 rounded-xl border-gray-200 text-gray-500 font-semibold hover:bg-gray-50"
|
||||
>
|
||||
<div className="agent-editor-init-actions">
|
||||
<Button onClick={onCancel} className="agent-editor-secondary-action">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="flex-1 h-12 rounded-xl border-none font-semibold"
|
||||
className="agent-editor-primary-action"
|
||||
>
|
||||
确认创建
|
||||
</Button>
|
||||
|
|
@ -188,41 +137,43 @@ export default function InitModal({
|
|||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-3 px-1">
|
||||
选择你的智能体形象
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
||||
<div className="agent-editor-avatar-section">
|
||||
<div className="agent-editor-avatar-heading">选择你的智能体形象</div>
|
||||
<div className="agent-editor-avatar-toolbar">
|
||||
<div className="agent-editor-avatar-hint">
|
||||
默认会使用系统头像,你也可以上传自己的图片替换。
|
||||
</div>
|
||||
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadInitAvatar}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
|
||||
<Upload
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUploadInitAvatar}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} className="agent-editor-upload-button">
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
<div className="agent-editor-avatar-grid monica-scrollbar" style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px' }}>
|
||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
onClick={() => setSelectedAvatar(url)}
|
||||
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'}`}
|
||||
style={{ width: 80, height: 80, minWidth: 80, borderColor: selectedAvatar === url ? 'var(--color-brand)' : 'transparent' }}
|
||||
>
|
||||
<img src={url} className="w-full h-full object-cover" alt="preset" />
|
||||
{selectedAvatar === url && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
|
||||
>
|
||||
<div className="bg-white rounded-full p-0.5 shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="agent-editor-avatar-grid monica-scrollbar">
|
||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
|
||||
const isSelected = selectedAvatar === url;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={url}
|
||||
onClick={() => setSelectedAvatar(url)}
|
||||
className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
|
||||
aria-label="选择智能体头像"
|
||||
>
|
||||
<img src={url} className="agent-editor-avatar-image" alt="preset" />
|
||||
{isSelected && (
|
||||
<span className="agent-editor-avatar-selected">
|
||||
<span className="agent-editor-avatar-dot" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ export default function PreviewPane({ liveAgent, agentId }: PreviewPaneProps) {
|
|||
Live Preview
|
||||
</span>
|
||||
</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-text">
|
||||
{liveAgent?.name || '未命名智能体'} 会基于左侧 Prompt 与中栏设置立即更新。你可以直接在这里检查语气、头像和整体观感。
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
|
|||
onValuesChange={markDirty}
|
||||
>
|
||||
<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 className="agent-editor-prompt">
|
||||
|
|
@ -44,13 +44,12 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
|
|||
<Input.TextArea
|
||||
rows={30}
|
||||
placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
|
||||
className="border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
|
||||
style={{ height: 'calc(100vh - 290px)', resize: 'none', borderRadius: 16 }}
|
||||
className="agent-editor-prompt-textarea border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
</Form.Item>
|
||||
</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>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ import CapabilitySettings from './components/CapabilitySettings';
|
|||
import PreviewPane from './components/PreviewPane';
|
||||
import InitModal from './components/InitModal';
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -52,7 +56,7 @@ export default function AgentEditor() {
|
|||
|
||||
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
|
||||
isNew={isNew}
|
||||
currentName={currentName}
|
||||
|
|
@ -62,7 +66,7 @@ export default function AgentEditor() {
|
|||
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} />
|
||||
<CapabilitySettings
|
||||
form={form}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,170 +1,13 @@
|
|||
import { useState } from 'react';
|
||||
import { Form, Input, Button, Tabs, App as AntApp, Alert } from 'antd';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useLoginPageLogic } from './LoginPage/LoginPageLogic';
|
||||
import LoginPageH5 from './LoginPage/components/LoginPageH5';
|
||||
import LoginPageWeb from './LoginPage/components/LoginPageWeb';
|
||||
import './LoginPage/styles/login-page-web.css';
|
||||
import './LoginPage/styles/login-page-h5.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const next = params.get('next') || '/';
|
||||
const { login, register } = useAuth();
|
||||
const { message } = AntApp.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const logic = useLoginPageLogic();
|
||||
const isMobile = useIsMobile(1100);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
return isMobile ? <LoginPageH5 logic={logic} /> : <LoginPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,11 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { usePointsMallPageLogic } from './PointsMallPageLogic';
|
||||
import PointsMallPageWeb from './components/PointsMallPageWeb';
|
||||
import PointsMallPageH5 from './components/PointsMallPageH5';
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export default function PointsMallPage() {
|
||||
const logic = usePointsMallPageLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,35 +16,35 @@ export default function ConfirmExchangeModal(props: {
|
|||
const canAfford = product ? userPoints >= totalPoints : false;
|
||||
|
||||
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 : (
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 10 }}>{product.name}</div>
|
||||
<Space size={8} wrap style={{ marginBottom: 16 }}>
|
||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||
<div className="points-confirm-product-name">{product.name}</div>
|
||||
<Space size={8} wrap className="points-confirm-tags">
|
||||
<Tag color="processing" className="points-confirm-tag">
|
||||
单价 {product.pointsPrice.toLocaleString()} 积分
|
||||
</Tag>
|
||||
<Tag color="default" style={{ marginInlineEnd: 0 }}>
|
||||
<Tag color="default" className="points-confirm-tag">
|
||||
当前余额 {userPoints.toLocaleString()}
|
||||
</Tag>
|
||||
<Tag color={canAfford ? 'success' : 'error'} style={{ marginInlineEnd: 0 }}>
|
||||
<Tag color={canAfford ? 'success' : 'error'} className="points-confirm-tag">
|
||||
合计 {totalPoints.toLocaleString()}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>兑换数量</div>
|
||||
<div className="points-confirm-quantity-row">
|
||||
<div className="points-confirm-label">兑换数量</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={Math.max(1, product.stock || 1)}
|
||||
value={quantity}
|
||||
disabled={loading}
|
||||
onChange={(v) => onQuantityChange(Number(v || 1))}
|
||||
style={{ width: 140 }}
|
||||
className="points-confirm-quantity-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ color: 'var(--color-text-tertiary)', fontSize: 12.5, lineHeight: 1.6, marginBottom: 16 }}>
|
||||
<div className="points-confirm-tip">
|
||||
确认后将发起兑换申请并冻结积分/预扣库存,成功后请继续填写收件信息完成兑换。
|
||||
</div>
|
||||
|
||||
|
|
@ -56,4 +56,3 @@ export default function ConfirmExchangeModal(props: {
|
|||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default function ExchangeModal(props: {
|
|||
}, [province, city, region.provinces]);
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<div className="points-exchange-modal-header">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { Button, Card, Empty, Form, Input, Select, Space, Spin, Tag } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import type { PointsMallProduct } from '../../../api';
|
||||
import { Form } from 'antd';
|
||||
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
|
||||
import ExchangeModal from '../components/ExchangeModal';
|
||||
import ConfirmExchangeModal from '../components/ConfirmExchangeModal';
|
||||
import ConfirmExchangeModal from './ConfirmExchangeModal';
|
||||
import ExchangeModal from './ExchangeModal';
|
||||
import PointsMallH5Products from './PointsMallH5Products';
|
||||
import PointsMallH5Top from './PointsMallH5Top';
|
||||
import type { ExchangeFormValues } from '../types';
|
||||
import '../styles/points-mall-h5.css';
|
||||
|
||||
interface Props {
|
||||
logic: PointsMallPageLogicOutput;
|
||||
|
|
@ -13,234 +14,21 @@ interface Props {
|
|||
export default function PointsMallPageH5({ logic }: Props) {
|
||||
const [exchangeForm] = Form.useForm<ExchangeFormValues>();
|
||||
const {
|
||||
overview,
|
||||
overviewLoading,
|
||||
categories,
|
||||
categoryId,
|
||||
q,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
productsLoading,
|
||||
products,
|
||||
total,
|
||||
userPoints,
|
||||
totalSpentUSD,
|
||||
banner,
|
||||
promoEntries,
|
||||
exchangeModalVisible,
|
||||
confirmModalVisible,
|
||||
selectedProduct,
|
||||
exchangeQuantity,
|
||||
pendingExpiresAt,
|
||||
exchangeLoading,
|
||||
setCategoryId,
|
||||
setQ,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
handleExchangeClick,
|
||||
userPoints,
|
||||
setExchangeModalVisible,
|
||||
setConfirmModalVisible,
|
||||
setConfirmModalVisible
|
||||
} = logic;
|
||||
|
||||
return (
|
||||
<div className="page-container h5-page-container" style={{ paddingLeft: 8, paddingRight: 8 }}>
|
||||
<div className="points-mall-hero h5-points-mall-hero" style={{ padding: '16px 12px' }}>
|
||||
<div className="points-mall-header h5-points-mall-header" style={{ flexDirection: 'column', gap: 12 }}>
|
||||
<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>
|
||||
<div className="page-container h5-page-container points-mall-page-h5">
|
||||
<PointsMallH5Top logic={logic} />
|
||||
<PointsMallH5Products logic={logic} />
|
||||
|
||||
<ExchangeModal
|
||||
open={exchangeModalVisible}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,15 @@ import { PromptTemplateAPI } from '../../api';
|
|||
import PromptLibraryHero from './components/PromptLibraryHero';
|
||||
import PromptTemplateGrid from './components/PromptTemplateGrid';
|
||||
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 }) {
|
||||
const { onSelect } = props;
|
||||
const isMobile = useIsMobile();
|
||||
const { message } = AntApp.useApp();
|
||||
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
|
||||
const [q, setQ] = useState('');
|
||||
|
|
@ -90,7 +96,7 @@ export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTempla
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="feature-cover-container">
|
||||
<div className={`feature-cover-container prompt-library-page-${isMobile ? 'h5' : 'web'}`}>
|
||||
<div className="page-container">
|
||||
<PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} />
|
||||
<PromptTemplateGrid
|
||||
|
|
|
|||
|
|
@ -13,70 +13,46 @@ export default function PromptLibraryHero(props: {
|
|||
const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: '28px 28px 24px',
|
||||
background:
|
||||
'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 className="prompt-library-hero">
|
||||
<div className="prompt-library-hero-header">
|
||||
<div className="prompt-library-hero-copy">
|
||||
<div className="prompt-library-badge">
|
||||
<AppstoreOutlined className="prompt-library-badge-icon" />
|
||||
灵感模板中心
|
||||
</div>
|
||||
|
||||
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
||||
<h1 className="page-title prompt-library-title">
|
||||
Prompt 模板库
|
||||
</h1>
|
||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}>
|
||||
<div className="page-subtitle prompt-library-subtitle">
|
||||
把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.5fr) auto', gap: 14, alignItems: 'center' }}>
|
||||
<div className="prompt-library-toolbar">
|
||||
<Input
|
||||
placeholder="搜索标题、分类或正文..."
|
||||
value={q}
|
||||
onChange={(e) => onChangeQ(e.target.value)}
|
||||
onPressEnter={onSearch}
|
||||
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
|
||||
prefix={<SearchOutlined className="prompt-library-search-icon" />}
|
||||
suffix={
|
||||
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>
|
||||
) : null
|
||||
}
|
||||
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }}
|
||||
className="prompt-library-search-input"
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<div className="prompt-library-scope-list">
|
||||
{SCOPE_OPTIONS.map((item) => {
|
||||
const active = scope === item.key;
|
||||
return (
|
||||
|
|
@ -84,17 +60,7 @@ export default function PromptLibraryHero(props: {
|
|||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onChangeScope(item.key)}
|
||||
style={{
|
||||
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'
|
||||
}}
|
||||
className={`prompt-library-scope-btn${active ? ' active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
|
@ -105,4 +71,3 @@ export default function PromptLibraryHero(props: {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,131 +15,59 @@ export default function PromptTemplateCard(props: {
|
|||
const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他'];
|
||||
|
||||
return (
|
||||
<div
|
||||
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 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' }}>
|
||||
<div className="prompt-template-card">
|
||||
<div className="prompt-template-card-header">
|
||||
<div className="prompt-template-tags">
|
||||
<Tag
|
||||
bordered={false}
|
||||
style={{
|
||||
margin: 0,
|
||||
borderRadius: 999,
|
||||
paddingInline: 10,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: tone.bg,
|
||||
color: tone.text
|
||||
}}
|
||||
className={`prompt-template-tag prompt-category-${tone}`}
|
||||
>
|
||||
{t.category || '其他'}
|
||||
</Tag>
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
|
||||
style={{
|
||||
margin: 0,
|
||||
borderRadius: 999,
|
||||
paddingInline: 10,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
fontSize: 12,
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}
|
||||
className="prompt-template-tag prompt-template-visibility"
|
||||
>
|
||||
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<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 ? '使用' : '复制'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', letterSpacing: '-0.02em', marginBottom: 6 }}>{t.title}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>by {t.ownerName || '我'}</div>
|
||||
<div className="prompt-template-title-row">
|
||||
<div className="prompt-template-title-block">
|
||||
<div className="prompt-template-title">{t.title}</div>
|
||||
<div className="prompt-template-owner">by {t.ownerName || '我'}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(15, 23, 42, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
<div className="prompt-template-use-count">
|
||||
使用 {t.useCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<div className={`prompt-template-body-card prompt-category-border-${tone}`}>
|
||||
<div className="prompt-template-body">
|
||||
{t.body.slice(0, 220)}
|
||||
{t.body.length > 220 ? '…' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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)'
|
||||
}}
|
||||
>
|
||||
<div className="prompt-template-footer">
|
||||
<ClockCircleOutlined />
|
||||
<span>最近更新于 {formatDate(t.updatedAt)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="prompt-template-footer-spacer" />
|
||||
<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>
|
||||
<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>
|
||||
</Popconfirm>
|
||||
|
|
@ -148,4 +76,3 @@ export default function PromptTemplateCard(props: {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,17 +15,7 @@ export default function PromptTemplateGrid(props: {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: 280,
|
||||
borderRadius: 22,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="prompt-template-state-card">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -33,14 +23,14 @@ export default function PromptTemplateGrid(props: {
|
|||
|
||||
if (list.length === 0) {
|
||||
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="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
||||
<div className="prompt-template-grid">
|
||||
{list.map((t) => (
|
||||
<PromptTemplateCard
|
||||
key={t.id}
|
||||
|
|
@ -55,4 +45,3 @@ export default function PromptTemplateGrid(props: {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ export const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: strin
|
|||
{ key: 'public', label: '公开灵感' }
|
||||
];
|
||||
|
||||
export const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' },
|
||||
编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' },
|
||||
写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' },
|
||||
翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' },
|
||||
分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' },
|
||||
客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' },
|
||||
其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' }
|
||||
export const CATEGORY_STYLES: Record<string, string> = {
|
||||
通用: 'general',
|
||||
编程: 'code',
|
||||
写作: 'writing',
|
||||
翻译: 'translate',
|
||||
分析: 'analysis',
|
||||
客服: 'service',
|
||||
其他: 'other'
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Empty, Spin, App as AntApp } from 'antd';
|
||||
import { Empty, Spin } from 'antd';
|
||||
import { SharedAPI } from '../api';
|
||||
import Markdown from '../components/Markdown';
|
||||
import './SharedSessionPage.css';
|
||||
|
||||
interface Data {
|
||||
agent: { name: string; description: string };
|
||||
|
|
@ -15,60 +16,46 @@ export default function SharedSessionPage() {
|
|||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>('');
|
||||
const { message } = AntApp.useApp();
|
||||
const loginHref = `${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}/login`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
setErr('会话不存在或已失效');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
SharedAPI.get(token)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
const msg = e?.response?.data?.error ?? e?.message ?? '加载失败';
|
||||
setErr(msg);
|
||||
message.error(msg);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
if (loading) return <Spin className="shared-session-spin" />;
|
||||
if (err || !data)
|
||||
return (
|
||||
<Empty
|
||||
description={err || '会话不存在或已失效'}
|
||||
style={{ marginTop: 80 }}
|
||||
className="shared-session-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--color-bg)' }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 900,
|
||||
margin: '0 auto',
|
||||
padding: '32px 24px',
|
||||
background: 'var(--color-surface)',
|
||||
minHeight: '100vh',
|
||||
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)' }}>
|
||||
<div className="shared-session-shell">
|
||||
<div className="shared-session-container">
|
||||
<div className="shared-session-header">
|
||||
<div className="shared-session-eyebrow">公开分享</div>
|
||||
<h1 className="shared-session-title">{data.session.title}</h1>
|
||||
<div className="shared-session-meta">
|
||||
<span>🤖 {data.agent.name} · {data.messages.length} 条消息</span>
|
||||
<span className="shared-session-date">
|
||||
{new Date(data.session.updatedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
{data.agent.description && (
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
<div className="shared-session-description">
|
||||
{data.agent.description}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -77,52 +64,24 @@ export default function SharedSessionPage() {
|
|||
{data.messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
marginBottom: 18,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: m.role === 'user' ? 'flex-end' : 'flex-start'
|
||||
}}
|
||||
className={`shared-message shared-message-${m.role}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
marginBottom: 4
|
||||
}}
|
||||
>
|
||||
<div className="shared-message-meta">
|
||||
{m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '}
|
||||
{new Date(m.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
<div
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<div className="shared-message-bubble">
|
||||
{m.role === 'assistant' ? (
|
||||
<Markdown>{m.content}</Markdown>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
|
||||
<div className="shared-message-text">{m.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontSize: 12,
|
||||
marginTop: 40
|
||||
}}
|
||||
>
|
||||
由 Agent Studio 生成 · <a href={loginHref} style={{ color: 'var(--color-brand)' }}>登录创建你自己的 agent</a>
|
||||
<div className="shared-session-footer">
|
||||
由 鲸域AI 生成 · <a href={loginHref}>登录创建你自己的 agent</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,384 +1,14 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { BarChartOutlined, DollarOutlined, GiftOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
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;
|
||||
};
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useStatsPageLogic } from './StatsPage/StatsPageLogic';
|
||||
import StatsPageH5 from './StatsPage/components/StatsPageH5';
|
||||
import StatsPageWeb from './StatsPage/components/StatsPageWeb';
|
||||
import './StatsPage/styles/stats-page-shared.css';
|
||||
import './StatsPage/styles/stats-page-web.css';
|
||||
import './StatsPage/styles/stats-page-h5.css';
|
||||
|
||||
export default function StatsPage() {
|
||||
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<string>('');
|
||||
const [tokenDays, setTokenDays] = useState<number>(30);
|
||||
const [tokenLimit, setTokenLimit] = useState<number>(10);
|
||||
const logic = useStatsPageLogic();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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);
|
||||
});
|
||||
// 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>
|
||||
);
|
||||
return isMobile ? <StatsPageH5 logic={logic} /> : <StatsPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,287 +1,36 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ApartmentOutlined, ClockCircleOutlined, PlayCircleOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
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 { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useWorkflowsPageLogic } from './WorkflowsPageLogic';
|
||||
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() {
|
||||
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 () => {
|
||||
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();
|
||||
};
|
||||
const isMobile = useIsMobile();
|
||||
const logic = useWorkflowsPageLogic({ enabled: false });
|
||||
|
||||
return (
|
||||
<div className="feature-cover-container">
|
||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||
<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 }}>
|
||||
让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
|
||||
新建工作流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="feature-cover-container workflows-page-shell">
|
||||
{isMobile ? <WorkflowsPageH5 logic={logic} /> : <WorkflowsPageWeb logic={logic} />}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
||||
{[
|
||||
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||||
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||||
{
|
||||
label: '定时运行',
|
||||
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>
|
||||
{logic.editing && (
|
||||
<WorkflowEditorDrawer
|
||||
open={logic.editorOpen}
|
||||
workflow={logic.editing}
|
||||
onClose={() => logic.setEditorOpen(false)}
|
||||
onSaved={logic.handleSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? null : list.length === 0 ? (
|
||||
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}>
|
||||
<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>
|
||||
{logic.runsFor && (
|
||||
<RunsDrawer open={logic.runsOpen} workflow={logic.runsFor} onClose={() => logic.setRunsOpen(false)} />
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
<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 className="feature-cover">功能规划中,本期不支持</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -85,7 +85,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
setDetail(null);
|
||||
onClose();
|
||||
}}
|
||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||||
className="workflow-drawer"
|
||||
>
|
||||
<Tabs
|
||||
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"}' />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
|
||||
<Space className="workflow-drawer-switch-row">
|
||||
<Button type="primary" loading={streaming} onClick={onRunStream} className="workflow-drawer-save">
|
||||
▶ 流式执行
|
||||
</Button>
|
||||
<Button
|
||||
style={{ borderRadius: 10 }}
|
||||
className="workflow-drawer-save"
|
||||
onClick={async () => {
|
||||
try {
|
||||
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>
|
||||
|
||||
{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) => (
|
||||
<div key={i} style={{ marginBottom: 8 }}>
|
||||
<div key={i} className="workflow-run-step">
|
||||
<Space>
|
||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||
<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>}
|
||||
</Space>
|
||||
{s.error && (
|
||||
<Paragraph type="danger" style={{ marginBottom: 0 }}>
|
||||
<Paragraph type="danger" className="workflow-run-error">
|
||||
{s.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
{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)}
|
||||
</pre>
|
||||
)}
|
||||
|
|
@ -147,8 +147,8 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
)}
|
||||
|
||||
{finalRun && (
|
||||
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
|
||||
<pre style={{ fontSize: 12, margin: 0 }}>{JSON.stringify(finalRun, null, 2)}</pre>
|
||||
<Card size="small" title="最终结果" className="workflow-drawer-card">
|
||||
<pre className="workflow-run-pre workflow-run-pre-plain">{JSON.stringify(finalRun, null, 2)}</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -159,7 +159,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
label: `历史 (${runs.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}>
|
||||
<Button size="small" onClick={loadRuns} className="workflow-history-refresh">
|
||||
刷新
|
||||
</Button>
|
||||
<Table<WorkflowRun>
|
||||
|
|
@ -181,7 +181,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
<b>状态:</b>
|
||||
<Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>
|
||||
{detail.error && (
|
||||
<Text type="danger" style={{ marginLeft: 8 }}>
|
||||
<Text type="danger" className="workflow-detail-error">
|
||||
{detail.error}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -193,21 +193,21 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
<p>
|
||||
<b>Input:</b>
|
||||
</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>
|
||||
<b>Steps:</b>
|
||||
</p>
|
||||
{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>
|
||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||
<Text strong>{s.nodeId}</Text>
|
||||
<Text type="secondary">{s.nodeType}</Text>
|
||||
<Text type="secondary">{s.durationMs} ms</Text>
|
||||
</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 && (
|
||||
<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>
|
||||
))}
|
||||
|
|
@ -221,4 +221,3 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
|||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,9 +65,9 @@ export default function WorkflowEditorDrawer(props: {
|
|||
width={840}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||||
className="workflow-drawer"
|
||||
extra={
|
||||
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
|
||||
<Button type="primary" loading={saving} onClick={onSave} className="workflow-drawer-save">
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ export default function WorkflowEditorDrawer(props: {
|
|||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Space size="large" style={{ marginBottom: 12 }}>
|
||||
<Space size="large" className="workflow-drawer-switch-row">
|
||||
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
|
|
@ -91,7 +91,7 @@ export default function WorkflowEditorDrawer(props: {
|
|||
label={
|
||||
<Space>
|
||||
<span>Cron 表达式</span>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<Text type="secondary" className="workflow-drawer-help">
|
||||
5 段:分 时 日 月 周,例如 \"*/30 * * * *\"
|
||||
</Text>
|
||||
</Space>
|
||||
|
|
@ -118,10 +118,10 @@ export default function WorkflowEditorDrawer(props: {
|
|||
<Card
|
||||
size="small"
|
||||
title={<Text strong>Graph JSON(节点列表)</Text>}
|
||||
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
|
||||
className="workflow-drawer-card workflow-drawer-card-spaced"
|
||||
extra={
|
||||
<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>
|
||||
</Tooltip>
|
||||
|
|
@ -131,9 +131,9 @@ export default function WorkflowEditorDrawer(props: {
|
|||
rows={20}
|
||||
value={graphText}
|
||||
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>。
|
||||
<br />
|
||||
节点类型:agent / skill / http / transform / branch;branch 节点用 <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 [id, setId] = useState('');
|
||||
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>
|
||||
<Select
|
||||
value={type}
|
||||
style={{ width: 140 }}
|
||||
className="workflow-node-type-select"
|
||||
onChange={(v) => setType(v as WorkflowNodeType)}
|
||||
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
|
||||
type="primary"
|
||||
disabled={!id.trim()}
|
||||
style={{ borderRadius: 10 }}
|
||||
className="workflow-drawer-save"
|
||||
onClick={() => {
|
||||
const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' };
|
||||
if (type === 'branch') base.elseNext = '';
|
||||
|
|
@ -188,4 +188,3 @@ function defaultConfig(type: WorkflowNodeType): Record<string, any> {
|
|||
return { condition: 'steps.prev?.output?.value === true' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -146,18 +146,12 @@ body {
|
|||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-brand-mark {
|
||||
.login-brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--gradient-brand);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
box-shadow: var(--shadow-md);
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 12px 22px rgba(17, 103, 255, 0.16));
|
||||
}
|
||||
|
||||
.login-brand-name {
|
||||
|
|
@ -307,17 +301,12 @@ body {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar .brand .brand-mark {
|
||||
.sidebar .brand .brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--gradient-brand);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
|
||||
}
|
||||
|
||||
.sidebar .nav-section-label {
|
||||
|
|
@ -714,21 +703,18 @@ body {
|
|||
}
|
||||
|
||||
.chat-input-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-input-toolbar-left {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-input-toolbar .chat-send-button {
|
||||
margin-left: 0;
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-model-select .ant-select-selector {
|
||||
|
|
@ -736,14 +722,7 @@ body {
|
|||
}
|
||||
|
||||
.chat-model-select .ant-select-selection-item {
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-model-select .ant-select-selection-placeholder {
|
||||
max-width: 9rem;
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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%);
|
||||
}
|
||||
Loading…
Reference in New Issue