Commit c49ab07e authored by David Yang's avatar David Yang

feat: 应用切换

parent 3489b2a6
import { defineConfig } from '@umijs/max';
export default defineConfig({
base: 'ai',
publicPath: '/ai/',
base: '/ai_chat/',
publicPath: '/ai_chat/',
antd: {},
access: {},
model: {},
......@@ -33,7 +33,7 @@ export default defineConfig({
npmClient: 'pnpm',
proxy: {
'/api': {
target: 'https://awm.rsmchina.com.cn/api',
target: 'https://xy.streams7.com/api',
// target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: { '^/api': '' },
......
{
"name": "S-Agent",
"version": "1.0.26",
"build": "1"
"name": "ai_chat",
"version": "1.0.1",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
import {
AppstoreOutlined,
ExclamationCircleOutlined,
ExportOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { Alert, Empty, Modal, Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { useEffect, useMemo, useState } from 'react';
import { getApplications, getSystemInfo } from '../../services/DataService';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title:
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function extractApplications(response) {
if (Array.isArray(response)) {
return response;
}
if (Array.isArray(response?.records)) {
return response.records;
}
if (Array.isArray(response?.applications)) {
return response.applications;
}
if (Array.isArray(response?.data)) {
return response.data;
}
return [];
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
const useStyles = createStyles(({ css, responsive }) => ({
modal: css`
.ant-modal-content {
overflow: hidden;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.72);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.22);
padding: 0;
}
.ant-modal-body {
padding: 0;
}
`,
header: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid rgba(228, 228, 231, 0.82);
${responsive.xs} {
flex-direction: column;
padding: 18px;
}
`,
eyebrow: css`
display: inline-flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #e11d48;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
`,
title: css`
margin: 10px 0 0;
color: #020617;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
letter-spacing: 0;
`,
subtitle: css`
margin: 6px 0 0;
max-width: 640px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
`,
headerActions: css`
display: flex;
align-items: center;
gap: 12px;
`,
countBox: css`
min-width: 90px;
padding: 8px 12px;
border-radius: 16px;
border: 1px solid #ffe4e6;
background: rgba(255, 241, 242, 0.76);
text-align: right;
`,
countLabel: css`
color: #f43f5e;
font-size: 11px;
line-height: 1.2;
font-weight: 600;
`,
countValue: css`
margin-top: 4px;
color: #020617;
font-size: 22px;
line-height: 1;
font-weight: 700;
`,
closeButton: css`
height: 40px;
padding: 0 14px;
border: 1px solid #e4e4e7;
border-radius: 12px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
border-color: #d4d4d8;
color: #0f172a;
background: #fafafa;
}
`,
body: css`
max-height: min(68dvh, 720px);
overflow-y: auto;
padding: 18px 20px 20px;
scrollbar-gutter: stable;
${responsive.xs} {
max-height: 64dvh;
padding: 16px 18px 18px;
}
`,
section: css`
& + & {
margin-top: 18px;
}
`,
sectionHeader: css`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
`,
sectionTitle: css`
color: #0f172a;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
`,
sectionLine: css`
height: 1px;
flex: 1;
background: rgba(228, 228, 231, 0.86);
`,
sectionCount: css`
color: #94a3b8;
font-size: 12px;
font-weight: 600;
`,
grid: css`
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
@media (min-width: 1680px) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
${responsive.lg} {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
${responsive.md} {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
${responsive.xs} {
grid-template-columns: 1fr;
}
`,
card: css`
position: relative;
min-height: 84px;
overflow: hidden;
display: block;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(228, 228, 231, 0.88);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
border-color: #fecdd3;
box-shadow: 0 16px 32px rgba(244, 63, 94, 0.12);
color: inherit;
}
&:focus-visible {
outline: 2px solid rgba(244, 63, 94, 0.48);
outline-offset: 2px;
}
`,
cardCurrent: css`
border-color: #fda4af;
background: linear-gradient(
180deg,
rgba(255, 241, 242, 0.95),
rgba(255, 255, 255, 0.96)
);
`,
cardGlow: css`
position: absolute;
inset: 0 0 auto;
height: 56px;
background: radial-gradient(
circle at top right,
rgba(251, 113, 133, 0.14),
transparent 50%
);
opacity: 0;
transition: opacity 0.2s ease;
a:hover & {
opacity: 1;
}
`,
cardTop: css`
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
`,
cardMain: css`
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
`,
logo: css`
width: 36px;
height: 36px;
flex-shrink: 0;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.86);
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
`,
logoFallback: css`
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #b42318, #f43f5e);
color: #ffffff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 8px 18px rgba(180, 35, 24, 0.22);
`,
cardText: css`
min-width: 0;
`,
cardTitle: css`
overflow: hidden;
color: #020617;
font-size: 14px;
line-height: 1.35;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardDesc: css`
margin-top: 3px;
overflow: hidden;
color: #64748b;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardActions: css`
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
`,
currentBadge: css`
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #ffe4e6;
color: #be123c;
font-size: 11px;
font-weight: 700;
`,
openIcon: css`
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
transition: background 0.2s ease, color 0.2s ease;
a:hover & {
background: #fff1f2;
color: #e11d48;
}
`,
tags: css`
position: relative;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
`,
tag: css`
display: inline-flex;
align-items: center;
max-width: 100%;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #f4f4f5;
color: #3f3f46;
font-size: 11px;
line-height: 1;
font-weight: 600;
`,
roleTag: css`
background: #f1f5f9;
color: #334155;
`,
groupTag: css`
background: #fff1f2;
color: #be123c;
`,
skeletonCard: css`
min-height: 84px;
padding: 10px;
border-radius: 16px;
border: 1px solid #e4e4e7;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
`,
empty: css`
min-height: 288px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #e4e4e7;
border-radius: 28px;
background: rgba(250, 250, 250, 0.82);
`,
}));
function ApplicationCard({ application }) {
const { styles, cx } = useStyles();
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={cx(styles.card, current && styles.cardCurrent)}
>
<div className={styles.cardGlow} />
<div className={styles.cardTop}>
<div className={styles.cardMain}>
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className={styles.logo}
/>
) : (
<div className={styles.logoFallback}>
{application.title.slice(0, 1)}
</div>
)}
<div className={styles.cardText}>
<div className={styles.cardTitle}>{application.title}</div>
<div className={styles.cardDesc}>{application.description}</div>
</div>
</div>
<div className={styles.cardActions}>
{current ? <span className={styles.currentBadge}>当前</span> : null}
<span className={styles.openIcon}>
<ExportOutlined />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className={styles.tags}>
{accessLabel ? (
<span className={styles.tag}>{accessLabel}</span>
) : null}
{firstRole ? (
<span className={`${styles.tag} ${styles.roleTag}`}>
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className={`${styles.tag} ${styles.groupTag}`}>
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
const { styles } = useStyles();
return (
<div className={styles.grid}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className={styles.skeletonCard}>
<Skeleton active avatar paragraph={{ rows: 2 }} title={false} />
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const { styles } = useStyles();
const [loadedSiteInfo, setLoadedSiteInfo] = useState(null);
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const effectiveSiteInfo = siteInfo || loadedSiteInfo;
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, effectiveSiteInfo?.group);
}, [applications, effectiveSiteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open || siteInfo?.group || loadedSiteInfo) {
return;
}
let cancelled = false;
const loadSiteInfo = async () => {
try {
const response = await getSystemInfo();
if (cancelled) {
return;
}
setLoadedSiteInfo({
...(response?.site || response || {}),
key: response?.key,
});
} catch (error) {
if (!cancelled) {
setLoadedSiteInfo({});
}
}
};
loadSiteInfo();
return () => {
cancelled = true;
};
}, [loadedSiteInfo, open, siteInfo?.group]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = extractApplications(response);
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Modal
open={open}
centered
footer={null}
closeIcon={null}
width="min(92vw, 1472px)"
onCancel={onClose}
className={styles.modal}
>
<div className={styles.header}>
<div>
<div className={styles.eyebrow}>
<ThunderboltOutlined />
Applications
</div>
<h2 className={styles.title}>应用切换</h2>
<p className={styles.subtitle}>
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className={styles.headerActions}>
<div className={styles.countBox}>
<div className={styles.countLabel}>可见应用</div>
<div className={styles.countValue}>{applications.length}</div>
</div>
<button
type="button"
onClick={onClose}
className={styles.closeButton}
>
关闭
</button>
</div>
</div>
<div className={styles.body}>
{status === 'error' && errorMessage ? (
<Alert
showIcon
type="warning"
icon={<ExclamationCircleOutlined />}
message={errorMessage}
style={{ marginBottom: 16 }}
/>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
applicationSections.map((section) => (
<section key={section.key} className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>{section.label}</div>
<span className={styles.sectionLine} />
<span className={styles.sectionCount}>
{section.applications.length}
</span>
</div>
<div className={styles.grid}>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))
) : (
<div className={styles.empty}>
<Empty
image={<AppstoreOutlined style={{ fontSize: 42 }} />}
description={
<span>
暂无可切换应用
<br />
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</span>
}
/>
</div>
)}
</div>
</Modal>
);
}
import { login } from '@/services/DataService';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { getCaptcha, login } from '@/services/DataService';
import {
LockOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons';
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { Alert, Flex, Modal, Typography } from 'antd';
import { Alert, Button, Flex, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
const useStyle = createStyles(({ css }) => {
return {
......@@ -14,10 +19,10 @@ const useStyle = createStyles(({ css }) => {
overflow: hidden;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.22),
rgba(251, 207, 232, 0.26),
transparent 20%
),
linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);
}
.ant-modal-body {
......@@ -33,7 +38,7 @@ const useStyle = createStyles(({ css }) => {
padding: 38px 34px;
background: radial-gradient(
circle at top left,
rgba(0, 166, 232, 0.22),
rgba(200, 30, 58, 0.22),
transparent 22%
),
linear-gradient(160deg, #0f172a 0%, #1e293b 100%);
......@@ -132,6 +137,8 @@ const useStyle = createStyles(({ css }) => {
height: 46px;
border-radius: 16px;
font-weight: 600;
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
border: none;
}
`,
formInner: css`
......@@ -142,14 +149,76 @@ const useStyle = createStyles(({ css }) => {
margin-bottom: 20px;
border-radius: 14px;
`,
captchaRow: css`
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 12px;
align-items: flex-start;
.ant-form-item {
margin-bottom: 0;
}
`,
captchaButton: css`
height: 48px;
border-radius: 16px;
padding: 0 10px;
overflow: hidden;
border-color: #e2e8f0;
color: #64748b;
.ant-btn-icon {
display: inline-flex;
}
`,
captchaImage: css`
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
display: block;
}
`,
};
});
export default (props) => {
const { onError, onSuccess } = props;
const [errorMessage, setErrorMessage] = useState(null);
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const formRef = useRef();
const { styles } = useStyle();
const refreshCaptcha = useCallback(async () => {
setCaptchaLoading(true);
try {
const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (error) {
const nextErrorMessage =
error.response?.data?.message || error.message || '验证码加载失败';
setCaptchaData(null);
setErrorMessage(nextErrorMessage);
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (props.open) {
setErrorMessage(null);
formRef.current?.setFieldsValue({ captcha: undefined });
refreshCaptcha();
}
}, [props.open, refreshCaptcha]);
return (
<Modal
{...props}
......@@ -189,12 +258,18 @@ export default (props) => {
<div className={styles.formPane}>
<div className={styles.formInner}>
<LoginForm
formRef={formRef}
logo="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
subTitle="登录后继续使用统一 AI 对话工作台"
onFinish={async (form) => {
try {
const userInfo = await login(form.username, form.password);
const userInfo = await login(
form.username,
form.password,
form.captcha,
captchaData?.token,
);
localStorage.setItem(
'current_user',
......@@ -211,6 +286,8 @@ export default (props) => {
error.message ||
'网络错误';
setErrorMessage(nextErrorMessage);
formRef.current?.setFieldsValue({ captcha: undefined });
refreshCaptcha();
onError?.(nextErrorMessage, error);
}
}}
......@@ -244,6 +321,45 @@ export default (props) => {
},
]}
/>
<div className={styles.captchaRow}>
<ProFormText
name="captcha"
fieldProps={{
size: 'large',
prefix: (
<SafetyCertificateOutlined className={'prefixIcon'} />
),
}}
placeholder="验证码"
rules={[
{
required: true,
message: '请输入验证码',
},
]}
/>
<Button
htmlType="button"
className={styles.captchaButton}
loading={captchaLoading}
disabled={captchaLoading}
icon={captchaData?.image ? undefined : <ReloadOutlined />}
onClick={() => {
setErrorMessage(null);
formRef.current?.setFieldsValue({ captcha: undefined });
refreshCaptcha();
}}
>
{captchaData?.image ? (
<span
className={styles.captchaImage}
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
'刷新'
)}
</Button>
</div>
{errorMessage && (
<Alert
className={styles.error}
......
......@@ -12,17 +12,19 @@ const GlobalStyle = createGlobalStyle`
--app-shell-bg:
radial-gradient(
circle at top left,
rgba(0, 156, 217, 0.16),
rgba(200, 30, 58, 0.14),
transparent 26%
),
radial-gradient(
circle at bottom right,
rgba(126, 34, 206, 0.08),
transparent 26%
circle at right center,
rgba(159, 18, 57, 0.08),
transparent 24%
),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%);
--app-surface: rgba(255, 255, 255, 0.86);
--app-surface-strong: #ffffff;
--app-brand: #c81e3a;
--app-brand-strong: #9f1239;
--app-sidebar: #0f172a;
--app-sidebar-soft: rgba(30, 41, 59, 0.9);
--app-border: rgba(148, 163, 184, 0.2);
......@@ -38,7 +40,10 @@ const GlobalStyle = createGlobalStyle`
html,
body,
#root {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
body {
......@@ -84,12 +89,12 @@ const GlobalStyle = createGlobalStyle`
export default () => {
const themeToken = defaultAlgorithm({
...defaultSeed,
colorPrimary: '#009cd9',
colorInfo: '#009cd9',
colorPrimary: '#c81e3a',
colorInfo: '#c81e3a',
colorSuccess: '#1d9b62',
colorWarning: '#d97706',
colorError: '#dc2626',
colorBgBase: '#f6f9fc',
colorBgBase: '#fff7f7',
colorBgContainer: '#ffffff',
colorTextBase: '#0f172a',
borderRadius: 18,
......@@ -121,7 +126,7 @@ export default () => {
},
Input: {
borderRadius: 14,
activeShadow: '0 0 0 3px rgba(0, 156, 217, 0.12)',
activeShadow: '0 0 0 3px rgba(200, 30, 58, 0.14)',
},
Modal: {
borderRadiusLG: 24,
......@@ -134,7 +139,7 @@ export default () => {
colorBgElevated: '#0f172a',
},
Steps: {
colorPrimary: '#009cd9',
colorPrimary: '#c81e3a',
},
},
}}
......
import {
AuditOutlined,
BulbOutlined,
CloudUploadOutlined,
CopyOutlined,
DatabaseOutlined,
FileDoneOutlined,
FileSearchOutlined,
LoadingOutlined,
PaperClipOutlined,
PayCircleOutlined,
......@@ -18,15 +22,12 @@ import {
Welcome,
XStream,
} from '@ant-design/x';
import { useQuery } from '@umijs/max';
import {
Button,
Checkbox,
Collapse,
Flex,
message,
Space,
Tag,
theme,
Typography,
} from 'antd';
......@@ -51,7 +52,6 @@ import {
imageAnalyze,
llmChat,
queryMcps,
queryTopics,
semanticSearch,
updateConversation,
} from '../../services/DataService';
......@@ -80,6 +80,14 @@ const AGENTS = {
key: '2',
label: '智能体',
children: [
{
key: '2-5',
icon: <FileDoneOutlined />,
label: '审计检查清单生成',
description:
'根据当前审计项目、整改问题和责任单位上下文,生成可执行的审计检查清单。',
disabled: false,
},
{
key: '2-4',
icon: <PayCircleOutlined />,
......@@ -104,6 +112,159 @@ const AGENTS = {
],
};
const AUDIT_TOPIC = {
name: 'mock_audit_rectification_query',
title: '审计整改情况查询',
metadata: {
prompt:
'你是内网审计业务工作台助手,专注审计整改情况查询、问题跟踪、责任单位协同和检查清单生成。',
context:
'回答时优先使用已选择的审计对象上下文,说明数据来源于当前内网业务对象;如果上下文不足,指出需要补充的对象或字段。',
enable_search: false,
questions: [
'汇总当前项目的整改进度和高风险事项',
'分析该整改问题逾期的主要原因',
'生成责任单位现场复核需要关注的检查点',
],
},
};
const AUDIT_OBJECT_GROUPS = [
{
key: 'project',
title: '审计项目',
icon: <AuditOutlined />,
records: [
{
id: 'AP-2026-001',
title: '2026 年度财务收支审计',
status: '整改跟踪中',
fields: {
项目编号: 'AP-2026-001',
审计期间: '2026-01 至 2026-05',
牵头部门: '审计部一组',
问题总数: '18',
已完成整改: '11',
高风险问题: '3',
},
},
{
id: 'AP-2026-004',
title: '采购合规专项审计',
status: '现场复核中',
fields: {
项目编号: 'AP-2026-004',
审计期间: '2026-03 至 2026-06',
牵头部门: '审计部二组',
问题总数: '12',
已完成整改: '7',
高风险问题: '2',
},
},
],
},
{
key: 'issue',
title: '整改问题',
icon: <FileSearchOutlined />,
records: [
{
id: 'RI-2026-014',
title: '供应商准入资料不完整',
status: '逾期未完成',
fields: {
问题编号: 'RI-2026-014',
风险等级: '高',
整改要求: '补齐供应商准入审批、资质复核和年度评价记录',
计划完成日: '2026-06-15',
当前进展: '已补充 60% 资料,缺少两家供应商复核记录',
逾期风险: '影响采购合规闭环和后续责任认定',
},
},
{
id: 'RI-2026-021',
title: '费用报销附件审核不充分',
status: '整改中',
fields: {
问题编号: 'RI-2026-021',
风险等级: '中',
整改要求: '完善附件校验规则,抽样复核历史报销单据',
计划完成日: '2026-06-30',
当前进展: '规则已上线,抽样复核完成 45%',
逾期风险: '可能导致重复报销或附件缺失问题持续发生',
},
},
],
},
{
key: 'unit',
title: '责任单位',
icon: <DatabaseOutlined />,
records: [
{
id: 'OU-1203',
title: '采购管理部',
status: '重点跟踪',
fields: {
单位编码: 'OU-1203',
负责人: '张敏',
整改联系人: '李卓',
未完成问题: '4',
近三月逾期次数: '2',
协同部门: '法务部、财务共享中心',
},
},
{
id: 'OU-0816',
title: '财务共享中心',
status: '正常推进',
fields: {
单位编码: 'OU-0816',
负责人: '王珂',
整改联系人: '陈宁',
未完成问题: '3',
近三月逾期次数: '0',
协同部门: '采购管理部、信息技术部',
},
},
],
},
];
const DEFAULT_AUDIT_SELECTION = AUDIT_OBJECT_GROUPS.reduce((result, group) => {
result[group.key] = group.records[0]?.id;
return result;
}, {});
function buildAuditContext(selection) {
return AUDIT_OBJECT_GROUPS.map((group) => {
const record =
group.records.find((item) => item.id === selection[group.key]) ||
group.records[0];
return {
type: group.key,
typeLabel: group.title,
id: record.id,
title: record.title,
status: record.status,
fields: record.fields,
};
});
}
function formatAuditContext(context = []) {
return context
.map((item) => {
const fields = Object.entries(item.fields || {})
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
return `## ${item.typeLabel}: ${item.title}\n状态: ${item.status}\n${fields}`;
})
.join('\n\n');
}
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
......@@ -112,56 +273,171 @@ const useStyle = createStyles(({ token, css, responsive }) => {
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
padding: 18px;
gap: 12px;
padding: 16px 22px 18px;
background: transparent;
overflow: hidden;
${responsive.xs} {
padding: 12px;
gap: 14px;
gap: 10px;
}
`,
chatPrompt: css`
.ant-prompts-item {
border-radius: 22px !important;
border: 1px solid rgba(148, 163, 184, 0.16) !important;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.9) 100%
) !important;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
width: 100%;
min-width: 0;
overflow: hidden;
.ant-prompts,
.ant-prompts-list,
.ant-prompts-group,
.ant-prompts-group-content {
width: 100%;
min-width: 0;
}
& > .ant-prompts-list,
.ant-prompts-item-has-nest {
height: 100%;
}
.ant-prompts-item-has-nest {
display: flex !important;
width: 100% !important;
min-width: 0 !important;
overflow: hidden;
flex-direction: column !important;
border-radius: 20px !important;
border: 1px solid rgba(200, 30, 58, 0.1) !important;
background: rgba(255, 255, 255, 0.92) !important;
box-shadow: 0 10px 26px rgba(159, 18, 57, 0.06) !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.ant-prompts-item:hover {
transform: translateY(-2px);
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.1) !important;
.ant-prompts-item-has-nest:hover {
border-color: rgba(200, 30, 58, 0.2) !important;
box-shadow: 0 16px 34px rgba(159, 18, 57, 0.1) !important;
}
.ant-prompts-label {
color: #0f172a !important;
font-weight: 600 !important;
min-width: 0;
max-width: 100%;
}
.ant-prompts-desc {
color: #64748b !important;
width: 100%;
line-height: 1.6;
min-width: 0;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
}
.ant-prompts-content {
min-width: 0 !important;
max-width: 100% !important;
overflow: hidden;
}
.ant-prompts-icon {
color: ${token.colorPrimary} !important;
flex: 0 0 auto;
}
.ant-prompts-sub-item {
border-radius: 16px !important;
background: rgba(255, 255, 255, 0.72) !important;
border: 1px solid rgba(148, 163, 184, 0.12) !important;
.ant-prompts-item-has-nest > .ant-prompts-content {
display: flex !important;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
}
.ant-prompts-item-has-nest > .ant-prompts-content > .ant-prompts-label {
padding: 24px 28px 14px !important;
color: #0f172a !important;
font-size: 20px !important;
font-weight: 700 !important;
line-height: 1.25 !important;
letter-spacing: 0 !important;
height: auto !important;
margin: 0 !important;
}
.ant-prompts-nested {
height: auto !important;
overflow: visible !important;
padding: 0 28px 26px !important;
}
.ant-prompts-nested .ant-prompts-list {
display: grid !important;
height: auto !important;
overflow: visible !important;
gap: 10px !important;
}
.ant-prompts-nested .ant-prompts-item {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
min-width: 0 !important;
display: flex !important;
align-items: center !important;
gap: 12px !important;
min-height: 46px !important;
padding: 10px 12px !important;
border-radius: 14px !important;
background: #ffffff !important;
border: 1px solid rgba(200, 30, 58, 0.1) !important;
box-shadow: none !important;
transition: background-color 0.18s ease, border-color 0.18s ease,
box-shadow 0.18s ease;
&:hover {
background: #fff6f7 !important;
border-color: rgba(200, 30, 58, 0.22) !important;
box-shadow: 0 8px 18px rgba(159, 18, 57, 0.08) !important;
}
.ant-prompts-label,
.ant-prompts-desc {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ant-prompts-label {
-webkit-line-clamp: 1;
}
.ant-prompts-desc {
-webkit-line-clamp: 3;
}
}
${responsive.xs} {
.ant-prompts-item-has-nest > .ant-prompts-content > .ant-prompts-label {
padding: 18px 18px 12px !important;
font-size: 18px !important;
}
.ant-prompts-nested {
padding: 0 18px 18px !important;
}
.ant-prompts-nested .ant-prompts-list {
gap: 8px !important;
}
.ant-prompts-nested .ant-prompts-item {
align-items: flex-start !important;
min-height: 44px !important;
}
}
`,
chatList: css`
flex: 1;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
`,
......@@ -170,8 +446,9 @@ const useStyle = createStyles(({ token, css, responsive }) => {
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
gap: 12px;
min-height: 0;
padding-top: 0;
`,
bubbleList: css`
.ant-bubble-list {
......@@ -194,10 +471,10 @@ const useStyle = createStyles(({ token, css, responsive }) => {
}
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
color: #ffffff;
border-radius: 22px 22px 10px 22px;
box-shadow: 0 18px 36px rgba(14, 165, 233, 0.24);
box-shadow: 0 18px 36px rgba(159, 18, 57, 0.22);
padding: 14px 18px;
`,
assistantBubble: css`
......@@ -219,39 +496,48 @@ const useStyle = createStyles(({ token, css, responsive }) => {
padding: 14px 16px;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #3f9c35 0%, #009cde 100%);
background-image: linear-gradient(90deg, #c81e3a 0%, #fb7185 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
justify-content: center;
padding-top: 10px;
`,
heroCard: css`
padding: 32px;
border-radius: 30px;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.36),
transparent 24%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.08);
min-height: 100%;
justify-content: flex-start;
padding-top: 26px;
padding-bottom: 2px;
${responsive.xs} {
padding: 24px 18px;
padding-top: 12px;
}
`,
auditContextCard: css`
margin-top: 16px;
padding: 16px;
border-radius: 18px;
background: #0f172a;
color: #e2e8f0;
display: grid;
gap: 10px;
strong {
color: #ffffff;
font-size: 14px;
}
p {
margin: 0;
color: rgba(226, 232, 240, 0.74);
font-size: 13px;
line-height: 1.7;
}
`,
promptRow: css`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
width: 100%;
min-width: 0;
${responsive.md} {
grid-template-columns: 1fr;
......@@ -263,11 +549,11 @@ const useStyle = createStyles(({ token, css, responsive }) => {
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(148, 163, 184, 0.18) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08) !important;
padding: 10px !important;
border-radius: 26px !important;
border: 1px solid rgba(200, 30, 58, 0.28) !important;
background: rgba(255, 255, 255, 0.96) !important;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08) !important;
padding: 12px !important;
}
.ant-sender-textarea {
......@@ -276,6 +562,7 @@ const useStyle = createStyles(({ token, css, responsive }) => {
.ant-sender-input {
font-size: 15px;
color: #0f172a !important;
}
.ant-sender-header {
......@@ -291,11 +578,11 @@ const useStyle = createStyles(({ token, css, responsive }) => {
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 8px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.68);
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.08);
padding: 6px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.08);
`,
senderPrompt: css`
width: 100%;
......@@ -352,7 +639,7 @@ const useStyle = createStyles(({ token, css, responsive }) => {
}
`,
assistantActions: css`
padding-top: 8px;
padding-top: 10px;
`,
};
});
......@@ -367,10 +654,20 @@ export default forwardRef((props, ref) => {
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [knowledgeBases, setKnowledgeBases] = useState([]);
const [inputValue, setInputValue] = useState('');
const senderRef = useRef(null);
const abortController = useRef(null);
const auditContext = useMemo(
() => buildAuditContext(DEFAULT_AUDIT_SELECTION),
[],
);
const auditSettings = useMemo(
() => ({
auditContext,
auditContextText: formatAuditContext(auditContext),
}),
[auditContext],
);
useImperativeHandle(ref, () => {
return {
......@@ -475,26 +772,21 @@ export default forwardRef((props, ref) => {
);
};
const { data: topics = [], refetch: refetchConversations } = useQuery({
queryKey: ['topics', userInfo],
queryFn: async () => {
if (!userInfo) {
return [];
}
try {
const res = await queryTopics();
return res.records;
} catch (err) {}
},
});
const getSystemPrompt = async () => {
const topic = conversation.metadata.topic;
const auditContextText = formatAuditContext(
conversation.metadata.auditContext || [],
);
const auditPrompt = auditContextText
? `\n\n当前审计对象上下文:\n${auditContextText}`
: '';
if (!topic) {
return SYSTEM_PROMPT;
return `${SYSTEM_PROMPT}${auditPrompt}`;
}
return `${topic.metadata.prompt || ''}\n${topic.metadata.context || ''}`;
return `${topic.metadata.prompt || ''}\n${
topic.metadata.context || ''
}${auditPrompt}`;
};
const getMessagePrompt = async (message) => {
......@@ -509,6 +801,12 @@ export default forwardRef((props, ref) => {
let searchPrompt = '';
let filePrompt = '';
let kbPrompt = '';
const auditContextText =
message.settings?.auditContextText ||
formatAuditContext(conversation.metadata.auditContext || []);
const auditPrompt = auditContextText
? `当前审计对象上下文:\n${auditContextText}\n\n`
: '';
for (const searchSubject of searchSubjects) {
const res = await semanticSearch(searchSubject.subject, {
......@@ -629,7 +927,7 @@ export default forwardRef((props, ref) => {
filePrompt = `用户上传的文件内容:\n${fileContent}\n\n`;
}
return `${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
return `${auditPrompt}${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
};
const [agent] = useXAgent({
......@@ -1091,39 +1389,6 @@ export default forwardRef((props, ref) => {
}
}, [messages]);
useEffect(() => {
setKnowledgeBases([
{
key: 'external_law',
description: '法律法规库',
icon: <Checkbox />,
selected: false,
},
{
key: 'regulatory_penalty',
description: '监管处罚库',
icon: <Checkbox />,
selected: false,
},
{
key: 'am_material',
description: 'AM材料库',
icon: <Checkbox />,
selected: false,
},
]);
}, []);
const toggleKnowledgeBase = (key) => {
const knowledgeBase = knowledgeBases.find((kb) => kb.key === key);
if (knowledgeBase) {
knowledgeBase.selected = !knowledgeBase.selected;
knowledgeBase.icon = <Checkbox checked={knowledgeBase.selected} />;
}
setKnowledgeBases([...knowledgeBases]);
};
const addConversation = async (data, message) => {
const conversation = await createConversation(data);
conversation.metadata = {
......@@ -1140,6 +1405,10 @@ export default forwardRef((props, ref) => {
settings = null,
agentLabel = null,
}) => {
const nextSettings = {
...auditSettings,
...settings,
};
const userMessage = content && {
role: 'user',
content,
......@@ -1148,14 +1417,15 @@ export default forwardRef((props, ref) => {
attachedFiles
.filter((file) => file.status === 'done')
.map((file) => file.response),
settings: settings,
settings: nextSettings,
};
if (!conversation) {
await addConversation(
{
label: agentLabel || content || topic?.title,
label: agentLabel || content || topic?.title || AUDIT_TOPIC.title,
topic: topic,
auditContext: nextSettings.auditContext,
},
userMessage,
);
......@@ -1206,6 +1476,19 @@ export default forwardRef((props, ref) => {
}}
/>
)}
{conversation.metadata.auditContext?.length > 0 && (
<div className={styles.auditContextCard}>
<strong>已读取审计对象上下文</strong>
<p>
{conversation.metadata.auditContext
.map(
(item) =>
`${item.typeLabel}:${item.title}(${item.status})`,
)
.join(' / ')}
</p>
</div>
)}
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
......@@ -1241,45 +1524,27 @@ export default forwardRef((props, ref) => {
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<div className={styles.heroCard}>
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Tag
color="cyan"
bordered={false}
style={{ width: 'fit-content' }}
>
企业 AI 对话应用
</Tag>
<Welcome
variant="borderless"
icon="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
description="统一承载知识问答、业务理解、附件分析与专属智能体协作。"
/>
</Space>
</div>
<div className={styles.promptRow}>
<Prompts
items={[
{
key: 'topics',
key: AUDIT_TOPIC.name,
label: '话题',
children: topics.map((topic, i) => ({
key: topic.name,
description: topic.title,
icon: (
<span
style={{
fontWeight: 'bold',
fontFamily: 'monospace',
color: token.colorPrimary,
}}
>
{i + 1}
</span>
),
topic: topic,
})),
children: [
{
key: AUDIT_TOPIC.name,
description: AUDIT_TOPIC.title,
icon: <FileSearchOutlined />,
topic: AUDIT_TOPIC,
},
...AUDIT_TOPIC.metadata.questions.map((question) => ({
key: question,
description: question,
icon: <AuditOutlined />,
topic: AUDIT_TOPIC,
content: question,
})),
],
},
]}
style={{
......@@ -1287,10 +1552,11 @@ export default forwardRef((props, ref) => {
}}
styles={{
list: { height: '100%' },
subItem: { padding: 0, background: 'transparent' },
subItem: { padding: 0, background: '#ffffff' },
}}
onItemClick={async (info) => {
onSubmit({
content: info.data.content,
topic: info.data.topic,
});
}}
......@@ -1302,12 +1568,16 @@ export default forwardRef((props, ref) => {
flex: 1,
}}
styles={{
subItem: { background: 'rgba(255,255,255,0.72)' },
list: { height: '100%' },
subItem: { background: '#ffffff' },
}}
onItemClick={(info) => {
// 所有智能体都正常发送描述信息,包括发票助手
onSubmit({
content: info.data.description,
topic:
info.data.label === AGENTS.children[0].label
? AUDIT_TOPIC
: undefined,
agentLabel: info.data.label,
});
}}
......
......@@ -6,6 +6,7 @@ import {
updateConversation,
} from '@/services/DataService';
import {
AppstoreOutlined,
BorderlessTableOutlined,
CommentOutlined,
DeleteOutlined,
......@@ -28,20 +29,22 @@ import {
Input,
Modal,
Spin,
Typography,
Tooltip,
message,
theme,
} from 'antd';
import { createStyles } from 'antd-style';
import { useEffect, useMemo, useRef, useState } from 'react';
import ApplicationSwitcherDialog from '../../components/ApplicationSwitcherDialog';
import LoginModal from '../../components/LoginModal';
const useStyle = createStyles(({ token, css, responsive }) => {
return {
shell: css`
min-height: 100vh;
height: 100dvh;
padding: 16px;
display: flex;
overflow: hidden;
${responsive.xs} {
padding: 12px;
......@@ -49,7 +52,7 @@ const useStyle = createStyles(({ token, css, responsive }) => {
`,
frame: css`
width: 100%;
min-height: calc(100vh - 32px);
height: calc(100dvh - 32px);
display: flex;
gap: 16px;
border-radius: 36px;
......@@ -57,36 +60,39 @@ const useStyle = createStyles(({ token, css, responsive }) => {
overflow: hidden;
${responsive.xs} {
min-height: calc(100vh - 24px);
height: calc(100dvh - 24px);
border-radius: 26px;
}
`,
sidebar: css`
width: 304px;
width: 316px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.98) 0%,
rgba(15, 23, 42, 0.92) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 26px 60px rgba(15, 23, 42, 0.24);
gap: 12px;
background: radial-gradient(
circle at top left,
rgba(200, 30, 58, 0.12),
transparent 26%
),
linear-gradient(180deg, #0f172a 0%, #162033 58%, #1b2434 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 24px 52px rgba(15, 23, 42, 0.24);
color: rgba(255, 255, 255, 0.95);
padding: 22px 18px 18px;
border-radius: 32px;
padding: 18px 16px 14px;
border-radius: 34px;
`,
sidebarCollapsed: css`
width: 92px;
padding-inline: 14px;
width: 108px;
padding-inline: 12px;
align-items: center;
`,
sidebarHeader: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
min-height: 52px;
`,
brand: css`
display: flex;
......@@ -95,188 +101,269 @@ const useStyle = createStyles(({ token, css, responsive }) => {
min-width: 0;
`,
brandMark: css`
width: 40px;
height: 40px;
border-radius: 14px;
background: linear-gradient(135deg, #00a6e8 0%, #7dd3fc 100%);
width: 44px;
height: 44px;
border-radius: 16px;
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 18px;
font-size: 19px;
font-weight: 700;
box-shadow: 0 12px 24px rgba(0, 166, 232, 0.28);
box-shadow: 0 14px 28px rgba(159, 18, 57, 0.24);
`,
brandText: css`
min-width: 0;
h1 {
margin: 0;
font-size: 17px;
font-size: 18px;
line-height: 1.2;
color: #f8fafc;
color: #f8fbff;
letter-spacing: -0.01em;
}
p {
margin: 4px 0 0;
color: rgba(226, 232, 240, 0.72);
margin: 6px 0 0;
color: rgba(226, 232, 240, 0.86);
font-size: 12px;
line-height: 1.55;
max-width: 180px;
}
`,
collapseButton: css`
color: rgba(255, 255, 255, 0.78);
width: 40px;
height: 40px;
color: rgba(255, 255, 255, 0.94);
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.06);
&:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.08) !important;
background: rgba(255, 255, 255, 0.1) !important;
}
`,
newButton: css`
width: 100%;
height: 46px;
height: 48px;
border: none;
background: linear-gradient(135deg, #00a6e8 0%, #0ea5e9 100%);
box-shadow: 0 14px 24px rgba(0, 166, 232, 0.24);
border-radius: 18px;
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
box-shadow: 0 16px 28px rgba(159, 18, 57, 0.24);
color: #ffffff;
font-weight: 600;
font-size: 16px;
&:hover {
color: #ffffff !important;
background: linear-gradient(
135deg,
#13b5f5 0%,
#38bdf8 100%
#d92a47 0%,
#be123c 100%
) !important;
}
`,
sectionLabel: css`
font-size: 12px;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(226, 232, 240, 0.78);
font-weight: 700;
margin: 4px 6px 0;
`,
conversations: css`
flex: 1;
overflow-y: auto;
padding-right: 2px;
position: relative;
&::before {
content: '';
position: sticky;
top: 0;
display: block;
height: 6px;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(15, 23, 42, 0) 100%
);
z-index: 1;
pointer-events: none;
}
.ant-conversations-list {
margin: 0;
padding-top: 0;
padding-inline-start: 0;
}
.ant-conversations-list .ant-conversations-item {
padding-inline-start: 12px;
}
.ant-conversations-group {
margin-top: 10px;
margin-top: 0;
}
.ant-conversations-group-title {
color: rgba(226, 232, 240, 0.72);
font-size: 12px;
padding: 0 6px 10px;
color: rgba(248, 250, 252, 0.96) !important;
font-size: 14px;
line-height: 1.2;
padding: 2px 8px 8px;
font-weight: 700;
letter-spacing: 0.06em;
letter-spacing: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.4);
}
.ant-conversations-group-title * {
color: inherit !important;
}
.ant-conversations-item {
margin-bottom: 8px;
border-radius: 18px;
color: #e5eefb;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(148, 163, 184, 0.08);
color: #f8fafc !important;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.13) 0%,
rgba(255, 255, 255, 0.08) 100%
);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
min-height: 72px;
height: 60px;
min-height: 60px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-conversations-item:hover {
background: rgba(255, 255, 255, 0.11);
border-color: rgba(125, 211, 252, 0.22);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.12) 100%
);
border-color: rgba(251, 113, 133, 0.32);
transform: translateY(-1px);
}
.ant-conversations-item-active {
background: linear-gradient(
135deg,
rgba(14, 165, 233, 0.3) 0%,
rgba(59, 130, 246, 0.22) 100%
rgba(200, 30, 58, 0.36) 0%,
rgba(159, 18, 57, 0.26) 100%
);
border-color: rgba(125, 211, 252, 0.38);
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.2),
0 12px 26px rgba(2, 132, 199, 0.18);
border-color: rgba(251, 113, 133, 0.48);
box-shadow: inset 0 0 0 1px rgba(251, 113, 133, 0.24),
0 14px 26px rgba(15, 23, 42, 0.22);
}
.ant-conversations-item-label,
.ant-conversations-item-icon {
.ant-conversations-label,
.ant-conversations-icon,
.ant-conversations-menu-icon {
color: inherit !important;
}
.ant-conversations-item-label {
font-size: 15px;
.ant-conversations-label {
color: #f8fafc !important;
display: block;
font-size: 16px;
font-weight: 600;
line-height: 1.45;
color: #edf4ff !important;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.35);
line-height: 1.35;
min-width: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.45);
}
.ant-conversations-item-desc {
color: rgba(203, 213, 225, 0.68) !important;
.ant-conversations-label * {
color: inherit !important;
}
.ant-conversations-item-icon {
font-size: 16px;
opacity: 0.94;
.ant-conversations-icon {
flex: 0 0 22px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 17px;
opacity: 1;
color: rgba(255, 255, 255, 0.96) !important;
}
.ant-conversations-item-active .ant-conversations-item-label {
.ant-conversations-item-active .ant-conversations-label {
color: #ffffff !important;
}
.ant-conversations-item-active .ant-conversations-item-icon {
color: #e0f2fe !important;
.ant-conversations-item:hover .ant-conversations-label,
.ant-conversations-item:hover .ant-conversations-label *,
.ant-conversations-item-active .ant-conversations-label,
.ant-conversations-item-active .ant-conversations-label * {
color: #ffffff !important;
}
.ant-conversations-item-icon {
width: 18px;
height: 18px;
.ant-conversations-item-active .ant-conversations-icon,
.ant-conversations-item-active .ant-conversations-menu-icon {
color: #ffffff !important;
}
.ant-conversations-item-content {
padding-block: 2px;
.ant-conversations-menu-icon {
color: rgba(255, 255, 255, 0.86) !important;
opacity: 0.74;
padding: 6px;
border-radius: 10px;
transition: color 0.2s ease, background 0.2s ease, opacity 0.2s ease;
}
.ant-conversations-item-menu {
color: rgba(226, 232, 240, 0.72) !important;
.ant-conversations-item:hover .ant-conversations-menu-icon {
opacity: 1;
}
.ant-conversations-item-menu:hover {
.ant-conversations-menu-icon:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.12);
}
`,
emptyHistory: css`
padding-top: 28px;
padding-top: 12px;
.ant-empty-description {
color: rgba(203, 213, 225, 0.62);
color: rgba(226, 232, 240, 0.72);
}
`,
compactNav: css`
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-top: 8px;
overflow-y: auto;
`,
compactNavItem: css`
width: 100%;
display: flex;
justify-content: center;
`,
compactNavButton: css`
position: relative;
width: 58px;
height: 58px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(241, 245, 249, 0.92);
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
compactNavButtonActive: css`
background: linear-gradient(
135deg,
rgba(159, 18, 57, 0.52) 0%,
rgba(225, 29, 72, 0.26) 100%
);
border-color: rgba(251, 113, 133, 0.3);
box-shadow: 0 12px 24px rgba(127, 29, 29, 0.16);
`,
compactNavPin: css`
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #fb7185;
box-shadow: 0 0 0 4px rgba(251, 113, 133, 0.14);
`,
sidebarFooter: css`
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
......@@ -285,17 +372,34 @@ const useStyle = createStyles(({ token, css, responsive }) => {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: background 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
accountCardCollapsed: css`
flex-direction: column;
padding: 10px 8px;
gap: 8px;
`,
accountIdentity: css`
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
color: inherit;
cursor: pointer;
`,
accountMeta: css`
flex: 1;
min-width: 0;
strong {
......@@ -307,59 +411,67 @@ const useStyle = createStyles(({ token, css, responsive }) => {
span {
display: block;
color: rgba(203, 213, 225, 0.7);
color: rgba(226, 232, 240, 0.76);
font-size: 12px;
line-height: 1.3;
margin-top: 2px;
margin-top: 4px;
}
`,
applicationSwitcherButton: css`
width: 44px;
height: 44px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
border: 1px solid rgba(251, 113, 133, 0.34);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.06)
);
color: #fb7185;
cursor: pointer;
box-shadow: 0 12px 22px rgba(159, 18, 57, 0.14);
transition: transform 0.2s ease, border-color 0.2s ease,
background 0.2s ease, color 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(251, 113, 133, 0.72);
background: rgba(251, 113, 133, 0.14);
color: #fecdd3;
}
&:focus-visible {
outline: 2px solid rgba(251, 113, 133, 0.58);
outline-offset: 2px;
}
`,
contentShell: css`
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.58);
position: relative;
background: rgba(255, 255, 255, 0.64);
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(18px);
border-radius: 32px;
overflow: hidden;
`,
toolbar: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 12px;
flex-wrap: wrap;
`,
toolbarTitle: css`
min-width: 0;
h2 {
margin: 0;
font-size: 24px;
line-height: 1.25;
color: #0f172a;
}
p {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
`,
toolbarActions: css`
display: flex;
align-items: center;
gap: 10px;
`,
mobileMenuButton: css`
display: none;
${responsive.lg} {
display: inline-flex;
position: absolute;
top: 14px;
right: 14px;
z-index: 4;
}
`,
mobileOnly: css`
......@@ -379,7 +491,7 @@ const useStyle = createStyles(({ token, css, responsive }) => {
body: css`
flex: 1;
min-height: 0;
padding: 0 20px 20px;
padding: 0 20px 16px;
${responsive.xs} {
padding: 0 12px 12px;
......@@ -388,12 +500,17 @@ const useStyle = createStyles(({ token, css, responsive }) => {
chatStage: css`
height: 100%;
border-radius: 28px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.82) 0%,
rgba(248, 250, 252, 0.94) 100%
);
border: 1px solid rgba(148, 163, 184, 0.18);
background: radial-gradient(
circle at top center,
rgba(251, 207, 232, 0.28),
transparent 22%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.92) 0%,
rgba(255, 247, 247, 0.98) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
overflow: hidden;
`,
......@@ -424,6 +541,15 @@ function buildConversationItems(conversations = []) {
}));
}
function buildCompactConversationItems(conversations = []) {
return conversations.slice(0, 6).map((c) => ({
key: c.name,
label: c.metadata.label || '新会话',
icon: c.metadata.topic ? <BorderlessTableOutlined /> : <CommentOutlined />,
pinned: c.metadata.pinned,
}));
}
export default () => {
const { token } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
......@@ -440,6 +566,7 @@ export default () => {
const [renamingConversationLabel, setRenamingConversationLabel] =
useState(null);
const [renamingModalOpen, setRenamingModalOpen] = useState(false);
const [applicationSwitcherOpen, setApplicationSwitcherOpen] = useState(false);
const [loading, setLoading] = useState(false);
const chatRef = useRef(null);
......@@ -494,30 +621,14 @@ export default () => {
};
}, []);
const currentTitle = useMemo(() => {
if (!currentConversation) {
return '智能工作台';
}
return currentConversation.metadata?.label || '当前会话';
}, [currentConversation]);
const currentSubtitle = useMemo(() => {
if (!currentConversation) {
return '统一承载知识问答、智能体协作、附件分析与业务流程型对话。';
}
if (currentConversation.metadata?.topic?.title) {
return `当前话题:${currentConversation.metadata.topic.title}`;
}
return '保持连续上下文,支持流式回复、知识检索与工具调用。';
}, [currentConversation]);
const conversationItems = useMemo(
() => buildConversationItems(conversations),
[conversations],
);
const compactConversationItems = useMemo(
() => buildCompactConversationItems(conversations),
[conversations],
);
const openConversation = async (name) => {
const conversation = await getRecord(name);
......@@ -583,15 +694,15 @@ export default () => {
const sidebarContent = (
<div className={cx(styles.sidebar, collapsed && styles.sidebarCollapsed)}>
<div className={styles.sidebarHeader}>
<div className={styles.brand}>
<div className={styles.brandMark}>AI</div>
{!collapsed && (
{!collapsed && (
<div className={styles.brand}>
<div className={styles.brandMark}>AI</div>
<div className={styles.brandText}>
<h1>智能助手</h1>
<p>面向业务场景的统一 AI 对话工作台</p>
</div>
)}
</div>
</div>
)}
{!isMobile && (
<Button
type="text"
......@@ -613,70 +724,130 @@ export default () => {
{!collapsed ? '新建会话' : null}
</Button>
{!collapsed && <div className={styles.sectionLabel}>Conversations</div>}
{conversations?.length > 0 ? (
<Conversations
items={conversationItems}
className={styles.conversations}
activeKey={currentConversation?.name}
onActiveChange={openConversation}
groupable
styles={{
item: {
padding: collapsed ? '0 4px' : '0 8px',
},
}}
menu={conversationMenu}
/>
{!collapsed ? (
<>
{conversations?.length > 0 ? (
<Conversations
items={conversationItems}
className={styles.conversations}
activeKey={currentConversation?.name}
onActiveChange={openConversation}
groupable
styles={{
item: {
padding: '0 8px',
},
}}
menu={conversationMenu}
/>
) : (
<div className={styles.conversations}>
<Empty
description="还没有历史会话"
image={Empty.PRESENTED_IMAGE_SIMPLE}
className={styles.emptyHistory}
/>
</div>
)}
</>
) : (
<div className={styles.conversations}>
<Empty
description={collapsed ? '' : '还没有历史会话'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
className={styles.emptyHistory}
/>
<div className={styles.compactNav}>
{compactConversationItems.length > 0 ? (
compactConversationItems.map((item) => (
<Tooltip key={item.key} title={item.label} placement="right">
<div className={styles.compactNavItem}>
<div
className={cx(
styles.compactNavButton,
currentConversation?.name === item.key &&
styles.compactNavButtonActive,
)}
onClick={() => openConversation(item.key)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openConversation(item.key);
}
}}
>
{item.icon}
{item.pinned ? (
<span className={styles.compactNavPin} />
) : null}
</div>
</div>
</Tooltip>
))
) : (
<Tooltip title="还没有历史会话" placement="right">
<div className={styles.compactNavItem}>
<div className={styles.compactNavButton}>
<CommentOutlined />
</div>
</div>
</Tooltip>
)}
</div>
)}
<div className={styles.sidebarFooter}>
{userInfo && (
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'logout',
label: '退出登录',
icon: <PoweroffOutlined />,
onClick: () => {
localStorage.removeItem('current_user');
setUserInfo(null);
},
},
],
}}
<div
className={cx(
styles.accountCard,
collapsed && styles.accountCardCollapsed,
)}
>
<div className={styles.accountCard}>
<Avatar
size={collapsed ? 40 : 42}
src={userInfo?.avatar}
style={{
background: '#e0f2fe',
color: token.colorPrimary,
flexShrink: 0,
}}
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'logout',
label: '退出登录',
icon: <PoweroffOutlined />,
onClick: () => {
localStorage.removeItem('current_user');
setUserInfo(null);
},
},
],
}}
>
<div className={styles.accountIdentity}>
<Avatar
size={collapsed ? 40 : 42}
src={userInfo?.avatar}
style={{
background: '#e0f2fe',
color: token.colorPrimary,
flexShrink: 0,
}}
>
{userInfo.display_name?.[0]}
</Avatar>
{!collapsed && (
<div className={styles.accountMeta}>
<strong>{userInfo.display_name || '当前用户'}</strong>
<span>账户设置与登录状态</span>
</div>
)}
</div>
</Dropdown>
<Tooltip title="应用切换" placement={collapsed ? 'right' : 'top'}>
<button
type="button"
aria-label="应用切换"
className={styles.applicationSwitcherButton}
onClick={() => setApplicationSwitcherOpen(true)}
>
{userInfo.display_name?.[0]}
</Avatar>
{!collapsed && (
<div className={styles.accountMeta}>
<strong>{userInfo.display_name || '当前用户'}</strong>
<span>账户设置与登录状态</span>
</div>
)}
</div>
</Dropdown>
<AppstoreOutlined />
</button>
</Tooltip>
</div>
)}
</div>
</div>
......@@ -700,22 +871,14 @@ export default () => {
</Drawer>
<div className={styles.contentShell}>
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<Typography.Title level={2}>{currentTitle}</Typography.Title>
<Typography.Paragraph>{currentSubtitle}</Typography.Paragraph>
</div>
<div className={styles.toolbarActions}>
<Button
type="default"
icon={<MenuUnfoldOutlined />}
onClick={() => setMobileOpen(true)}
className={styles.mobileMenuButton}
>
菜单
</Button>
</div>
</div>
<Button
type="default"
icon={<MenuUnfoldOutlined />}
onClick={() => setMobileOpen(true)}
className={styles.mobileMenuButton}
>
菜单
</Button>
<div className={styles.body}>
<div className={styles.chatStage}>
......@@ -775,6 +938,12 @@ export default () => {
/>
</Modal>
<ApplicationSwitcherDialog
open={applicationSwitcherOpen}
onClose={() => setApplicationSwitcherOpen(false)}
userInfo={userInfo}
/>
<LoginModal
open={!userInfo}
onSuccess={(currentUser) => {
......
......@@ -12,6 +12,18 @@ export function getApplication(name) {
});
}
export function getSystemInfo() {
return request('/api/info', {
method: 'get',
});
}
export function getApplications() {
return request('/api/application/info', {
method: 'get',
});
}
export function getRecord(name) {
return request(`/api/record/${name}`, {
method: 'get',
......@@ -50,13 +62,21 @@ export function getUserInfo() {
return currentUser && JSON.parse(currentUser);
}
export async function login(username, password) {
export function getCaptcha() {
return request('/api/auth/captcha', {
method: 'get',
});
}
export async function login(username, password, captcha, token) {
await request('/api/auth/login', {
method: 'post',
data: {
type: 'password',
identity: username,
password: password,
captcha,
token,
},
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment