feat: 完善智慧停车综合管理平台源码
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClearOutlined,
|
||||
RobotOutlined,
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
StopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './index.less';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AiSettings {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
// v2:服务商切换为 TokenFree,旧版本地缓存不再复用,避免过期默认值残留
|
||||
const SETTINGS_KEY = 'parking-ai-settings-v2';
|
||||
const LEGACY_SETTINGS_KEY = 'parking-ai-settings';
|
||||
const MESSAGES_KEY = 'parking-ai-messages';
|
||||
|
||||
const PROVIDER_HOME = 'https://www.tokenfree.com/';
|
||||
|
||||
const DEFAULT_SETTINGS: AiSettings = {
|
||||
baseUrl: 'https://www.tokenfree.com/v1',
|
||||
apiKey: '',
|
||||
model: 'kimi-k2.6',
|
||||
};
|
||||
|
||||
const loadSettings = (): AiSettings => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
// 仅迁移用户在旧服务商下已填写的 API Key,地址/模型强制使用新默认值
|
||||
const legacyRaw = localStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyRaw) {
|
||||
const legacy = JSON.parse(legacyRaw) as Partial<AiSettings>;
|
||||
const migrated = {
|
||||
...DEFAULT_SETTINGS,
|
||||
apiKey: typeof legacy.apiKey === 'string' ? legacy.apiKey : '',
|
||||
};
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
};
|
||||
|
||||
const createMessage = (
|
||||
role: ChatMessage['role'],
|
||||
content: string,
|
||||
): ChatMessage => ({
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
role,
|
||||
content,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
});
|
||||
|
||||
const AiChat: FC = () => {
|
||||
const [settings, setSettings] = useState<AiSettings>(loadSettings);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(MESSAGES_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsForm] = Form.useForm<AiSettings>();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const normalizedBaseUrl = useMemo(
|
||||
() => settings.baseUrl.replace(/\/+$/, ''),
|
||||
[settings.baseUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'AI对话 - 智慧停车管理平台';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(MESSAGES_KEY, JSON.stringify(messages));
|
||||
listRef.current?.scrollTo({
|
||||
top: listRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
const values = await settingsForm.validateFields();
|
||||
const next = {
|
||||
...values,
|
||||
baseUrl: values.baseUrl.replace(/\/+$/, ''),
|
||||
};
|
||||
setSettings(next);
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
|
||||
setSettingsOpen(false);
|
||||
message.success('AI 接口配置已保存');
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
const values = settingsForm.getFieldsValue();
|
||||
const baseUrl = values.baseUrl.replace(/\/+$/, '');
|
||||
if (!baseUrl) {
|
||||
message.warning('请先填写 API Base URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/models`, {
|
||||
headers: { Authorization: `Bearer ${values.apiKey}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
message.success('连接测试成功');
|
||||
} catch (error) {
|
||||
message.error(`连接失败:${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const stopGenerating = () => {
|
||||
abortRef.current?.abort();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
if (!settings.apiKey) {
|
||||
setSettingsOpen(true);
|
||||
message.warning('请先配置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = createMessage('user', content);
|
||||
const assistantMessage = createMessage('assistant', '');
|
||||
const nextMessages = [...messages, userMessage, assistantMessage];
|
||||
setMessages(nextMessages);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const payload = {
|
||||
model: settings.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是智慧停车管理平台AI助手,可解答停车运营、设备状态、异常报警、园区监控和数据分析相关问题。',
|
||||
},
|
||||
...messages
|
||||
.filter((item) => item.role !== 'system')
|
||||
.map((item) => ({ role: item.role, content: item.content })),
|
||||
{ role: 'user', content },
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedBaseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(response.statusText || 'AI 服务响应异常');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let answer = '';
|
||||
|
||||
const patchLastMessage = (text: string) => {
|
||||
setMessages((current) =>
|
||||
current.map((item, index) =>
|
||||
index === current.length - 1 ? { ...item, content: text } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() || '';
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
chunk
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.forEach((line) => {
|
||||
const data = line.replace(/^data:\s*/, '').trim();
|
||||
if (!data || data === '[DONE]') return;
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json.choices?.[0]?.delta?.content || '';
|
||||
answer += delta;
|
||||
patchLastMessage(answer);
|
||||
} catch {
|
||||
// 忽略心跳或非标准分片
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!answer) patchLastMessage('暂未收到有效回复,请稍后重试。');
|
||||
} catch (error) {
|
||||
const text =
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? answerOrStopped()
|
||||
: `AI 服务连接失败:${String(error)}`;
|
||||
setMessages((current) =>
|
||||
current.map((item, index) =>
|
||||
index === current.length - 1 ? { ...item, content: text } : item,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const answerOrStopped = () => '已停止生成。';
|
||||
|
||||
const clearHistory = () => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(MESSAGES_KEY);
|
||||
message.success('对话记录已清空');
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
settingsForm.setFieldsValue(settings);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-page">
|
||||
<div className="ai-chat-shell panel-card">
|
||||
<div className="ai-chat-header">
|
||||
<div>
|
||||
<Space>
|
||||
<Avatar
|
||||
className="ai-robot-avatar"
|
||||
icon={<RobotOutlined />}
|
||||
size="large"
|
||||
/>
|
||||
<div>
|
||||
<h2>智慧停车 AI 助手</h2>
|
||||
<p>
|
||||
<Tag color="cyan">{settings.model}</Tag>
|
||||
<span className="ai-status-dot" />
|
||||
流式对话已就绪
|
||||
</p>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<Space>
|
||||
<Tooltip title="接口配置">
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
onClick={openSettings}
|
||||
shape="circle"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="清空对话">
|
||||
<Button
|
||||
danger
|
||||
icon={<ClearOutlined />}
|
||||
onClick={clearHistory}
|
||||
shape="circle"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!settings.apiKey && (
|
||||
<Alert
|
||||
className="ai-config-alert"
|
||||
message="尚未配置 API Key"
|
||||
description="默认已接入 TokenFree(标准 Chat Completions 接口),请点击右上角设置按钮填写您的 API Key 后即可开始对话。"
|
||||
type="warning"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" type="primary" onClick={openSettings}>
|
||||
立即配置
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="ai-message-list" ref={listRef}>
|
||||
{messages.length === 0 ? (
|
||||
<Empty
|
||||
description="开始询问停车运营、设备状态或异常报警问题"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
messages.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`ai-message-row ${
|
||||
item.role === 'user' ? 'is-user' : 'is-assistant'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
className="ai-message-avatar"
|
||||
icon={
|
||||
item.role === 'user' ? <UserOutlined /> : <RobotOutlined />
|
||||
}
|
||||
/>
|
||||
<div className="ai-bubble">
|
||||
<div className="ai-bubble-content">
|
||||
{item.content || (loading ? <Spin size="small" /> : '')}
|
||||
</div>
|
||||
<div className="ai-bubble-time">{item.createdAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ai-input-bar">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
className="ai-textarea"
|
||||
placeholder="输入你的问题,Enter 发送,Shift + Enter 换行"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{loading ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={stopGenerating}
|
||||
size="large"
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => void sendMessage()}
|
||||
size="large"
|
||||
type="primary"
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
className="ai-settings-drawer"
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined />
|
||||
AI 接口配置
|
||||
</Space>
|
||||
}
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
width={380}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={testConnection}>测试连接</Button>
|
||||
<Button type="primary" onClick={() => void saveSettings()}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="api-key-tip">
|
||||
<p className="api-key-tip-title">
|
||||
<strong>TokenFree 使用教程</strong>
|
||||
</p>
|
||||
<p className="api-key-tip-sub">
|
||||
TokenFree 是统一 AI API 网关,支持标准 Chat Completions 接口协议
|
||||
(流式输出 / Models 列表),注册后即可调用
|
||||
Kimi、Qwen、Claude 等多家模型。
|
||||
</p>
|
||||
<ol className="api-key-steps">
|
||||
<li>
|
||||
打开{' '}
|
||||
<a href={PROVIDER_HOME} target="_blank" rel="noreferrer">
|
||||
<strong>https://www.tokenfree.com/</strong>
|
||||
</a>
|
||||
,点击注册并完成邮箱验证后登录。
|
||||
</li>
|
||||
<li>
|
||||
登录后进入控制台「令牌 / API Keys」页面,点击「添加令牌」,
|
||||
复制生成的 Key(以 <code>sk-</code> 开头)。
|
||||
</li>
|
||||
<li>
|
||||
将 API Key 粘贴到下方表单;接口地址与默认模型已预填,
|
||||
一般无需修改。
|
||||
</li>
|
||||
<li>
|
||||
点击「测试连接」验证 Key 是否有效,通过后点击「保存」,
|
||||
返回页面即可开始对话。
|
||||
</li>
|
||||
</ol>
|
||||
<p className="api-key-tip-meta">
|
||||
<span>接口地址:<code>https://www.tokenfree.com/v1</code></span>
|
||||
<span>默认模型:<code>kimi-k2.6</code></span>
|
||||
</p>
|
||||
<p className="api-key-tip-note">
|
||||
提示:也可在「模型 / Model」处填写 TokenFree 支持的其他模型名称;
|
||||
Key 仅保存在本地浏览器,不会上传到本系统服务器。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" form={settingsForm}>
|
||||
<Form.Item
|
||||
label="API Base URL"
|
||||
name="baseUrl"
|
||||
rules={[{ required: true, message: '请输入 API Base URL' }]}
|
||||
extra="默认:https://www.tokenfree.com/v1(标准 Chat Completions 接口,一般无需修改)"
|
||||
>
|
||||
<Input placeholder="https://www.tokenfree.com/v1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name="apiKey"
|
||||
rules={[{ required: true, message: '请输入 API Key' }]}
|
||||
extra="在 TokenFree 控制台「令牌」页面创建并复制"
|
||||
>
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Model"
|
||||
name="model"
|
||||
rules={[{ required: true, message: '请输入模型名称' }]}
|
||||
extra="默认 kimi-k2.6,也可填写 TokenFree 支持的其他模型"
|
||||
>
|
||||
<Input placeholder="kimi-k2.6" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChat;
|
||||
Reference in New Issue
Block a user