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>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>驷马智能</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>鲸域AI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
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 { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button, Drawer, Spin } from 'antd';
|
import { Button, Drawer, Spin } from 'antd';
|
||||||
import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
|
import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
|
@ -14,9 +14,11 @@ import TeamsPage from './pages/TeamsPage';
|
||||||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||||
import StatsPage from './pages/StatsPage';
|
import StatsPage from './pages/StatsPage';
|
||||||
import SharedSessionPage from './pages/SharedSessionPage';
|
import SharedSessionPage from './pages/SharedSessionPage';
|
||||||
import WorkflowsPage from './pages/WorkflowsPage';
|
import WorkflowsPage from './pages/WorkflowsPage';
|
||||||
import { useAuth } from './store/auth';
|
import { useAuth } from './store/auth';
|
||||||
import { AgentAPI } from './api';
|
import { AgentAPI } from './api';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
|
import kaiwuIcon from './assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||||
|
|
||||||
function HomeRedirect() {
|
function HomeRedirect() {
|
||||||
return <Navigate to="/chat" replace />;
|
return <Navigate to="/chat" replace />;
|
||||||
|
|
@ -24,10 +26,10 @@ function HomeRedirect() {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(() => (typeof window === 'undefined' ? false : window.innerWidth < 768));
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// 全局快捷键 Ctrl/⌘ + K
|
// 全局快捷键 Ctrl/⌘ + K
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,13 +44,7 @@ export default function App() {
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
const mainContent = (
|
||||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
return () => window.removeEventListener('resize', onResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const mainContent = (
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomeRedirect />} />
|
<Route path="/" element={<HomeRedirect />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
|
@ -89,7 +85,8 @@ export default function App() {
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="mobile-topbar">
|
<div className="mobile-topbar">
|
||||||
<Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} />
|
<Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} />
|
||||||
<div className="mobile-topbar-title">Agent Studio</div>
|
<img src={kaiwuIcon} alt="鲸域AI" className="mobile-topbar-logo" />
|
||||||
|
<div className="mobile-topbar-title">鲸域AI</div>
|
||||||
<Button type="text" icon={<SearchOutlined />} onClick={() => setPaletteOpen(true)} />
|
<Button type="text" icon={<SearchOutlined />} onClick={() => setPaletteOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -103,7 +100,7 @@ export default function App() {
|
||||||
open={mobileNavOpen}
|
open={mobileNavOpen}
|
||||||
onClose={() => setMobileNavOpen(false)}
|
onClose={() => setMobileNavOpen(false)}
|
||||||
width={280}
|
width={280}
|
||||||
styles={{ body: { padding: 0 } }}
|
className="mobile-nav-drawer"
|
||||||
>
|
>
|
||||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} />
|
<Sidebar onOpenPalette={() => setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -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';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../store/auth';
|
import { useAuth } from '../store/auth';
|
||||||
import { useTheme } from '../main';
|
import { useTheme } from '../main';
|
||||||
|
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenPalette?: () => void;
|
onOpenPalette?: () => void;
|
||||||
|
|
@ -67,9 +68,9 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<span className="brand-mark">A</span>
|
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
|
||||||
<span>Agent Studio</span>
|
<span>鲸域AI</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div className="sidebar-brand-spacer" />
|
||||||
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
||||||
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
||||||
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||||
|
|
@ -82,22 +83,16 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
onOpenPalette?.();
|
onOpenPalette?.();
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
}}
|
}}
|
||||||
className="nav-item"
|
className="nav-item sidebar-search-action"
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
marginBottom: 8
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<span className="sidebar-search-label">
|
||||||
<SearchOutlined className="nav-icon" />
|
<SearchOutlined className="nav-icon" />
|
||||||
<span>快速搜索</span>
|
<span>快速搜索</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="kbd">{cmdKey} K</span>
|
<span className="kbd">{cmdKey} K</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', marginRight: -4, paddingRight: 4 }}>
|
<div className="sidebar-scroll">
|
||||||
{NAV_GROUPS.map((group) => (
|
{NAV_GROUPS.map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label}>
|
||||||
<div className="nav-section-label">{group.label}</div>
|
<div className="nav-section-label">{group.label}</div>
|
||||||
|
|
@ -123,7 +118,7 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
label: <span style={{ color: 'var(--color-text-tertiary)' }}>{user.email}</span>,
|
label: <span className="sidebar-user-role">{user.email}</span>,
|
||||||
disabled: true
|
disabled: true
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
|
@ -147,28 +142,15 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
}}
|
}}
|
||||||
placement="topLeft"
|
placement="topLeft"
|
||||||
>
|
>
|
||||||
<div className="sidebar-user" style={{ marginTop: 8 }}>
|
<div className="sidebar-user">
|
||||||
<Avatar
|
<Avatar size={32} className="sidebar-user-avatar">
|
||||||
size={32}
|
|
||||||
style={{ background: 'var(--gradient-brand)', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{(user.name?.charAt(0) || '?').toUpperCase()}
|
{(user.name?.charAt(0) || '?').toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="sidebar-user-main">
|
||||||
<div
|
<div className="sidebar-user-name">
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
letterSpacing: '-0.005em'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.name}
|
{user.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
|
<div className="sidebar-user-role">
|
||||||
{user.role === 'admin' ? '管理员' : '成员'}
|
{user.role === 'admin' ? '管理员' : '成员'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import './styles/aura-theme.css';
|
||||||
|
import './styles/app-shell-h5.css';
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark';
|
type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
|
@ -40,17 +42,17 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
() => ({
|
() => ({
|
||||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: isDark ? '#e07b3e' : '#c2541f',
|
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
|
||||||
colorInfo: isDark ? '#e07b3e' : '#c2541f',
|
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
|
||||||
colorBgBase: isDark ? '#1a1816' : '#faf9f5',
|
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
||||||
colorBgContainer: isDark ? '#221f1c' : '#ffffff',
|
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
||||||
colorBgElevated: isDark ? '#2b2824' : '#ffffff',
|
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
||||||
colorBgLayout: isDark ? '#1a1816' : '#faf9f5',
|
colorBgLayout: isDark ? '#071126' : '#f5f9ff',
|
||||||
colorBorder: isDark ? '#36322c' : '#ebe7da',
|
colorBorder: isDark ? '#1f3764' : '#d6e5fb',
|
||||||
colorBorderSecondary: isDark ? '#2b2824' : '#f0ece0',
|
colorBorderSecondary: isDark ? '#17294c' : '#e3efff',
|
||||||
colorText: isDark ? '#f3efe6' : '#2a2622',
|
colorText: isDark ? '#edf5ff' : '#06143f',
|
||||||
colorTextSecondary: isDark ? '#b6afa3' : '#6b6660',
|
colorTextSecondary: isDark ? '#b6c8e8' : '#405784',
|
||||||
colorTextTertiary: isDark ? '#7e7869' : '#a09a8e',
|
colorTextTertiary: isDark ? '#7f93ba' : '#7c8caf',
|
||||||
colorSuccess: isDark ? '#7fb87d' : '#4f8a4d',
|
colorSuccess: isDark ? '#7fb87d' : '#4f8a4d',
|
||||||
colorWarning: isDark ? '#d49a4a' : '#b8782a',
|
colorWarning: isDark ? '#d49a4a' : '#b8782a',
|
||||||
colorError: isDark ? '#e07060' : '#c0392b',
|
colorError: isDark ? '#e07060' : '#c0392b',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Modal, Button, Upload } from 'antd';
|
import { Modal, Button, Upload } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import { Agent, AgentAPI } from '../../../api';
|
import { Agent } from '../../../api';
|
||||||
import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants';
|
import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants';
|
||||||
|
|
||||||
interface AvatarSelectorProps {
|
interface AvatarSelectorProps {
|
||||||
|
|
@ -21,43 +21,53 @@ export default function AvatarSelector({
|
||||||
onAvatarSelect,
|
onAvatarSelect,
|
||||||
}: AvatarSelectorProps) {
|
}: AvatarSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<Modal title="选择头像形象" open={open} onCancel={onCancel} footer={null} width={600} centered>
|
<Modal
|
||||||
<div className="py-4">
|
title="选择头像形象"
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
open={open}
|
||||||
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
onCancel={onCancel}
|
||||||
点击即可更换
|
footer={null}
|
||||||
</div>
|
width={600}
|
||||||
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadEditAvatar}>
|
centered
|
||||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
|
className="agent-editor-avatar-modal"
|
||||||
|
>
|
||||||
|
<div className="agent-editor-avatar-modal-content">
|
||||||
|
<div className="agent-editor-avatar-modal-toolbar">
|
||||||
|
<div className="agent-editor-avatar-heading">点击即可更换</div>
|
||||||
|
<Upload
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUploadEditAvatar}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={avatarUploading} className="agent-editor-upload-button">
|
||||||
上传图片
|
上传图片
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 p-4 rounded-2xl max-h-[400px] overflow-y-auto monica-scrollbar" style={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px' }}>
|
<div className="agent-editor-avatar-grid agent-editor-avatar-grid-panel monica-scrollbar">
|
||||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
|
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
|
||||||
<div
|
const isSelected = agent?.avatar === url;
|
||||||
key={url}
|
|
||||||
onClick={async () => {
|
return (
|
||||||
if (!agent?.id) return;
|
<button
|
||||||
await onAvatarSelect(url);
|
type="button"
|
||||||
onCancel();
|
key={url}
|
||||||
}}
|
onClick={async () => {
|
||||||
className={`relative rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 mx-auto ${agent?.avatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
|
if (!agent?.id) return;
|
||||||
style={{ width: 80, height: 80, minWidth: 80, borderColor: agent?.avatar === url ? 'var(--color-brand)' : 'transparent' }}
|
await onAvatarSelect(url);
|
||||||
>
|
onCancel();
|
||||||
<img src={url} className="w-full h-full object-cover" alt="preset" />
|
}}
|
||||||
{agent?.avatar === url && (
|
className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
|
||||||
<div
|
aria-label="选择头像形象"
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
>
|
||||||
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
|
<img src={url} className="agent-editor-avatar-image" alt="preset" />
|
||||||
>
|
{isSelected && (
|
||||||
<div className="bg-white rounded-full p-0.5 shadow-sm">
|
<span className="agent-editor-avatar-selected">
|
||||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
|
<span className="agent-editor-avatar-dot" />
|
||||||
</div>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</button>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Form, Input, Select, Collapse, Button, List, Popconfirm, Tag, Switch, InputNumber } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { DatabaseOutlined, RocketOutlined, SettingOutlined, ToolOutlined } from '@ant-design/icons';
|
import { Agent, Team } from '../../../api';
|
||||||
import { Agent, Team, AgentAPI } from '../../../api';
|
import AgentIdentityCard from './capability/AgentIdentityCard';
|
||||||
import { STATUS_TAG, isImageUrl, parseModelSelections } from '../constants';
|
import BasicSettingsPanel from './capability/BasicSettingsPanel';
|
||||||
import ModelCheckboxDropdown from './ModelCheckboxDropdown';
|
import KnowledgeSettingsPanel from './capability/KnowledgeSettingsPanel';
|
||||||
|
import ModelSettingsCard from './capability/ModelSettingsCard';
|
||||||
|
import WebSearchCard from './capability/WebSearchCard';
|
||||||
|
|
||||||
interface CapabilitySettingsProps {
|
interface CapabilitySettingsProps {
|
||||||
form: any;
|
form: any;
|
||||||
|
|
@ -41,258 +43,25 @@ export default function CapabilitySettings({
|
||||||
这里决定它用什么模型、拥有哪些知识和技能,以及它会以怎样的方式被别人看到。
|
这里决定它用什么模型、拥有哪些知识和技能,以及它会以怎样的方式被别人看到。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="agent-editor-badge">
|
<span className="agent-editor-badge">Capability</span>
|
||||||
<ToolOutlined />
|
|
||||||
Capability
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form form={form} layout="vertical" onValuesChange={markDirty}>
|
<Form form={form} layout="vertical" onValuesChange={markDirty}>
|
||||||
<div className="agent-editor-intro" style={{ marginBottom: 18 }}>
|
<AgentIdentityCard
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
agent={agent}
|
||||||
<div
|
currentName={currentName}
|
||||||
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto"
|
agentName={agentName}
|
||||||
style={{
|
selectedAvatar={selectedAvatar}
|
||||||
width: 88,
|
setAvatarSelectorOpen={setAvatarSelectorOpen}
|
||||||
height: 88,
|
|
||||||
background: isImageUrl(agent?.avatar) ? '#f3f4f6' : agent?.avatar,
|
|
||||||
border: '3px solid rgba(255,255,255,0.92)',
|
|
||||||
}}
|
|
||||||
onClick={() => setAvatarSelectorOpen(true)}
|
|
||||||
>
|
|
||||||
{isImageUrl(agent?.avatar) ? (
|
|
||||||
<img src={agent?.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="avatar" />
|
|
||||||
) : (
|
|
||||||
(agent?.name?.charAt(0) || agentName?.charAt(0) || '?').toUpperCase()
|
|
||||||
)}
|
|
||||||
<div className="avatar-overlay">
|
|
||||||
<span className="avatar-overlay-text">更换形象</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
{currentName && (
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
|
|
||||||
{currentName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
|
|
||||||
{agent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}
|
|
||||||
</div>
|
|
||||||
<Button size="small" type="default" className="rounded-lg h-8" onClick={() => setAvatarSelectorOpen(true)}>
|
|
||||||
修改头像
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
expandIconPosition="end"
|
|
||||||
className="monica-collapse-bordered mb-4"
|
|
||||||
defaultActiveKey={['basic']}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'basic',
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
|
|
||||||
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
基础设置
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-4 mb-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-[11px] text-gray-400">名称、描述和可见性决定了这个智能体如何被理解和管理。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]} className="mb-3">
|
|
||||||
<Input placeholder="智能体名称" style={{ borderRadius: 12, height: 42 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="description" label="描述" className="mb-3">
|
|
||||||
<Input.TextArea rows={3} placeholder="简短描述这个智能体是做什么的..." style={{ borderRadius: 12 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Form.Item name="visibility" label="可见性" className="flex-1 mb-0">
|
|
||||||
<Select
|
|
||||||
size="middle"
|
|
||||||
style={{ minHeight: 42 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'private', label: '🔒 私有' },
|
|
||||||
{ value: 'team', label: '👥 团队' },
|
|
||||||
{ value: 'public', label: '🌐 公开' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="teamId" label="归属团队" className="flex-1 mb-0" tooltip="团队功能暂未开放">
|
|
||||||
<Select
|
|
||||||
size="middle"
|
|
||||||
placeholder="暂不开放"
|
|
||||||
disabled
|
|
||||||
allowClear
|
|
||||||
style={{ minHeight: 42 }}
|
|
||||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
<BasicSettingsPanel teams={teams} />
|
||||||
<div className="monica-card !mb-4" style={{ borderRadius: 18 }}>
|
<ModelSettingsCard models={models} />
|
||||||
<div className="flex items-center gap-2 mb-4 font-medium text-gray-700">
|
<KnowledgeSettingsPanel
|
||||||
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
|
agent={agent}
|
||||||
模型设置
|
beforeUploadKnowledge={beforeUploadKnowledge}
|
||||||
</div>
|
onDeleteKnowledge={onDeleteKnowledge}
|
||||||
<div className="space-y-4">
|
|
||||||
<Form.Item
|
|
||||||
name="model"
|
|
||||||
label="模型"
|
|
||||||
required
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
validator: async (_rule, value) => {
|
|
||||||
const selected = parseModelSelections(value);
|
|
||||||
if (selected.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('请选择至少一个模型');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="mb-0"
|
|
||||||
getValueProps={(value) => ({ value: parseModelSelections(value) })}
|
|
||||||
>
|
|
||||||
<ModelCheckboxDropdown models={models} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<InputNumber min={0} max={2} step={0.1} className="w-20" style={{ borderRadius: 12, height: 42 }} />
|
|
||||||
<span className="text-[11px] text-gray-400">控制随机性,数值越高越发散</span>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
expandIconPosition="end"
|
|
||||||
className="monica-collapse"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'knowledge',
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
|
|
||||||
<DatabaseOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
知识库 ({agent?.knowledge?.length ?? 0})
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div className="px-1">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<span className="text-xs text-gray-500">上传文档以增强 AI 知识</span>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
id="knowledge-upload"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (files) {
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
await beforeUploadKnowledge(files[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
ghost
|
|
||||||
icon={<DatabaseOutlined />}
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onClick={() => document.getElementById('knowledge-upload')?.click()}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<List
|
|
||||||
size="small"
|
|
||||||
dataSource={agent?.knowledge ?? []}
|
|
||||||
renderItem={(item) => (
|
|
||||||
<List.Item
|
|
||||||
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
|
|
||||||
actions={[
|
|
||||||
<Popconfirm
|
|
||||||
key="del"
|
|
||||||
title="确认删除?"
|
|
||||||
onConfirm={() => onDeleteKnowledge(item.id)}
|
|
||||||
>
|
|
||||||
<Button type="text" danger size="small" style={{ borderRadius: 8 }}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1 overflow-hidden">
|
|
||||||
<span className="text-sm font-medium truncate">{item.originalName}</span>
|
|
||||||
<span className="text-[10px] text-gray-400">
|
|
||||||
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''} ·{' '}
|
|
||||||
{item.status === 'indexing' ? (
|
|
||||||
<Tag color="processing" className="m-0 text-[10px] px-1">
|
|
||||||
<span
|
|
||||||
className="inline-block"
|
|
||||||
style={{ animation: 'pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite' }}
|
|
||||||
>
|
|
||||||
索引中…
|
|
||||||
</span>
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
|
|
||||||
{STATUS_TAG[(item.status || 'ready')].text}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'skills',
|
|
||||||
collapsible: 'disabled',
|
|
||||||
label: (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 font-medium text-gray-400 cursor-not-allowed"
|
|
||||||
title="技能功能开发中"
|
|
||||||
>
|
|
||||||
<ToolOutlined />
|
|
||||||
技能 & 工具 (开发中)
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: null,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
<WebSearchCard />
|
||||||
<div className="monica-card mt-6" style={{ borderRadius: 18 }}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
|
|
||||||
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
联网搜索
|
|
||||||
</div>
|
|
||||||
<Form.Item name="webSearchEnabled" valuePropName="checked" className="mb-0">
|
|
||||||
<Switch size="small" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-gray-400">
|
|
||||||
启用后,智能体可以在回答时通过 DuckDuckGo 搜索实时信息。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,22 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/agents')}
|
onClick={() => navigate('/agents')}
|
||||||
className="hover:bg-gray-100"
|
className="agent-editor-icon-action hover:bg-gray-100"
|
||||||
style={{ borderRadius: 12, width: 40, height: 40 }}
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RocketOutlined style={{ color: 'var(--color-brand)', fontSize: 18 }} />
|
<RocketOutlined className="agent-editor-header-icon" />
|
||||||
<span className="font-bold text-lg text-gray-800" style={{ color: 'var(--color-text)' }}>
|
<span className="agent-editor-header-title font-bold text-lg text-gray-800">
|
||||||
{isNew ? '创建新智能体' : currentName || ''}
|
{isNew ? '创建新智能体' : currentName || ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginTop: 2 }}>
|
<div className="agent-editor-header-subtitle">
|
||||||
围绕个性化、能力配置与实时预览,搭建一个更完整的 AI 工作台。
|
围绕个性化、能力配置与实时预览,搭建一个更完整的 AI 工作台。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-xs text-gray-400" style={{ color: 'var(--color-text-tertiary)' }}>
|
<span className="agent-editor-save-state text-xs text-gray-400">
|
||||||
{autoSaveStatus === 'saving'
|
{autoSaveStatus === 'saving'
|
||||||
? '正在保存...'
|
? '正在保存...'
|
||||||
: autoSaveStatus === 'dirty'
|
: autoSaveStatus === 'dirty'
|
||||||
|
|
@ -43,7 +42,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
||||||
? '保存失败'
|
? '保存失败'
|
||||||
: '已保存'}
|
: '已保存'}
|
||||||
</span>
|
</span>
|
||||||
<Button icon={<FileTextOutlined />} style={{ borderRadius: 12, height: 40 }}>
|
<Button icon={<FileTextOutlined />} className="agent-editor-header-action">
|
||||||
文档
|
文档
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -51,7 +50,7 @@ export default function Header({ isNew, currentName, navigate, autoSaveStatus, s
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
style={{ borderRadius: 12, height: 40, paddingInline: 16, fontWeight: 600 }}
|
className="agent-editor-save-action"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ interface InitModalProps {
|
||||||
beforeUploadInitAvatar: (file: any) => Promise<boolean>;
|
beforeUploadInitAvatar: (file: any) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROFILE_SUGGESTIONS = ['客服助理', '内容创作', '数据分析', '私人教练'];
|
||||||
|
|
||||||
export default function InitModal({
|
export default function InitModal({
|
||||||
open,
|
open,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
|
@ -28,6 +30,7 @@ export default function InitModal({
|
||||||
beforeUploadInitAvatar,
|
beforeUploadInitAvatar,
|
||||||
}: InitModalProps) {
|
}: InitModalProps) {
|
||||||
const [initForm] = Form.useForm();
|
const [initForm] = Form.useForm();
|
||||||
|
const selectedName = agentName || '你的新智能体';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -39,61 +42,34 @@ export default function InitModal({
|
||||||
centered
|
centered
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
className="agent-editor-init-modal"
|
||||||
>
|
>
|
||||||
<div className="py-2">
|
<div className="agent-editor-init-content">
|
||||||
<div style={{ marginBottom: 18 }}>
|
<div className="agent-editor-init-header">
|
||||||
<div
|
<div className="agent-editor-init-badge">
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(8, 145, 178, 0.08)',
|
|
||||||
color: 'var(--color-brand)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RocketOutlined />
|
<RocketOutlined />
|
||||||
第一步 · 定义智能体形象
|
第一步 · 定义智能体形象
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>
|
<div className="agent-editor-init-title">
|
||||||
先给你的智能体一个更完整的开场
|
先给你的智能体一个更完整的开场
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14.5, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
|
<div className="agent-editor-init-subtitle">
|
||||||
先决定它的形象、名字和一句话定位。确认后会进入三栏工作台,继续完成个性化、能力配置和实时预览。
|
先决定它的形象、名字和一句话定位。确认后会进入三栏工作台,继续完成个性化、能力配置和实时预览。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="agent-editor-modal-hero">
|
<div className="agent-editor-modal-hero">
|
||||||
<div
|
<div className="agent-editor-modal-card agent-editor-preview-card">
|
||||||
className="agent-editor-modal-card"
|
<div className="agent-editor-avatar-preview">
|
||||||
style={{
|
|
||||||
padding: '22px 20px',
|
|
||||||
background: 'linear-gradient(180deg, rgba(236,253,245,0.96) 0%, rgba(240,249,255,0.96) 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto"
|
|
||||||
style={{
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
background: isImageUrl(selectedAvatar) ? '#f3f4f6' : selectedAvatar,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImageUrl(selectedAvatar) ? (
|
{isImageUrl(selectedAvatar) ? (
|
||||||
<img src={selectedAvatar} className="w-full h-full object-cover" alt="avatar" />
|
<img src={selectedAvatar} className="agent-editor-avatar-image" alt="avatar" />
|
||||||
) : (
|
) : (
|
||||||
(agentName?.charAt(0) || '?').toUpperCase()
|
selectedName.charAt(0).toUpperCase()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
<div className="agent-editor-preview-text">
|
||||||
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
|
<div className="agent-editor-preview-name">{selectedName}</div>
|
||||||
{agentName || '你的新智能体'}
|
<div className="agent-editor-preview-caption">
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
|
||||||
这里会实时映射你输入的名字与选择的形象。
|
这里会实时映射你输入的名字与选择的形象。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,81 +82,54 @@ export default function InitModal({
|
||||||
onValuesChange={(changed) => {
|
onValuesChange={(changed) => {
|
||||||
if (changed.name !== undefined) setAgentName(changed.name);
|
if (changed.name !== undefined) setAgentName(changed.name);
|
||||||
}}
|
}}
|
||||||
className="agent-editor-modal-card"
|
className="agent-editor-modal-card agent-editor-init-form"
|
||||||
style={{ padding: 20 }}
|
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label={
|
label={<span className="agent-editor-form-label">智能体名称</span>}
|
||||||
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
智能体名称
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
rules={[{ required: true, message: '请输入智能体名称' }]}
|
rules={[{ required: true, message: '请输入智能体名称' }]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="给你的智能体起个名字"
|
placeholder="给你的智能体起个名字"
|
||||||
size="large"
|
size="large"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded-xl h-12 border-gray-200 focus:border-cyan-500"
|
className="agent-editor-form-input"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="description"
|
name="description"
|
||||||
label={
|
label={<span className="agent-editor-form-label">描述(选填)</span>}
|
||||||
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
描述(选填)
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
placeholder="介绍一下这个智能体是做什么的..."
|
placeholder="介绍一下这个智能体是做什么的..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="rounded-xl border-gray-200 focus:border-cyan-500"
|
className="agent-editor-form-textarea"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
<div className="agent-editor-suggestion-list">
|
||||||
{['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => (
|
{PROFILE_SUGGESTIONS.map((label) => (
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
key={label}
|
key={label}
|
||||||
onClick={() => initForm.setFieldsValue({ description: label })}
|
onClick={() => initForm.setFieldsValue({ description: label })}
|
||||||
style={{
|
className="agent-editor-suggestion-chip"
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12.5,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--color-border)';
|
|
||||||
e.currentTarget.style.color = 'var(--color-text)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--color-surface-2)';
|
|
||||||
e.currentTarget.style.color = 'var(--color-text-secondary)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 pt-2">
|
<div className="agent-editor-init-actions">
|
||||||
<Button
|
<Button onClick={onCancel} className="agent-editor-secondary-action">
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 h-12 rounded-xl border-gray-200 text-gray-500 font-semibold hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
loading={saving}
|
loading={saving}
|
||||||
className="flex-1 h-12 rounded-xl border-none font-semibold"
|
className="agent-editor-primary-action"
|
||||||
>
|
>
|
||||||
确认创建
|
确认创建
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -188,41 +137,43 @@ export default function InitModal({
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="agent-editor-avatar-section">
|
||||||
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-3 px-1">
|
<div className="agent-editor-avatar-heading">选择你的智能体形象</div>
|
||||||
选择你的智能体形象
|
<div className="agent-editor-avatar-toolbar">
|
||||||
</div>
|
<div className="agent-editor-avatar-hint">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
|
||||||
默认会使用系统头像,你也可以上传自己的图片替换。
|
默认会使用系统头像,你也可以上传自己的图片替换。
|
||||||
</div>
|
</div>
|
||||||
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadInitAvatar}>
|
<Upload
|
||||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUploadInitAvatar}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={avatarUploading} className="agent-editor-upload-button">
|
||||||
上传图片
|
上传图片
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-editor-avatar-grid monica-scrollbar" style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px' }}>
|
<div className="agent-editor-avatar-grid monica-scrollbar">
|
||||||
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
|
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => {
|
||||||
<div
|
const isSelected = selectedAvatar === url;
|
||||||
key={url}
|
|
||||||
onClick={() => setSelectedAvatar(url)}
|
return (
|
||||||
className={`relative rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 mx-auto ${selectedAvatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
|
<button
|
||||||
style={{ width: 80, height: 80, minWidth: 80, borderColor: selectedAvatar === url ? 'var(--color-brand)' : 'transparent' }}
|
type="button"
|
||||||
>
|
key={url}
|
||||||
<img src={url} className="w-full h-full object-cover" alt="preset" />
|
onClick={() => setSelectedAvatar(url)}
|
||||||
{selectedAvatar === url && (
|
className={`agent-editor-avatar-option ${isSelected ? 'agent-editor-avatar-option-active' : ''}`}
|
||||||
<div
|
aria-label="选择智能体头像"
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
>
|
||||||
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
|
<img src={url} className="agent-editor-avatar-image" alt="preset" />
|
||||||
>
|
{isSelected && (
|
||||||
<div className="bg-white rounded-full p-0.5 shadow-sm">
|
<span className="agent-editor-avatar-selected">
|
||||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
|
<span className="agent-editor-avatar-dot" />
|
||||||
</div>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</button>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,13 @@ export default function PreviewPane({ liveAgent, agentId }: PreviewPaneProps) {
|
||||||
Live Preview
|
Live Preview
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-editor-intro" style={{ marginBottom: 14 }}>
|
<div className="agent-editor-intro agent-editor-preview-intro">
|
||||||
<div className="agent-editor-intro-title">当前预览角色</div>
|
<div className="agent-editor-intro-title">当前预览角色</div>
|
||||||
<div className="agent-editor-intro-text">
|
<div className="agent-editor-intro-text">
|
||||||
{liveAgent?.name || '未命名智能体'} 会基于左侧 Prompt 与中栏设置立即更新。你可以直接在这里检查语气、头像和整体观感。
|
{liveAgent?.name || '未命名智能体'} 会基于左侧 Prompt 与中栏设置立即更新。你可以直接在这里检查语气、头像和整体观感。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-editor-surface" style={{ flex: 1, overflow: 'hidden' }}>
|
<div className="agent-editor-surface agent-editor-preview-surface">
|
||||||
<ChatPreview agent={liveAgent} agentId={agentId} />
|
<ChatPreview agent={liveAgent} agentId={agentId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
|
||||||
onValuesChange={markDirty}
|
onValuesChange={markDirty}
|
||||||
>
|
>
|
||||||
<div className="agent-editor-surface agent-editor-prompt-wrap">
|
<div className="agent-editor-surface agent-editor-prompt-wrap">
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10, paddingInline: 4 }}>
|
<div className="agent-editor-prompt-hint">
|
||||||
建议写清楚角色设定、目标用户、语气和回答结构。
|
建议写清楚角色设定、目标用户、语气和回答结构。
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-editor-prompt">
|
<div className="agent-editor-prompt">
|
||||||
|
|
@ -44,13 +44,12 @@ export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={30}
|
rows={30}
|
||||||
placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
|
placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
|
||||||
className="border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
|
className="agent-editor-prompt-textarea border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
|
||||||
style={{ height: 'calc(100vh - 290px)', resize: 'none', borderRadius: 16 }}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-400 mt-2 px-2" style={{ color: 'var(--color-text-tertiary)' }}>
|
<div className="agent-editor-prompt-footer text-[11px] text-gray-400 mt-2 px-2">
|
||||||
提示:写得越具体,预览区里的回答风格越稳定。
|
提示:写得越具体,预览区里的回答风格越稳定。
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -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 PreviewPane from './components/PreviewPane';
|
||||||
import InitModal from './components/InitModal';
|
import InitModal from './components/InitModal';
|
||||||
import AvatarSelector from './components/AvatarSelector';
|
import AvatarSelector from './components/AvatarSelector';
|
||||||
|
import './styles/agent-editor-capability.css';
|
||||||
|
import './styles/agent-editor-panels.css';
|
||||||
|
import './styles/agent-editor-modal.css';
|
||||||
|
import './styles/agent-editor-h5.css';
|
||||||
|
|
||||||
export default function AgentEditor() {
|
export default function AgentEditor() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -52,7 +56,7 @@ export default function AgentEditor() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell" style={{ background: 'var(--color-bg)' }}>
|
<div className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell">
|
||||||
<Header
|
<Header
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
currentName={currentName}
|
currentName={currentName}
|
||||||
|
|
@ -62,7 +66,7 @@ export default function AgentEditor() {
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden agent-editor-workbench" style={{ background: 'var(--color-bg)' }}>
|
<div className="flex-1 overflow-hidden agent-editor-workbench">
|
||||||
<PromptEditor form={form} markDirty={markDirty} />
|
<PromptEditor form={form} markDirty={markDirty} />
|
||||||
<CapabilitySettings
|
<CapabilitySettings
|
||||||
form={form}
|
form={form}
|
||||||
|
|
|
||||||
|
|
@ -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 { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { Form, Input, Button, Tabs, App as AntApp, Alert } from 'antd';
|
import { useLoginPageLogic } from './LoginPage/LoginPageLogic';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import LoginPageH5 from './LoginPage/components/LoginPageH5';
|
||||||
import { useAuth } from '../store/auth';
|
import LoginPageWeb from './LoginPage/components/LoginPageWeb';
|
||||||
|
import './LoginPage/styles/login-page-web.css';
|
||||||
|
import './LoginPage/styles/login-page-h5.css';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
const logic = useLoginPageLogic();
|
||||||
const navigate = useNavigate();
|
const isMobile = useIsMobile(1100);
|
||||||
const [params] = useSearchParams();
|
|
||||||
const next = params.get('next') || '/';
|
|
||||||
const { login, register } = useAuth();
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const onLogin = async (values: any) => {
|
return isMobile ? <LoginPageH5 logic={logic} /> : <LoginPageWeb logic={logic} />;
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await login(values.email, values.password);
|
|
||||||
message.success('登录成功');
|
|
||||||
navigate(next, { replace: true });
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.response?.data?.error ?? e?.message ?? '登录失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRegister = async (values: any) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await register({
|
|
||||||
email: values.email,
|
|
||||||
password: values.password,
|
|
||||||
name: values.name,
|
|
||||||
inviteCode: values.inviteCode || undefined
|
|
||||||
});
|
|
||||||
message.success('注册成功,已自动登录');
|
|
||||||
navigate(next, { replace: true });
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.response?.data?.error ?? e?.message ?? '注册失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="login-page">
|
|
||||||
<div className="login-deco login-deco-1" />
|
|
||||||
<div className="login-deco login-deco-2" />
|
|
||||||
|
|
||||||
<div className="login-content">
|
|
||||||
<div className="login-brand-panel">
|
|
||||||
<div className="login-brand-header">
|
|
||||||
<div className="login-brand-mark">A</div>
|
|
||||||
<span className="login-brand-name">Agent Studio</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="login-title">
|
|
||||||
为你的工作流构建
|
|
||||||
<br />
|
|
||||||
<span className="login-title-highlight">专属 AI 智能体</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="login-subtitle">
|
|
||||||
可视化编排提示词、知识库与工具,让每一位团队成员都能调用最契合的 AI 能力。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="login-features">
|
|
||||||
{[
|
|
||||||
{ icon: '✦', text: '多模型即插即用' },
|
|
||||||
{ icon: '✦', text: '知识库 + RAG 检索' },
|
|
||||||
{ icon: '✦', text: '可分享智能体' }
|
|
||||||
].map((it) => (
|
|
||||||
<div key={it.text} className="login-feature-item">
|
|
||||||
<span className="login-feature-icon">{it.icon}</span>
|
|
||||||
{it.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="login-form-panel">
|
|
||||||
<div className="login-card">
|
|
||||||
<div className="login-card-header">
|
|
||||||
<h2 className="login-card-title">欢迎回来</h2>
|
|
||||||
<div className="login-card-subtitle">使用邮箱登录或注册以继续</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
activeKey={tab}
|
|
||||||
onChange={(k) => setTab(k as any)}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'login',
|
|
||||||
label: '登录',
|
|
||||||
children: (
|
|
||||||
<Form layout="vertical" onFinish={onLogin} className="login-form">
|
|
||||||
<Form.Item
|
|
||||||
name="email"
|
|
||||||
label="邮箱"
|
|
||||||
rules={[{ required: true, type: 'email', message: '请填写合法邮箱' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="you@example.com" size="large" autoFocus />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
|
|
||||||
<Input.Password placeholder="••••••" size="large" />
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={loading}
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
className="login-submit-btn"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'register',
|
|
||||||
label: '注册',
|
|
||||||
children: (
|
|
||||||
<Form layout="vertical" onFinish={onRegister} className="login-form">
|
|
||||||
<Alert
|
|
||||||
className="login-register-alert"
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
message="第一个注册的用户自动成为管理员;之后需要邀请码"
|
|
||||||
/>
|
|
||||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}>
|
|
||||||
<Input placeholder="you@example.com" size="large" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="name" label="昵称" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="张三" size="large" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
label="密码"
|
|
||||||
rules={[{ required: true, min: 6, message: '至少 6 位' }]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="••••••" size="large" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="inviteCode" label="邀请码(可选)">
|
|
||||||
<Input placeholder="如果你是被邀请的用户" size="large" />
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={loading}
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
className="login-submit-btn"
|
|
||||||
>
|
|
||||||
创建账户
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="login-footer">登录即表示你已同意我们的服务条款与隐私政策</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { usePointsMallPageLogic } from './PointsMallPageLogic';
|
||||||
import PointsMallPageWeb from './components/PointsMallPageWeb';
|
import PointsMallPageWeb from './components/PointsMallPageWeb';
|
||||||
import PointsMallPageH5 from './components/PointsMallPageH5';
|
import PointsMallPageH5 from './components/PointsMallPageH5';
|
||||||
|
|
||||||
const isMobileDevice = () => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return window.innerWidth < 768;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PointsMallPage() {
|
export default function PointsMallPage() {
|
||||||
const logic = usePointsMallPageLogic();
|
const logic = usePointsMallPageLogic();
|
||||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setIsMobile(isMobileDevice());
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
|
return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,35 +16,35 @@ export default function ConfirmExchangeModal(props: {
|
||||||
const canAfford = product ? userPoints >= totalPoints : false;
|
const canAfford = product ? userPoints >= totalPoints : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="确认兑换" open={open} onCancel={onCancel} footer={null} width={460} destroyOnClose>
|
<Modal title="确认兑换" open={open} onCancel={onCancel} footer={null} width={460} destroyOnHidden>
|
||||||
{!product ? null : (
|
{!product ? null : (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 10 }}>{product.name}</div>
|
<div className="points-confirm-product-name">{product.name}</div>
|
||||||
<Space size={8} wrap style={{ marginBottom: 16 }}>
|
<Space size={8} wrap className="points-confirm-tags">
|
||||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
<Tag color="processing" className="points-confirm-tag">
|
||||||
单价 {product.pointsPrice.toLocaleString()} 积分
|
单价 {product.pointsPrice.toLocaleString()} 积分
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color="default" style={{ marginInlineEnd: 0 }}>
|
<Tag color="default" className="points-confirm-tag">
|
||||||
当前余额 {userPoints.toLocaleString()}
|
当前余额 {userPoints.toLocaleString()}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color={canAfford ? 'success' : 'error'} style={{ marginInlineEnd: 0 }}>
|
<Tag color={canAfford ? 'success' : 'error'} className="points-confirm-tag">
|
||||||
合计 {totalPoints.toLocaleString()}
|
合计 {totalPoints.toLocaleString()}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
<div className="points-confirm-quantity-row">
|
||||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>兑换数量</div>
|
<div className="points-confirm-label">兑换数量</div>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
max={Math.max(1, product.stock || 1)}
|
max={Math.max(1, product.stock || 1)}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onChange={(v) => onQuantityChange(Number(v || 1))}
|
onChange={(v) => onQuantityChange(Number(v || 1))}
|
||||||
style={{ width: 140 }}
|
className="points-confirm-quantity-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ color: 'var(--color-text-tertiary)', fontSize: 12.5, lineHeight: 1.6, marginBottom: 16 }}>
|
<div className="points-confirm-tip">
|
||||||
确认后将发起兑换申请并冻结积分/预扣库存,成功后请继续填写收件信息完成兑换。
|
确认后将发起兑换申请并冻结积分/预扣库存,成功后请继续填写收件信息完成兑换。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -56,4 +56,3 @@ export default function ConfirmExchangeModal(props: {
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export default function ExchangeModal(props: {
|
||||||
}, [province, city, region.provinces]);
|
}, [province, city, region.provinces]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="兑换商品" open={open} onCancel={onCancel} footer={null} width={500} className="points-exchange-modal" destroyOnClose>
|
<Modal title="兑换商品" open={open} onCancel={onCancel} footer={null} width={500} className="points-exchange-modal" destroyOnHidden>
|
||||||
{product && (
|
{product && (
|
||||||
<>
|
<>
|
||||||
<div className="points-exchange-modal-header">
|
<div className="points-exchange-modal-header">
|
||||||
|
|
|
||||||
|
|
@ -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 { Form } from 'antd';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
|
||||||
import type { PointsMallProduct } from '../../../api';
|
|
||||||
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
|
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
|
||||||
import ExchangeModal from '../components/ExchangeModal';
|
import ConfirmExchangeModal from './ConfirmExchangeModal';
|
||||||
import ConfirmExchangeModal from '../components/ConfirmExchangeModal';
|
import ExchangeModal from './ExchangeModal';
|
||||||
|
import PointsMallH5Products from './PointsMallH5Products';
|
||||||
|
import PointsMallH5Top from './PointsMallH5Top';
|
||||||
import type { ExchangeFormValues } from '../types';
|
import type { ExchangeFormValues } from '../types';
|
||||||
|
import '../styles/points-mall-h5.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logic: PointsMallPageLogicOutput;
|
logic: PointsMallPageLogicOutput;
|
||||||
|
|
@ -13,234 +14,21 @@ interface Props {
|
||||||
export default function PointsMallPageH5({ logic }: Props) {
|
export default function PointsMallPageH5({ logic }: Props) {
|
||||||
const [exchangeForm] = Form.useForm<ExchangeFormValues>();
|
const [exchangeForm] = Form.useForm<ExchangeFormValues>();
|
||||||
const {
|
const {
|
||||||
overview,
|
|
||||||
overviewLoading,
|
|
||||||
categories,
|
|
||||||
categoryId,
|
|
||||||
q,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
productsLoading,
|
|
||||||
products,
|
|
||||||
total,
|
|
||||||
userPoints,
|
|
||||||
totalSpentUSD,
|
|
||||||
banner,
|
|
||||||
promoEntries,
|
|
||||||
exchangeModalVisible,
|
exchangeModalVisible,
|
||||||
confirmModalVisible,
|
confirmModalVisible,
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
exchangeQuantity,
|
exchangeQuantity,
|
||||||
pendingExpiresAt,
|
pendingExpiresAt,
|
||||||
exchangeLoading,
|
exchangeLoading,
|
||||||
setCategoryId,
|
userPoints,
|
||||||
setQ,
|
|
||||||
setSort,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
handleExchangeClick,
|
|
||||||
setExchangeModalVisible,
|
setExchangeModalVisible,
|
||||||
setConfirmModalVisible,
|
setConfirmModalVisible
|
||||||
} = logic;
|
} = logic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container h5-page-container" style={{ paddingLeft: 8, paddingRight: 8 }}>
|
<div className="page-container h5-page-container points-mall-page-h5">
|
||||||
<div className="points-mall-hero h5-points-mall-hero" style={{ padding: '16px 12px' }}>
|
<PointsMallH5Top logic={logic} />
|
||||||
<div className="points-mall-header h5-points-mall-header" style={{ flexDirection: 'column', gap: 12 }}>
|
<PointsMallH5Products logic={logic} />
|
||||||
<div className="points-mall-title-section">
|
|
||||||
<h1 className="page-title stats-page-title h5-page-title" style={{ fontSize: 24, marginBottom: 6 }}>积分商城</h1>
|
|
||||||
<p className="page-subtitle stats-page-subtitle h5-page-subtitle" style={{ fontSize: 13, lineHeight: 1.5 }}>
|
|
||||||
使用积分兑换权益、工具和活动礼包。<br />积分通过 API 调用消费自动累积,1 美元 = 1000 积分。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-balance-card h5-points-mall-balance-card" style={{ width: '100%', padding: 12 }}>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<Spin />
|
|
||||||
) : (
|
|
||||||
<div className="points-balance-row h5-points-balance-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<div className="points-balance-label" style={{ fontSize: 12 }}>我的积分</div>
|
|
||||||
<div className="points-balance-value" style={{ fontSize: 28 }}>{userPoints.toLocaleString()}</div>
|
|
||||||
<div className="points-balance-subtext" style={{ fontSize: 12 }}>
|
|
||||||
累计消费 ${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{String(overview?.me?.level || 'Lv.0')}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="points-mall-category-card h5-points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
|
||||||
<div className="points-mall-category-body">
|
|
||||||
<div className="points-mall-category-row h5-points-mall-category-row" style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
<span className="points-mall-category-label" style={{ width: '100%', marginBottom: 8 }}>商品分类</span>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<Button
|
|
||||||
key={c.id}
|
|
||||||
size="small"
|
|
||||||
type={c.id === categoryId ? 'primary' : 'default'}
|
|
||||||
style={{ borderRadius: 999, fontSize: 12 }}
|
|
||||||
onClick={() => {
|
|
||||||
setPage(1);
|
|
||||||
setCategoryId(c.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="points-mall-banner-section h5-points-mall-banner-section" style={{ flexDirection: 'column', gap: 12 }}>
|
|
||||||
<div className="points-mall-banner-card h5-points-mall-banner-card" style={{ width: '100%' }}>
|
|
||||||
<div className="points-mall-banner-header" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
|
||||||
<div>
|
|
||||||
<div className="points-mall-banner-title" style={{ fontSize: 16 }}>{banner?.title || '本期活动'}</div>
|
|
||||||
<div className="points-mall-banner-subtitle" style={{ fontSize: 12 }}>{banner?.subtitle || 'Up to 25% Off'}</div>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" style={{ borderRadius: 10, height: 36, fontWeight: 600, width: '100%' }}>
|
|
||||||
查看活动
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="points-mall-promo-grid h5-points-mall-promo-grid" style={{ gridTemplateColumns: '1fr', gap: 8 }}>
|
|
||||||
{promoEntries.slice(0, 2).map((p) => (
|
|
||||||
<div key={p.id} className="points-mall-promo-card" style={{ padding: 12 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div className="points-mall-promo-title" style={{ fontSize: 14 }}>{p.title}</div>
|
|
||||||
<div className="points-mall-promo-subtitle" style={{ fontSize: 12 }}>{p.subtitle}</div>
|
|
||||||
</div>
|
|
||||||
<Button size="small" className="points-mall-exchange-btn" style={{ marginTop: 8 }}>
|
|
||||||
进入
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{promoEntries.length < 2 && <div className="points-mall-promo-empty">促销入口由后端配置</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="stats-page-chart-card h5-stats-page-chart-card">
|
|
||||||
<div className="points-mall-filters-row h5-points-mall-filters-row" style={{ flexDirection: 'column', gap: 10 }}>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<Input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(1);
|
|
||||||
setQ(e.target.value);
|
|
||||||
}}
|
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
placeholder="搜索商品"
|
|
||||||
allowClear
|
|
||||||
className="points-mall-search-input"
|
|
||||||
style={{ height: 36 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
|
|
||||||
<Select
|
|
||||||
value={sort}
|
|
||||||
className="points-mall-filter-select"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
onChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setSort(v);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 'popular', label: '热度优先' },
|
|
||||||
{ value: 'newest', label: '最新上架' },
|
|
||||||
{ value: 'price_asc', label: '积分从低到高' },
|
|
||||||
{ value: 'price_desc', label: '积分从高到低' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={pageSize}
|
|
||||||
className="points-mall-filter-select-small"
|
|
||||||
style={{ width: 80 }}
|
|
||||||
onChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setPageSize(v);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 12, label: '每页 12' },
|
|
||||||
{ value: 24, label: '每页 24' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-total-text" style={{ textAlign: 'left' }}>共 {total.toLocaleString()} 件商品</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{productsLoading ? (
|
|
||||||
<Spin style={{ marginTop: 30, display: 'block' }} />
|
|
||||||
) : products.length === 0 ? (
|
|
||||||
<Empty description="暂无商品" style={{ marginTop: 40 }} />
|
|
||||||
) : (
|
|
||||||
<div className="points-mall-products-grid h5-points-mall-products-grid" style={{ gridTemplateColumns: '1fr', gap: 12 }}>
|
|
||||||
{products.map((p: PointsMallProduct) => (
|
|
||||||
<div key={p.id} className="points-mall-product-card h5-points-mall-product-card" style={{ padding: 12 }}>
|
|
||||||
<div className="points-mall-product-cover" />
|
|
||||||
<div className="points-mall-product-body">
|
|
||||||
<div className="points-mall-product-header">
|
|
||||||
<div className="points-mall-product-info">
|
|
||||||
<div className="points-mall-product-name" style={{ fontSize: 15 }}>{p.name}</div>
|
|
||||||
<div className="points-mall-product-desc" style={{ fontSize: 12 }}>{p.subtitle}</div>
|
|
||||||
</div>
|
|
||||||
{p.tags?.length ? (
|
|
||||||
<Tag bordered={false} className="points-mall-product-tag" style={{ fontSize: 11 }}>
|
|
||||||
{p.tags[0]}
|
|
||||||
</Tag>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-product-price-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
|
||||||
<div>
|
|
||||||
<span className="points-mall-product-price" style={{ fontSize: 20 }}>{Number(p.pointsPrice).toLocaleString()}</span>
|
|
||||||
<span className="points-mall-product-price-label">积分</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
|
||||||
disabled={userPoints < p.pointsPrice || exchangeLoading}
|
|
||||||
onClick={() => handleExchangeClick(p)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-product-footer" style={{ fontSize: 12 }}>
|
|
||||||
<span>库存 {p.stock}</span>
|
|
||||||
<span>已兑 {p.sold}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!total && (
|
|
||||||
<div className="points-mall-pagination h5-points-mall-pagination" style={{ marginTop: 16 }}>
|
|
||||||
<Space size={8}>
|
|
||||||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn" size="small">
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Tag bordered={false} className="points-mall-pagination-tag">
|
|
||||||
第 {page} 页
|
|
||||||
</Tag>
|
|
||||||
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn" size="small">
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ExchangeModal
|
<ExchangeModal
|
||||||
open={exchangeModalVisible}
|
open={exchangeModalVisible}
|
||||||
|
|
|
||||||
|
|
@ -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 PromptLibraryHero from './components/PromptLibraryHero';
|
||||||
import PromptTemplateGrid from './components/PromptTemplateGrid';
|
import PromptTemplateGrid from './components/PromptTemplateGrid';
|
||||||
import PromptTemplateEditorModal from './components/PromptTemplateEditorModal';
|
import PromptTemplateEditorModal from './components/PromptTemplateEditorModal';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import './styles/prompt-library.css';
|
||||||
|
import './styles/prompt-library-card.css';
|
||||||
|
import './styles/prompt-library-tones.css';
|
||||||
|
import './styles/prompt-library-h5.css';
|
||||||
|
|
||||||
export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) {
|
export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) {
|
||||||
const { onSelect } = props;
|
const { onSelect } = props;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
|
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
|
|
@ -90,7 +96,7 @@ export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTempla
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feature-cover-container">
|
<div className={`feature-cover-container prompt-library-page-${isMobile ? 'h5' : 'web'}`}>
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} />
|
<PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} />
|
||||||
<PromptTemplateGrid
|
<PromptTemplateGrid
|
||||||
|
|
|
||||||
|
|
@ -13,70 +13,46 @@ export default function PromptLibraryHero(props: {
|
||||||
const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props;
|
const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="prompt-library-hero">
|
||||||
style={{
|
<div className="prompt-library-hero-header">
|
||||||
borderRadius: 24,
|
<div className="prompt-library-hero-copy">
|
||||||
padding: '28px 28px 24px',
|
<div className="prompt-library-badge">
|
||||||
background:
|
<AppstoreOutlined className="prompt-library-badge-icon" />
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.96) 0%, rgba(236,253,245,0.92) 42%, rgba(240,249,255,0.96) 100%)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
||||||
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.06)',
|
|
||||||
marginBottom: 24
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap', marginBottom: 22 }}>
|
|
||||||
<div style={{ maxWidth: 620 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(255,255,255,0.78)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppstoreOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
灵感模板中心
|
灵感模板中心
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
<h1 className="page-title prompt-library-title">
|
||||||
Prompt 模板库
|
Prompt 模板库
|
||||||
</h1>
|
</h1>
|
||||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}>
|
<div className="page-subtitle prompt-library-subtitle">
|
||||||
把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。
|
把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
|
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} className="prompt-library-create-btn">
|
||||||
新建模板
|
新建模板
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.5fr) auto', gap: 14, alignItems: 'center' }}>
|
<div className="prompt-library-toolbar">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索标题、分类或正文..."
|
placeholder="搜索标题、分类或正文..."
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => onChangeQ(e.target.value)}
|
onChange={(e) => onChangeQ(e.target.value)}
|
||||||
onPressEnter={onSearch}
|
onPressEnter={onSearch}
|
||||||
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
|
prefix={<SearchOutlined className="prompt-library-search-icon" />}
|
||||||
suffix={
|
suffix={
|
||||||
q ? (
|
q ? (
|
||||||
<Button type="link" size="small" onClick={onSearch} style={{ padding: 0, height: 20 }}>
|
<Button type="link" size="small" onClick={onSearch} className="prompt-library-search-btn">
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }}
|
className="prompt-library-search-input"
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div className="prompt-library-scope-list">
|
||||||
{SCOPE_OPTIONS.map((item) => {
|
{SCOPE_OPTIONS.map((item) => {
|
||||||
const active = scope === item.key;
|
const active = scope === item.key;
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,17 +60,7 @@ export default function PromptLibraryHero(props: {
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChangeScope(item.key)}
|
onClick={() => onChangeScope(item.key)}
|
||||||
style={{
|
className={`prompt-library-scope-btn${active ? ' active' : ''}`}
|
||||||
border: '1px solid',
|
|
||||||
borderColor: active ? 'rgba(8, 145, 178, 0.18)' : 'var(--color-border)',
|
|
||||||
background: active ? 'rgba(8, 145, 178, 0.10)' : 'rgba(255,255,255,0.72)',
|
|
||||||
color: active ? 'var(--color-brand)' : 'var(--color-text-secondary)',
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '10px 14px',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -105,4 +71,3 @@ export default function PromptLibraryHero(props: {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,131 +15,59 @@ export default function PromptTemplateCard(props: {
|
||||||
const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他'];
|
const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="prompt-template-card">
|
||||||
style={{
|
<div className="prompt-template-card-header">
|
||||||
borderRadius: 20,
|
<div className="prompt-template-tags">
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.045)',
|
|
||||||
padding: 20,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 286
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<Tag
|
<Tag
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{
|
className={`prompt-template-tag prompt-category-${tone}`}
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: 10,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
background: tone.bg,
|
|
||||||
color: tone.text
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t.category || '其他'}
|
{t.category || '其他'}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag
|
<Tag
|
||||||
bordered={false}
|
bordered={false}
|
||||||
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
|
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
|
||||||
style={{
|
className="prompt-template-tag prompt-template-visibility"
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: 10,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
color: 'var(--color-text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
|
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tooltip title={hasOnSelect ? '插入到当前对话' : '复制到剪贴板'}>
|
<Tooltip title={hasOnSelect ? '插入到当前对话' : '复制到剪贴板'}>
|
||||||
<Button type="primary" icon={<CopyOutlined />} onClick={onUse} style={{ borderRadius: 12, height: 38, fontWeight: 600 }}>
|
<Button type="primary" icon={<CopyOutlined />} onClick={onUse} className="prompt-template-use-btn">
|
||||||
{hasOnSelect ? '使用' : '复制'}
|
{hasOnSelect ? '使用' : '复制'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 14 }}>
|
<div className="prompt-template-title-row">
|
||||||
<div style={{ minWidth: 0 }}>
|
<div className="prompt-template-title-block">
|
||||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', letterSpacing: '-0.02em', marginBottom: 6 }}>{t.title}</div>
|
<div className="prompt-template-title">{t.title}</div>
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>by {t.ownerName || '我'}</div>
|
<div className="prompt-template-owner">by {t.ownerName || '我'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="prompt-template-use-count">
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: 'rgba(15, 23, 42, 0.03)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
使用 {t.useCount}
|
使用 {t.useCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={`prompt-template-body-card prompt-category-border-${tone}`}>
|
||||||
style={{
|
<div className="prompt-template-body">
|
||||||
position: 'relative',
|
|
||||||
borderRadius: 16,
|
|
||||||
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%)',
|
|
||||||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
|
||||||
padding: '16px 16px 18px',
|
|
||||||
minHeight: 132,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'absolute', inset: '0 auto 0 0', width: 4, background: tone.text, opacity: 0.18 }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
fontSize: 13.5,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
lineHeight: 1.8,
|
|
||||||
minHeight: 96,
|
|
||||||
maxHeight: 130,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.body.slice(0, 220)}
|
{t.body.slice(0, 220)}
|
||||||
{t.body.length > 220 ? '…' : ''}
|
{t.body.length > 220 ? '…' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="prompt-template-footer">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
marginTop: 'auto',
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
fontSize: 12.5,
|
|
||||||
color: 'var(--color-text-tertiary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
<span>最近更新于 {formatDate(t.updatedAt)}</span>
|
<span>最近更新于 {formatDate(t.updatedAt)}</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span className="prompt-template-footer-spacer" />
|
||||||
<Space size={2}>
|
<Space size={2}>
|
||||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} style={{ borderRadius: 10 }}>
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} className="prompt-template-text-btn">
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm title="删除此模板?" onConfirm={onDelete}>
|
<Popconfirm title="删除此模板?" onConfirm={onDelete}>
|
||||||
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
<Button size="small" type="text" danger className="prompt-template-text-btn">
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
@ -148,4 +76,3 @@ export default function PromptTemplateCard(props: {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,7 @@ export default function PromptTemplateGrid(props: {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="prompt-template-state-card">
|
||||||
style={{
|
|
||||||
minHeight: 280,
|
|
||||||
borderRadius: 22,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -33,14 +23,14 @@ export default function PromptTemplateGrid(props: {
|
||||||
|
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}>
|
<div className="prompt-template-empty-card">
|
||||||
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
|
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
<div className="prompt-template-grid">
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<PromptTemplateCard
|
<PromptTemplateCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|
@ -55,4 +45,3 @@ export default function PromptTemplateGrid(props: {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,12 @@ export const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: strin
|
||||||
{ key: 'public', label: '公开灵感' }
|
{ key: 'public', label: '公开灵感' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = {
|
export const CATEGORY_STYLES: Record<string, string> = {
|
||||||
通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' },
|
通用: 'general',
|
||||||
编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' },
|
编程: 'code',
|
||||||
写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' },
|
写作: 'writing',
|
||||||
翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' },
|
翻译: 'translate',
|
||||||
分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' },
|
分析: 'analysis',
|
||||||
客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' },
|
客服: 'service',
|
||||||
其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' }
|
其他: 'other'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Empty, Spin, App as AntApp } from 'antd';
|
import { Empty, Spin } from 'antd';
|
||||||
import { SharedAPI } from '../api';
|
import { SharedAPI } from '../api';
|
||||||
import Markdown from '../components/Markdown';
|
import Markdown from '../components/Markdown';
|
||||||
|
import './SharedSessionPage.css';
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
agent: { name: string; description: string };
|
agent: { name: string; description: string };
|
||||||
|
|
@ -15,60 +16,46 @@ export default function SharedSessionPage() {
|
||||||
const [data, setData] = useState<Data | null>(null);
|
const [data, setData] = useState<Data | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string>('');
|
const [err, setErr] = useState<string>('');
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const loginHref = `${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}/login`;
|
const loginHref = `${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}/login`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
setErr('会话不存在或已失效');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
SharedAPI.get(token)
|
SharedAPI.get(token)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const msg = e?.response?.data?.error ?? e?.message ?? '加载失败';
|
const msg = e?.response?.data?.error ?? e?.message ?? '加载失败';
|
||||||
setErr(msg);
|
setErr(msg);
|
||||||
message.error(msg);
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
if (loading) return <Spin className="shared-session-spin" />;
|
||||||
if (err || !data)
|
if (err || !data)
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
description={err || '会话不存在或已失效'}
|
description={err || '会话不存在或已失效'}
|
||||||
style={{ marginTop: 80 }}
|
className="shared-session-empty"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: 'var(--color-bg)' }}>
|
<div className="shared-session-shell">
|
||||||
<div
|
<div className="shared-session-container">
|
||||||
style={{
|
<div className="shared-session-header">
|
||||||
maxWidth: 900,
|
<div className="shared-session-eyebrow">公开分享</div>
|
||||||
margin: '0 auto',
|
<h1 className="shared-session-title">{data.session.title}</h1>
|
||||||
padding: '32px 24px',
|
<div className="shared-session-meta">
|
||||||
background: 'var(--color-surface)',
|
<span>🤖 {data.agent.name} · {data.messages.length} 条消息</span>
|
||||||
minHeight: '100vh',
|
<span className="shared-session-date">
|
||||||
boxShadow: 'var(--shadow-lg)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
paddingBottom: 16,
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
marginBottom: 24
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>公开分享</div>
|
|
||||||
<h1 style={{ margin: 0, fontSize: 24, color: 'var(--color-text)' }}>{data.session.title}</h1>
|
|
||||||
<div style={{ marginTop: 8, color: 'var(--color-text-secondary)' }}>
|
|
||||||
🤖 {data.agent.name} · {data.messages.length} 条消息
|
|
||||||
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
{new Date(data.session.updatedAt).toLocaleString('zh-CN')}
|
{new Date(data.session.updatedAt).toLocaleString('zh-CN')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data.agent.description && (
|
{data.agent.description && (
|
||||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
<div className="shared-session-description">
|
||||||
{data.agent.description}
|
{data.agent.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -77,52 +64,24 @@ export default function SharedSessionPage() {
|
||||||
{data.messages.map((m) => (
|
{data.messages.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
style={{
|
className={`shared-message shared-message-${m.role}`}
|
||||||
marginBottom: 18,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: m.role === 'user' ? 'flex-end' : 'flex-start'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="shared-message-meta">
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
marginBottom: 4
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '}
|
{m.role === 'user' ? '🧑 用户' : '🤖 助手'} ·{' '}
|
||||||
{new Date(m.createdAt).toLocaleString('zh-CN')}
|
{new Date(m.createdAt).toLocaleString('zh-CN')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="shared-message-bubble">
|
||||||
style={{
|
|
||||||
maxWidth: '85%',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: 14,
|
|
||||||
background: m.role === 'user' ? 'var(--color-brand-soft)' : 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
boxShadow: m.role === 'assistant' ? 'var(--shadow-xs)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m.role === 'assistant' ? (
|
{m.role === 'assistant' ? (
|
||||||
<Markdown>{m.content}</Markdown>
|
<Markdown>{m.content}</Markdown>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
|
<div className="shared-message-text">{m.content}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div
|
<div className="shared-session-footer">
|
||||||
style={{
|
由 鲸域AI 生成 · <a href={loginHref}>登录创建你自己的 agent</a>
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 40
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
由 Agent Studio 生成 · <a href={loginHref} style={{ color: 'var(--color-brand)' }}>登录创建你自己的 agent</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,384 +1,14 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { BarChartOutlined, DollarOutlined, GiftOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
|
import { useStatsPageLogic } from './StatsPage/StatsPageLogic';
|
||||||
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd';
|
import StatsPageH5 from './StatsPage/components/StatsPageH5';
|
||||||
import { Link } from 'react-router-dom';
|
import StatsPageWeb from './StatsPage/components/StatsPageWeb';
|
||||||
import { AgentTokenStats, StatsAPI, StatsOverview } from '../api';
|
import './StatsPage/styles/stats-page-shared.css';
|
||||||
|
import './StatsPage/styles/stats-page-web.css';
|
||||||
type StatsOverviewWithPoints = StatsOverview & {
|
import './StatsPage/styles/stats-page-h5.css';
|
||||||
totalPoints?: number;
|
|
||||||
pointsEarned?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let statsOverviewCache:
|
|
||||||
| { ts: number; data: StatsOverview | null; inFlight: Promise<StatsOverview> | null }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const getStatsOverviewCached = () => {
|
|
||||||
if (!statsOverviewCache) statsOverviewCache = { ts: 0, data: null, inFlight: null };
|
|
||||||
if (statsOverviewCache.inFlight) return statsOverviewCache.inFlight;
|
|
||||||
if (statsOverviewCache.data && Date.now() - statsOverviewCache.ts < 5000) return Promise.resolve(statsOverviewCache.data);
|
|
||||||
statsOverviewCache.inFlight = StatsAPI.overview()
|
|
||||||
.then((res) => {
|
|
||||||
statsOverviewCache!.data = res;
|
|
||||||
statsOverviewCache!.ts = Date.now();
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (statsOverviewCache) statsOverviewCache.inFlight = null;
|
|
||||||
});
|
|
||||||
return statsOverviewCache.inFlight;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const { message } = AntApp.useApp();
|
const logic = useStatsPageLogic();
|
||||||
const [data, setData] = useState<StatsOverviewWithPoints | null>(null);
|
const isMobile = useIsMobile();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [tokenLoading, setTokenLoading] = useState(false);
|
|
||||||
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
|
|
||||||
const [tokenAgentId, setTokenAgentId] = useState<string>('');
|
|
||||||
const [tokenDays, setTokenDays] = useState<number>(30);
|
|
||||||
const [tokenLimit, setTokenLimit] = useState<number>(10);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return isMobile ? <StatsPageH5 logic={logic} /> : <StatsPageWeb logic={logic} />;
|
||||||
setLoading(true);
|
|
||||||
let alive = true;
|
|
||||||
getStatsOverviewCached()
|
|
||||||
.then((res) => {
|
|
||||||
if (!alive) return;
|
|
||||||
const enhanced = res as StatsOverviewWithPoints;
|
|
||||||
const spent = enhanced.costUSD || 0;
|
|
||||||
enhanced.totalPoints = Math.floor(spent * 1000);
|
|
||||||
enhanced.pointsEarned = enhanced.totalPoints;
|
|
||||||
setData(enhanced);
|
|
||||||
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
|
||||||
setTokenAgentId(res.topAgents[0].id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
|
|
||||||
.finally(() => {
|
|
||||||
if (alive) setLoading(false);
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tokenAgentId) return;
|
|
||||||
setTokenLoading(true);
|
|
||||||
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
|
|
||||||
.then((res) => {
|
|
||||||
setTokenData(res);
|
|
||||||
})
|
|
||||||
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
|
|
||||||
.finally(() => setTokenLoading(false));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [tokenAgentId, tokenDays, tokenLimit]);
|
|
||||||
|
|
||||||
if (loading) return <Spin style={{ marginTop: 80, display: 'block' }} />;
|
|
||||||
if (!data) return <Empty description="暂无数据" style={{ marginTop: 80 }} />;
|
|
||||||
|
|
||||||
const maxDaily = Math.max(1, ...data.daily.map((d) => d.total));
|
|
||||||
const maxAgent = Math.max(1, ...data.topAgents.map((a) => a.messageCount));
|
|
||||||
const last7Days = data.daily.slice(-7).reduce((sum, item) => sum + item.total, 0);
|
|
||||||
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
|
|
||||||
const formatUSD = (v?: number | null) =>
|
|
||||||
`$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
|
|
||||||
const formatPoints = (v?: number | null) => Number(v || 0).toLocaleString();
|
|
||||||
|
|
||||||
const totalSpentUSD = data.costUSD || 0;
|
|
||||||
const totalPoints = Math.floor(totalSpentUSD * 1000);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<div className="stats-page-hero">
|
|
||||||
<div className="stats-page-header">
|
|
||||||
<div className="stats-page-title-section">
|
|
||||||
<div className="stats-page-badge">
|
|
||||||
<BarChartOutlined className="stats-page-badge-icon" />
|
|
||||||
数据洞察看板
|
|
||||||
</div>
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>调用统计</h1>
|
|
||||||
<p className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
|
||||||
不只是查看调用数量,而是帮助你感知哪些智能体正在被频繁使用、最近的消息趋势如何,以及整体会话是否健康增长。
|
|
||||||
每消费 1 美元可获得 1000 积分。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="stats-page-summary-card">
|
|
||||||
<div className="stats-page-summary-label">近 7 天消息总量</div>
|
|
||||||
<div className="stats-page-summary-value">{last7Days}</div>
|
|
||||||
<div className="stats-page-summary-desc">平均每个会话 {avgSessionMessages} 条消息</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-page-cards-grid">
|
|
||||||
{[
|
|
||||||
{ icon: <RobotOutlined />, label: '智能体数量', value: data.agentCount, bgColor: 'rgba(8, 145, 178, 0.10)', textColor: 'var(--color-brand)' },
|
|
||||||
{ icon: <LineChartOutlined />, label: '会话总数', value: data.sessionCount, bgColor: 'rgba(14, 165, 233, 0.10)', textColor: 'var(--color-info)' },
|
|
||||||
{ icon: <MessageOutlined />, label: '消息总数', value: data.messageCount, bgColor: 'rgba(34, 197, 94, 0.10)', textColor: 'var(--color-success)' }
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} className="stats-page-card">
|
|
||||||
<div className="stats-page-card-label">
|
|
||||||
<span className="stats-page-card-icon" style={{ background: item.bgColor, color: item.textColor }}>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div className="stats-page-card-value">{item.value}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 积分与消费关联展示 */}
|
|
||||||
<div className="points-integration-section">
|
|
||||||
<Card
|
|
||||||
title="消费与积分"
|
|
||||||
className="points-integration-card"
|
|
||||||
extra={
|
|
||||||
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
|
|
||||||
1 USD = 1000 积分
|
|
||||||
</Tag>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="points-integration-row">
|
|
||||||
<div className="points-integration-item">
|
|
||||||
<div className="points-integration-label">
|
|
||||||
<DollarOutlined style={{ color: 'var(--color-warning)' }} />
|
|
||||||
累计消费 (USD)
|
|
||||||
</div>
|
|
||||||
<div className="points-integration-value">{formatUSD(totalSpentUSD)}</div>
|
|
||||||
<div className="points-integration-desc">API 调用总费用</div>
|
|
||||||
</div>
|
|
||||||
<div className="points-integration-item">
|
|
||||||
<div className="points-integration-label">
|
|
||||||
<GiftOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
累计积分
|
|
||||||
</div>
|
|
||||||
<div className="points-integration-value">{formatPoints(totalPoints)}</div>
|
|
||||||
<div className="points-integration-desc">可用于兑换商城商品</div>
|
|
||||||
</div>
|
|
||||||
<div className="points-integration-item">
|
|
||||||
<div className="points-integration-label">
|
|
||||||
<BarChartOutlined style={{ color: 'var(--color-success)' }} />
|
|
||||||
积分汇率
|
|
||||||
</div>
|
|
||||||
<div className="points-integration-value">1:1000</div>
|
|
||||||
<div className="points-integration-desc">每消费 1 美元获得 1000 积分</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-page-main-grid" style={{ marginTop: 18 }}>
|
|
||||||
<Card
|
|
||||||
title="最近 30 天消息走势"
|
|
||||||
className="stats-page-chart-card"
|
|
||||||
extra={<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>仅统计你的数据</Tag>}
|
|
||||||
>
|
|
||||||
<div className="stats-page-chart-desc">
|
|
||||||
深色柱代表用户消息,浅绿柱代表助手响应,用来快速判断近期对话活跃度。
|
|
||||||
</div>
|
|
||||||
{data.daily.length === 0 ? (
|
|
||||||
<Empty description="近期无对话" />
|
|
||||||
) : (
|
|
||||||
<div className="stats-page-chart-container">
|
|
||||||
{data.daily.map((d) => {
|
|
||||||
const userH = (d.user / maxDaily) * 180;
|
|
||||||
const aH = (d.assistant / maxDaily) * 180;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={d.day}
|
|
||||||
className="stats-page-chart-bar-group"
|
|
||||||
title={`${d.day}\n用户 ${d.user} · 助手 ${d.assistant}`}
|
|
||||||
>
|
|
||||||
<div className="stats-page-chart-bars">
|
|
||||||
<div className="stats-page-chart-bar-user" style={{ height: userH }} />
|
|
||||||
<div className="stats-page-chart-bar-assistant" style={{ height: aH }} />
|
|
||||||
</div>
|
|
||||||
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="stats-page-chart-legend">
|
|
||||||
<span className="stats-page-chart-legend-item">
|
|
||||||
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-brand)' }} />
|
|
||||||
用户消息
|
|
||||||
</span>
|
|
||||||
<span className="stats-page-chart-legend-item">
|
|
||||||
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-success)' }} />
|
|
||||||
助手消息
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="智能体活跃排行" className="stats-page-chart-card">
|
|
||||||
<div className="stats-page-chart-desc">
|
|
||||||
哪些智能体正在被频繁使用,一眼就能看出来。
|
|
||||||
</div>
|
|
||||||
{data.topAgents.length === 0 ? (
|
|
||||||
<Empty description="暂无" />
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{data.topAgents.map((a, index) => (
|
|
||||||
<div key={a.id} className="stats-page-agent-item">
|
|
||||||
<div className="stats-page-agent-header">
|
|
||||||
<div className="stats-page-agent-rank">{index + 1}</div>
|
|
||||||
<Link to={`/agents/${a.id}/chat`} className="stats-page-agent-name">
|
|
||||||
{a.name}
|
|
||||||
</Link>
|
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
|
||||||
{a.messageCount} 条
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div className="stats-page-agent-progress">
|
|
||||||
<div className="stats-page-agent-progress-bar" style={{ width: `${(a.messageCount / maxAgent) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-page-token-section">
|
|
||||||
<Card
|
|
||||||
title="Token 使用量"
|
|
||||||
className="stats-page-chart-card"
|
|
||||||
extra={
|
|
||||||
<Space size={10} wrap>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
value={tokenAgentId || undefined}
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
placeholder="选择智能体"
|
|
||||||
onChange={setTokenAgentId}
|
|
||||||
options={data.topAgents.map((a) => ({ value: a.id, label: a.name }))}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
value={tokenDays}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
onChange={setTokenDays}
|
|
||||||
options={[
|
|
||||||
{ value: 7, label: '近 7 天' },
|
|
||||||
{ value: 30, label: '近 30 天' },
|
|
||||||
{ value: 90, label: '近 90 天' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
value={tokenLimit}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
onChange={setTokenLimit}
|
|
||||||
options={[
|
|
||||||
{ value: 5, label: 'Top 5' },
|
|
||||||
{ value: 10, label: 'Top 10' },
|
|
||||||
{ value: 20, label: 'Top 20' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tokenLoading ? (
|
|
||||||
<Spin style={{ marginTop: 20, display: 'block' }} />
|
|
||||||
) : !tokenAgentId ? (
|
|
||||||
<Empty description="暂无可统计的智能体" style={{ marginTop: 20 }} />
|
|
||||||
) : !tokenData ? (
|
|
||||||
<Empty description="暂无 Token 统计数据" style={{ marginTop: 20 }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="stats-page-token-cards-grid">
|
|
||||||
{[
|
|
||||||
{ label: '总 Token', value: tokenData.totalTokens, color: 'var(--color-brand)' },
|
|
||||||
{ label: '输入 Token', value: tokenData.promptTokens, color: 'var(--color-info)' },
|
|
||||||
{ label: '输出 Token', value: tokenData.completionTokens, color: 'var(--color-success)' },
|
|
||||||
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), color: 'var(--color-warning)' }
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} className="stats-page-token-card">
|
|
||||||
<div className="stats-page-token-label">
|
|
||||||
<span className="stats-page-token-dot" style={{ background: item.color }} />
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div className="stats-page-token-value">
|
|
||||||
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-page-token-charts-grid">
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title="近 N 天趋势(总 Token)"
|
|
||||||
className="stats-page-token-chart-card"
|
|
||||||
extra={
|
|
||||||
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
|
|
||||||
days={tokenData.days}
|
|
||||||
</Tag>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tokenData.daily.length === 0 ? (
|
|
||||||
<Empty description="暂无" />
|
|
||||||
) : (
|
|
||||||
<div className="stats-page-token-chart-container">
|
|
||||||
{(() => {
|
|
||||||
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((d) => d.totalTokens));
|
|
||||||
return tokenData.daily.map((d) => {
|
|
||||||
const h = (d.totalTokens / maxTokenDaily) * 170;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={d.day}
|
|
||||||
className="stats-page-chart-bar-group"
|
|
||||||
title={`${d.day}\nCalls ${d.calls}\nTotalTokens ${d.totalTokens}\nCost ${formatUSD(d.costUSD)}`}
|
|
||||||
>
|
|
||||||
<div className="stats-page-token-chart-bar" style={{ height: h }} />
|
|
||||||
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title="模型消耗排行"
|
|
||||||
className="stats-page-token-chart-card"
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
pagination={false}
|
|
||||||
rowKey={(r) => `${r.providerKind}:${r.model}`}
|
|
||||||
dataSource={tokenData.byModel || []}
|
|
||||||
columns={[
|
|
||||||
{ title: 'Provider', dataIndex: 'providerKind', width: 110 },
|
|
||||||
{ title: 'Model', dataIndex: 'model' },
|
|
||||||
{ title: 'Calls', dataIndex: 'calls', width: 80 },
|
|
||||||
{
|
|
||||||
title: 'Tokens',
|
|
||||||
dataIndex: 'totalTokens',
|
|
||||||
width: 110,
|
|
||||||
render: (v) => Number(v || 0).toLocaleString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Cost',
|
|
||||||
dataIndex: 'costUSD',
|
|
||||||
width: 110,
|
|
||||||
render: (v) => formatUSD(v)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { ApartmentOutlined, ClockCircleOutlined, PlayCircleOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
import { useWorkflowsPageLogic } from './WorkflowsPageLogic';
|
||||||
import { Button, Empty, Tag, App as AntApp, Popconfirm } from 'antd';
|
|
||||||
import type { Workflow } from '../../api';
|
|
||||||
import { WorkflowAPI } from '../../api';
|
|
||||||
import { SAMPLE_GRAPH } from './constants';
|
|
||||||
import WorkflowEditorDrawer from './components/WorkflowEditorDrawer';
|
|
||||||
import RunsDrawer from './components/RunsDrawer';
|
import RunsDrawer from './components/RunsDrawer';
|
||||||
|
import WorkflowEditorDrawer from './components/WorkflowEditorDrawer';
|
||||||
|
import WorkflowsPageH5 from './components/WorkflowsPageH5';
|
||||||
|
import WorkflowsPageWeb from './components/WorkflowsPageWeb';
|
||||||
|
import './styles/workflows-page-shared.css';
|
||||||
|
import './styles/workflows-drawer.css';
|
||||||
|
import './styles/workflows-page-web.css';
|
||||||
|
import './styles/workflows-page-h5.css';
|
||||||
|
|
||||||
export default function WorkflowsPage() {
|
export default function WorkflowsPage() {
|
||||||
const { message } = AntApp.useApp();
|
const isMobile = useIsMobile();
|
||||||
const [list, setList] = useState<Workflow[]>([]);
|
const logic = useWorkflowsPageLogic({ enabled: false });
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Workflow | null>(null);
|
|
||||||
const [runsOpen, setRunsOpen] = useState(false);
|
|
||||||
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await WorkflowAPI.list();
|
|
||||||
setList(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载失败:' + (e?.message ?? e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onCreate = () => {
|
|
||||||
setEditing({
|
|
||||||
id: '',
|
|
||||||
name: '新工作流',
|
|
||||||
description: '',
|
|
||||||
graph: SAMPLE_GRAPH,
|
|
||||||
scheduleCron: '',
|
|
||||||
scheduleEnabled: false,
|
|
||||||
enabled: true,
|
|
||||||
lastRunAt: 0,
|
|
||||||
runCount: 0,
|
|
||||||
createdAt: 0,
|
|
||||||
updatedAt: 0
|
|
||||||
});
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEdit = (w: Workflow) => {
|
|
||||||
setEditing(w);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = async (w: Workflow) => {
|
|
||||||
await WorkflowAPI.remove(w.id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feature-cover-container">
|
<div className="feature-cover-container workflows-page-shell">
|
||||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
{isMobile ? <WorkflowsPageH5 logic={logic} /> : <WorkflowsPageWeb logic={logic} />}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '30px 30px 26px',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
||||||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
|
||||||
marginBottom: 24
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap', marginBottom: 20 }}>
|
|
||||||
<div style={{ maxWidth: 680 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(255,255,255,0.78)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ApartmentOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
自动化编排中心
|
|
||||||
</div>
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
|
||||||
工作流编排
|
|
||||||
</h1>
|
|
||||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
|
||||||
让多个 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 style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
{logic.editing && (
|
||||||
{[
|
<WorkflowEditorDrawer
|
||||||
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
open={logic.editorOpen}
|
||||||
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
workflow={logic.editing}
|
||||||
{
|
onClose={() => logic.setEditorOpen(false)}
|
||||||
label: '定时运行',
|
onSaved={logic.handleSaved}
|
||||||
value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length,
|
/>
|
||||||
tone: 'rgba(249, 115, 22, 0.10)',
|
)}
|
||||||
color: 'var(--color-warning)'
|
|
||||||
}
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{ borderRadius: 18, padding: '16px 18px', background: 'rgba(255,255,255,0.72)', border: '1px solid rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
|
|
||||||
<span style={{ borderRadius: 999, padding: '4px 8px', background: item.tone, color: item.color, fontSize: 12, fontWeight: 600 }}>
|
|
||||||
实时状态
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? null : list.length === 0 ? (
|
{logic.runsFor && (
|
||||||
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}>
|
<RunsDrawer open={logic.runsOpen} workflow={logic.runsFor} onClose={() => logic.setRunsOpen(false)} />
|
||||||
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
|
)}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
|
||||||
{list.map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.id}
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
|
||||||
padding: 20,
|
|
||||||
minHeight: 308,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
|
||||||
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
|
|
||||||
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.enabled ? '已启用' : '已停用'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
<div className="feature-cover">功能规划中,本期不支持</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '14px 14px 12px',
|
|
||||||
background: 'rgba(8, 145, 178, 0.06)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.10)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>节点数</div>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.graph?.nodes?.length ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '14px 14px 12px',
|
|
||||||
background: 'rgba(34, 197, 94, 0.06)',
|
|
||||||
border: '1px solid rgba(34, 197, 94, 0.10)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>运行次数</div>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.runCount}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '14px 14px 12px',
|
|
||||||
background: 'rgba(249, 115, 22, 0.06)',
|
|
||||||
border: '1px solid rgba(249, 115, 22, 0.10)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>触发方式</div>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '16px 16px 14px',
|
|
||||||
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
|
|
||||||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
调度与运行
|
|
||||||
</div>
|
|
||||||
{r.scheduleEnabled && r.scheduleCron ? (
|
|
||||||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
|
|
||||||
cron: {r.scheduleCron}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
|
||||||
手动触发
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
|
|
||||||
上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
|
||||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
|
||||||
编辑流程
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setRunsFor(r);
|
|
||||||
setRunsOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ borderRadius: 12, height: 40 }}
|
|
||||||
>
|
|
||||||
运行 / 历史
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
|
|
||||||
<Button danger style={{ borderRadius: 12, height: 40 }}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing && (
|
|
||||||
<WorkflowEditorDrawer
|
|
||||||
open={editorOpen}
|
|
||||||
workflow={editing}
|
|
||||||
onClose={() => setEditorOpen(false)}
|
|
||||||
onSaved={() => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{runsFor && <RunsDrawer open={runsOpen} workflow={runsFor} onClose={() => setRunsOpen(false)} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
setDetail(null);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
className="workflow-drawer"
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={tab}
|
activeKey={tab}
|
||||||
|
|
@ -101,12 +101,12 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
<Input.TextArea rows={4} value={inputJson} onChange={(e) => setInputJson(e.target.value)} placeholder='{"text":"hello"}' />
|
<Input.TextArea rows={4} value={inputJson} onChange={(e) => setInputJson(e.target.value)} placeholder='{"text":"hello"}' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Space style={{ marginBottom: 12 }}>
|
<Space className="workflow-drawer-switch-row">
|
||||||
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
|
<Button type="primary" loading={streaming} onClick={onRunStream} className="workflow-drawer-save">
|
||||||
▶ 流式执行
|
▶ 流式执行
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
style={{ borderRadius: 10 }}
|
className="workflow-drawer-save"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
|
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
|
||||||
|
|
@ -122,9 +122,9 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{steps.length > 0 && (
|
{steps.length > 0 && (
|
||||||
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
|
<Card size="small" title="实时进度" className="workflow-drawer-card workflow-drawer-card-bottom">
|
||||||
{steps.map((s, i) => (
|
{steps.map((s, i) => (
|
||||||
<div key={i} style={{ marginBottom: 8 }}>
|
<div key={i} className="workflow-run-step">
|
||||||
<Space>
|
<Space>
|
||||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||||
<Text strong>{s.nodeId}</Text>
|
<Text strong>{s.nodeId}</Text>
|
||||||
|
|
@ -132,12 +132,12 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
|
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
|
||||||
</Space>
|
</Space>
|
||||||
{s.error && (
|
{s.error && (
|
||||||
<Paragraph type="danger" style={{ marginBottom: 0 }}>
|
<Paragraph type="danger" className="workflow-run-error">
|
||||||
{s.error}
|
{s.error}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
)}
|
)}
|
||||||
{s.output != null && (
|
{s.output != null && (
|
||||||
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
|
<pre className="workflow-run-pre workflow-run-pre-compact">
|
||||||
{JSON.stringify(s.output, null, 2)}
|
{JSON.stringify(s.output, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|
@ -147,8 +147,8 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{finalRun && (
|
{finalRun && (
|
||||||
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
|
<Card size="small" title="最终结果" className="workflow-drawer-card">
|
||||||
<pre style={{ fontSize: 12, margin: 0 }}>{JSON.stringify(finalRun, null, 2)}</pre>
|
<pre className="workflow-run-pre workflow-run-pre-plain">{JSON.stringify(finalRun, null, 2)}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,7 +159,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
label: `历史 (${runs.length})`,
|
label: `历史 (${runs.length})`,
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}>
|
<Button size="small" onClick={loadRuns} className="workflow-history-refresh">
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Table<WorkflowRun>
|
<Table<WorkflowRun>
|
||||||
|
|
@ -181,7 +181,7 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
<b>状态:</b>
|
<b>状态:</b>
|
||||||
<Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>
|
<Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>
|
||||||
{detail.error && (
|
{detail.error && (
|
||||||
<Text type="danger" style={{ marginLeft: 8 }}>
|
<Text type="danger" className="workflow-detail-error">
|
||||||
{detail.error}
|
{detail.error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
@ -193,21 +193,21 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
<p>
|
<p>
|
||||||
<b>Input:</b>
|
<b>Input:</b>
|
||||||
</p>
|
</p>
|
||||||
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
|
<pre className="workflow-run-pre">{JSON.stringify(detail.input, null, 2)}</pre>
|
||||||
<p>
|
<p>
|
||||||
<b>Steps:</b>
|
<b>Steps:</b>
|
||||||
</p>
|
</p>
|
||||||
{detail.steps.map((s) => (
|
{detail.steps.map((s) => (
|
||||||
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
|
<Card size="small" key={s.id} className="workflow-drawer-card workflow-drawer-card-bottom">
|
||||||
<Space>
|
<Space>
|
||||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||||
<Text strong>{s.nodeId}</Text>
|
<Text strong>{s.nodeId}</Text>
|
||||||
<Text type="secondary">{s.nodeType}</Text>
|
<Text type="secondary">{s.nodeType}</Text>
|
||||||
<Text type="secondary">{s.durationMs} ms</Text>
|
<Text type="secondary">{s.durationMs} ms</Text>
|
||||||
</Space>
|
</Space>
|
||||||
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
|
{s.error && <p className="workflow-run-error-text">{s.error}</p>}
|
||||||
{s.output != null && (
|
{s.output != null && (
|
||||||
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>{JSON.stringify(s.output, null, 2)}</pre>
|
<pre className="workflow-run-pre workflow-run-pre-detail">{JSON.stringify(s.output, null, 2)}</pre>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
@ -221,4 +221,3 @@ export default function RunsDrawer(props: { open: boolean; workflow: Workflow; o
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
width={840}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
className="workflow-drawer"
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
|
<Button type="primary" loading={saving} onClick={onSave} className="workflow-drawer-save">
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ export default function WorkflowEditorDrawer(props: {
|
||||||
<Form.Item label="描述" name="description">
|
<Form.Item label="描述" name="description">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space size="large" style={{ marginBottom: 12 }}>
|
<Space size="large" className="workflow-drawer-switch-row">
|
||||||
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -91,7 +91,7 @@ export default function WorkflowEditorDrawer(props: {
|
||||||
label={
|
label={
|
||||||
<Space>
|
<Space>
|
||||||
<span>Cron 表达式</span>
|
<span>Cron 表达式</span>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" className="workflow-drawer-help">
|
||||||
5 段:分 时 日 月 周,例如 \"*/30 * * * *\"
|
5 段:分 时 日 月 周,例如 \"*/30 * * * *\"
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -118,10 +118,10 @@ export default function WorkflowEditorDrawer(props: {
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
title={<Text strong>Graph JSON(节点列表)</Text>}
|
title={<Text strong>Graph JSON(节点列表)</Text>}
|
||||||
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
|
className="workflow-drawer-card workflow-drawer-card-spaced"
|
||||||
extra={
|
extra={
|
||||||
<Tooltip title="还原为示例">
|
<Tooltip title="还原为示例">
|
||||||
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
|
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} className="workflow-drawer-small-btn">
|
||||||
示例
|
示例
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -131,9 +131,9 @@ export default function WorkflowEditorDrawer(props: {
|
||||||
rows={20}
|
rows={20}
|
||||||
value={graphText}
|
value={graphText}
|
||||||
onChange={(e) => setGraphText(e.target.value)}
|
onChange={(e) => setGraphText(e.target.value)}
|
||||||
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
className="workflow-graph-textarea"
|
||||||
/>
|
/>
|
||||||
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
|
<Paragraph type="secondary" className="workflow-drawer-help-block">
|
||||||
支持模板 <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>。
|
支持模板 <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>。
|
||||||
<br />
|
<br />
|
||||||
节点类型:agent / skill / http / transform / branch;branch 节点用 <code>condition</code>(返回 bool 的 JS 片段),引擎按 next/elseNext 走。
|
节点类型: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 [type, setType] = useState<WorkflowNodeType>('agent');
|
||||||
const [id, setId] = useState('');
|
const [id, setId] = useState('');
|
||||||
return (
|
return (
|
||||||
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
|
<Card size="small" title="快速追加节点" className="workflow-drawer-card workflow-drawer-card-spaced">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={type}
|
||||||
style={{ width: 140 }}
|
className="workflow-node-type-select"
|
||||||
onChange={(v) => setType(v as WorkflowNodeType)}
|
onChange={(v) => setType(v as WorkflowNodeType)}
|
||||||
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({ value: k, label }))}
|
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({ value: k, label }))}
|
||||||
/>
|
/>
|
||||||
<Input placeholder="节点 id(如 step1)" value={id} onChange={(e) => setId(e.target.value)} style={{ width: 200 }} />
|
<Input placeholder="节点 id(如 step1)" value={id} onChange={(e) => setId(e.target.value)} className="workflow-node-id-input" />
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={!id.trim()}
|
disabled={!id.trim()}
|
||||||
style={{ borderRadius: 10 }}
|
className="workflow-drawer-save"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' };
|
const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' };
|
||||||
if (type === 'branch') base.elseNext = '';
|
if (type === 'branch') base.elseNext = '';
|
||||||
|
|
@ -188,4 +188,3 @@ function defaultConfig(type: WorkflowNodeType): Record<string, any> {
|
||||||
return { condition: 'steps.prev?.output?.value === true' };
|
return { condition: 'steps.prev?.output?.value === true' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand-mark {
|
.login-brand-logo {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 12px;
|
flex: 0 0 auto;
|
||||||
background: var(--gradient-brand);
|
object-fit: contain;
|
||||||
color: #fff;
|
filter: drop-shadow(0 12px 22px rgba(17, 103, 255, 0.16));
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand-name {
|
.login-brand-name {
|
||||||
|
|
@ -307,17 +301,12 @@ body {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .brand .brand-mark {
|
.sidebar .brand .brand-logo {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
flex: 0 0 auto;
|
||||||
background: var(--gradient-brand);
|
object-fit: contain;
|
||||||
color: #fff;
|
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-section-label {
|
.sidebar .nav-section-label {
|
||||||
|
|
@ -714,21 +703,18 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-toolbar {
|
.chat-input-toolbar {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-toolbar-left {
|
.chat-input-toolbar-left {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-toolbar .chat-send-button {
|
.chat-input-toolbar .chat-send-button {
|
||||||
margin-left: 0;
|
margin-left: auto;
|
||||||
align-self: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-model-select .ant-select-selector {
|
.chat-model-select .ant-select-selector {
|
||||||
|
|
@ -736,14 +722,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-model-select .ant-select-selection-item {
|
.chat-model-select .ant-select-selection-item {
|
||||||
max-width: 9rem;
|
max-width: 10rem;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-model-select .ant-select-selection-placeholder {
|
|
||||||
max-width: 9rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
||||||
|
|
@ -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