feat: 添加三层级模型配置管理系统原型

新增三个层级的模型配置管理功能:
- 平台级模型配置(管理台):配置列表、新增/编辑、删除(默认模型不允许删除)、设为默认(仅页面状态)
- 项目级模型配置(工作台):配置列表、新增/编辑、删除(允许删除默认)、设为默认(仅页面状态)
- 个人模型配置(工作台):配置列表、新增/编辑、删除(允许删除默认)、设为默认(仅页面状态)
- 融合式模型选择器:在聊天输入框顶部集成,按层级分组展示模型列表(平台/项目/个人)

技术实现:
- 新增项目级和个人级配置数据文件
- 扩展 api.js 数据访问层,添加 consoleModels.project 和 consoleModels.user 对象
- 新增 4 个页面组件(ProjectModelConfigsPage、AddProjectModelConfigPage、UserModelConfigsPage、AddUserModelConfigPage)
- 修改 2 个现有页面(ModelConfigsPage、ChatPage、ConsoleLayout)
- 修改 Modal 组件支持 cancelText 为空时隐藏取消按钮
- 在 App.jsx 中添加 6 条新路由
- 新增模型选择器样式文件(融合式设计、分组展示、响应式)
- 更新 README.md 项目结构

样式特点:
- 融合式模型选择器与输入框风格一致
- 下拉列表按层级分组(平台/项目/个人)
- 默认标记使用渐变背景色
- 选中状态高亮(浅蓝色背景 + 左侧边框 + 右侧勾选)
- 响应式设计(移动端适配)

数据示例:
- 项目级:3 个示例配置(不同类型和状态)
- 个人级:2 个示例配置(不同类型和状态)
This commit is contained in:
2026-04-10 13:43:19 +08:00
parent eeef824b24
commit 3f815db0b2
21 changed files with 1836 additions and 18 deletions

View File

@@ -23,6 +23,10 @@ import PermissionsPage from './pages/console/PermissionsPage.jsx';
import SkillsConfigPage from './pages/console/SkillsConfigPage.jsx';
import ConsoleReviewListPage from './pages/console/ConsoleReviewListPage.jsx';
import ConsoleReviewDetailPage from './pages/console/ConsoleReviewDetailPage.jsx';
import ProjectModelConfigsPage from './pages/console/ProjectModelConfigsPage.jsx';
import AddProjectModelConfigPage from './pages/console/AddProjectModelConfigPage.jsx';
import UserModelConfigsPage from './pages/console/UserModelConfigsPage.jsx';
import AddUserModelConfigPage from './pages/console/AddUserModelConfigPage.jsx';
// Admin 子页面
import OverviewPage from './pages/admin/OverviewPage.jsx';
@@ -71,6 +75,12 @@ function App() {
<Route path="project/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project/permissions" element={<PermissionsPage />} />
<Route path="project/skills" element={<SkillsConfigPage />} />
<Route path="project/models" element={<ProjectModelConfigsPage />} />
<Route path="project/models/add" element={<AddProjectModelConfigPage />} />
<Route path="project/models/:id/edit" element={<AddProjectModelConfigPage />} />
<Route path="user-models" element={<UserModelConfigsPage />} />
<Route path="user-models/add" element={<AddUserModelConfigPage />} />
<Route path="user-models/:id/edit" element={<AddUserModelConfigPage />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>

View File

@@ -27,7 +27,7 @@ function Modal({
</div>
{showConfirm && (
<div className="modal-footer">
<button className="btn" onClick={onCancel}>{cancelText}</button>
{cancelText && <button className="btn" onClick={onCancel}>{cancelText}</button>}
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { FiPlus, FiClock, FiList, FiUsers, FiBox, FiFolder, FiShield, FiSettings } from 'react-icons/fi';
import { FiPlus, FiClock, FiList, FiUsers, FiBox, FiFolder, FiShield, FiSettings, FiCpu } from 'react-icons/fi';
import { FiTrash2 } from 'react-icons/fi';
import { FaPuzzlePiece } from 'react-icons/fa';
import Layout from '../Layout.jsx';
@@ -96,6 +96,12 @@ function ConsoleLayout() {
active={isPathActive('/console/tasks')}
onClick={() => navigate('/console/tasks')}
/>
<SidebarNavItem
icon={<FiCpu />}
label="个人模型"
active={isPathActive('/console/user-models')}
onClick={() => navigate('/console/user-models')}
/>
<SidebarNavItem
icon={<FiList />}
label="日志查询"
@@ -125,6 +131,12 @@ function ConsoleLayout() {
active={isPathActive('/console/project/skills')}
onClick={() => navigate('/console/project/skills')}
/>
<SidebarNavItem
icon={<FiCpu />}
label="模型配置"
active={isPathActive('/console/project/models')}
onClick={() => navigate('/console/project/models')}
/>
</SidebarNavGroup>
</div>
<Modal

View File

@@ -0,0 +1,47 @@
export const projectModelConfigs = [
{
id: 'proj_001',
name: '智算平台生产环境',
type: 'zhisuan',
isActive: true,
createdAt: '2026-03-25T09:00:00',
updatedAt: '2026-03-25T09:00:00',
zhisuan: {
apiUrl: 'https://zhisuan.internal.company.com/api/v1',
appId: 'app_prod_001',
appSecret: 'secret_prod_xyz123abc'
}
},
{
id: 'proj_002',
name: 'DeepSeek 项目专用',
type: 'basic',
isActive: false,
createdAt: '2026-03-26T14:00:00',
updatedAt: '2026-03-26T14:00:00',
basic: {
apiUrl: 'https://api.deepseek.com/v1',
apiKey: 'sk-proj-deepseek789xyz',
modelName: 'deepseek-coder',
temperature: 0.3,
maxTokens: 8192,
topP: 0.95
}
},
{
id: 'proj_003',
name: '通义千问代码助手',
type: 'basic',
isActive: false,
createdAt: '2026-03-27T11:00:00',
updatedAt: '2026-03-27T11:00:00',
basic: {
apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'sk-proj-qwen456def',
modelName: 'qwen-coder-plus',
temperature: 0.5,
maxTokens: 16384,
topP: 0.8
}
}
];

View File

@@ -0,0 +1,34 @@
export const userModelConfigs = [
{
id: 'user_001',
name: '我的 GPT-4',
type: 'basic',
isActive: true,
createdAt: '2026-03-26T10:00:00',
updatedAt: '2026-03-26T10:00:00',
basic: {
apiUrl: 'https://api.openai.com/v1',
apiKey: 'sk-user-abc123xyz456',
modelName: 'gpt-4o',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9
}
},
{
id: 'user_002',
name: 'Claude 备用',
type: 'basic',
isActive: false,
createdAt: '2026-03-28T16:00:00',
updatedAt: '2026-03-28T16:00:00',
basic: {
apiUrl: 'https://api.anthropic.com/v1',
apiKey: 'sk-user-claude789abc',
modelName: 'claude-3-sonnet',
temperature: 0.8,
maxTokens: 8192,
topP: 0.9
}
}
];

View File

@@ -10,6 +10,7 @@ function ModelConfigsPage() {
const [configs, setConfigs] = useState(api.admin.modelConfigs.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDeleteBlockedModal, setShowDeleteBlockedModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
@@ -19,14 +20,22 @@ function ModelConfigsPage() {
const handleSetActiveConfirm = () => {
if (selectedConfig) {
api.admin.modelConfigs.setActive(selectedConfig.id);
setConfigs([...api.admin.modelConfigs.list()]);
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
if (config.isActive) {
setSelectedConfig(config);
setShowDeleteBlockedModal(true);
return;
}
setSelectedConfig(config);
setShowDeleteModal(true);
};
@@ -42,7 +51,6 @@ function ModelConfigsPage() {
return (
<div className="model-configs-page">
{/* 配置列表 */}
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">配置列表</div>
@@ -86,17 +94,12 @@ function ModelConfigsPage() {
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/admin/models/${config.id}/edit`)}
disabled={config.isActive}
title={config.isActive ? '生效中的配置不可编辑' : ''}
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
disabled={config.isActive}
title={config.isActive ? '生效中的配置不可删除' : ''}
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
删除
@@ -111,7 +114,6 @@ function ModelConfigsPage() {
</div>
</div>
{/* 设为默认确认弹窗 */}
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
@@ -127,7 +129,6 @@ function ModelConfigsPage() {
<p>切换后原生效配置将变为备用状态</p>
</Modal>
{/* 删除确认弹窗 */}
<Modal
visible={showDeleteModal}
title="确认删除"
@@ -142,6 +143,24 @@ function ModelConfigsPage() {
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
<Modal
visible={showDeleteBlockedModal}
title="无法删除"
onConfirm={() => {
setShowDeleteBlockedModal(false);
setSelectedConfig(null);
}}
onCancel={() => {
setShowDeleteBlockedModal(false);
setSelectedConfig(null);
}}
confirmText="我知道了"
cancelText=""
>
<p>平台默认模型不允许删除</p>
<p>请先将另一个配置设为默认然后再删除此配置</p>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES, getConfigFields, getConfigTypeList } from '../../data/configTypes.js';
function AddProjectModelConfigPage() {
const { id } = useParams();
const navigate = useNavigate();
const editData = id ? api.consoleModels.project.getById(id) : null;
const isEdit = !!editData;
const [configName, setConfigName] = useState('');
const [configType, setConfigType] = useState('basic');
const [fieldValues, setFieldValues] = useState({});
const [showPasswords, setShowPasswords] = useState({});
useEffect(() => {
if (editData) {
setConfigName(editData.name || '');
setConfigType(editData.type || 'basic');
const typeData = editData[editData.type] || {};
const initialValues = {};
const fields = getConfigFields(editData.type);
fields.forEach(field => {
initialValues[field.key] = typeData[field.key] ?? field.default ?? '';
});
setFieldValues(initialValues);
} else {
const fields = getConfigFields('basic');
const initialValues = {};
fields.forEach(field => {
initialValues[field.key] = field.default ?? '';
});
setFieldValues(initialValues);
}
}, [editData]);
const handleTypeChange = (newType) => {
if (isEdit) return;
setConfigType(newType);
const fields = getConfigFields(newType);
const newValues = {};
fields.forEach(field => {
newValues[field.key] = field.default ?? '';
});
setFieldValues(newValues);
};
const handleFieldChange = (key, value) => {
setFieldValues(prev => ({ ...prev, [key]: value }));
};
const togglePasswordVisibility = (key) => {
setShowPasswords(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
const typeData = {};
const fields = getConfigFields(configType);
fields.forEach(field => {
let value = fieldValues[field.key];
if (field.type === 'number' && value !== '') {
value = Number(value);
}
typeData[field.key] = value;
});
const configData = {
name: configName.trim(),
type: configType,
[configType]: typeData
};
if (isEdit) {
api.consoleModels.project.update(editData.id, configData);
} else {
api.consoleModels.project.create(configData);
}
navigate('/console/project/models');
};
const currentFields = getConfigFields(configType);
const configTypeList = getConfigTypeList();
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/project/models')}>
<span></span>
<span>返回配置列表</span>
</div>
<div className="card">
<div className="card-header">
<div className="card-title">{isEdit ? '编辑配置' : '新增配置'}</div>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label required">配置名称</label>
<input
type="text"
className="form-control"
placeholder="请输入配置名称"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label required">配置类型</label>
<select
className="form-control"
value={configType}
onChange={(e) => handleTypeChange(e.target.value)}
disabled={isEdit}
>
{configTypeList.map(type => (
<option key={type.key} value={type.key}>
{type.label}
</option>
))}
</select>
{isEdit && (
<div style={{ fontSize: '12px', color: '#6B7280', marginTop: '4px' }}>
配置类型不可修改
</div>
)}
</div>
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#111827', marginBottom: '16px' }}>
{MODEL_CONFIG_TYPES[configType]?.label} 配置
</div>
{currentFields.map(field => (
<div key={field.key} className="form-group">
<label className={`form-label ${field.required ? 'required' : ''}`}>
{field.label}
</label>
{field.type === 'password' ? (
<div style={{ position: 'relative' }}>
<input
type={showPasswords[field.key] ? 'text' : 'password'}
className="form-control"
style={{ paddingRight: '40px' }}
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
color: '#6B7280'
}}
>
{showPasswords[field.key] ? <FiEyeOff size={16} /> : <FiEye size={16} />}
</button>
</div>
) : field.type === 'number' ? (
<input
type="number"
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
min={field.min}
max={field.max}
step={field.step}
/>
) : (
<input
type={field.type === 'url' ? 'url' : 'text'}
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button className="btn" onClick={() => navigate('/console/project/models')}>取消</button>
<button className="btn btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
</>
);
}
export default AddProjectModelConfigPage;

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES, getConfigFields, getConfigTypeList } from '../../data/configTypes.js';
function AddUserModelConfigPage() {
const { id } = useParams();
const navigate = useNavigate();
const editData = id ? api.consoleModels.user.getById(id) : null;
const isEdit = !!editData;
const [configName, setConfigName] = useState('');
const [configType, setConfigType] = useState('basic');
const [fieldValues, setFieldValues] = useState({});
const [showPasswords, setShowPasswords] = useState({});
useEffect(() => {
if (editData) {
setConfigName(editData.name || '');
setConfigType(editData.type || 'basic');
const typeData = editData[editData.type] || {};
const initialValues = {};
const fields = getConfigFields(editData.type);
fields.forEach(field => {
initialValues[field.key] = typeData[field.key] ?? field.default ?? '';
});
setFieldValues(initialValues);
} else {
const fields = getConfigFields('basic');
const initialValues = {};
fields.forEach(field => {
initialValues[field.key] = field.default ?? '';
});
setFieldValues(initialValues);
}
}, [editData]);
const handleTypeChange = (newType) => {
if (isEdit) return;
setConfigType(newType);
const fields = getConfigFields(newType);
const newValues = {};
fields.forEach(field => {
newValues[field.key] = field.default ?? '';
});
setFieldValues(newValues);
};
const handleFieldChange = (key, value) => {
setFieldValues(prev => ({ ...prev, [key]: value }));
};
const togglePasswordVisibility = (key) => {
setShowPasswords(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
const typeData = {};
const fields = getConfigFields(configType);
fields.forEach(field => {
let value = fieldValues[field.key];
if (field.type === 'number' && value !== '') {
value = Number(value);
}
typeData[field.key] = value;
});
const configData = {
name: configName.trim(),
type: configType,
[configType]: typeData
};
if (isEdit) {
api.consoleModels.user.update(editData.id, configData);
} else {
api.consoleModels.user.create(configData);
}
navigate('/console/user-models');
};
const currentFields = getConfigFields(configType);
const configTypeList = getConfigTypeList();
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/user-models')}>
<span></span>
<span>返回配置列表</span>
</div>
<div className="card">
<div className="card-header">
<div className="card-title">{isEdit ? '编辑配置' : '新增配置'}</div>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label required">配置名称</label>
<input
type="text"
className="form-control"
placeholder="请输入配置名称"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label required">配置类型</label>
<select
className="form-control"
value={configType}
onChange={(e) => handleTypeChange(e.target.value)}
disabled={isEdit}
>
{configTypeList.map(type => (
<option key={type.key} value={type.key}>
{type.label}
</option>
))}
</select>
{isEdit && (
<div style={{ fontSize: '12px', color: '#6B7280', marginTop: '4px' }}>
配置类型不可修改
</div>
)}
</div>
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#111827', marginBottom: '16px' }}>
{MODEL_CONFIG_TYPES[configType]?.label} 配置
</div>
{currentFields.map(field => (
<div key={field.key} className="form-group">
<label className={`form-label ${field.required ? 'required' : ''}`}>
{field.label}
</label>
{field.type === 'password' ? (
<div style={{ position: 'relative' }}>
<input
type={showPasswords[field.key] ? 'text' : 'password'}
className="form-control"
style={{ paddingRight: '40px' }}
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
color: '#6B7280'
}}
>
{showPasswords[field.key] ? <FiEyeOff size={16} /> : <FiEye size={16} />}
</button>
</div>
) : field.type === 'number' ? (
<input
type="number"
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
min={field.min}
max={field.max}
step={field.step}
/>
) : (
<input
type={field.type === 'url' ? 'url' : 'text'}
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button className="btn" onClick={() => navigate('/console/user-models')}>取消</button>
<button className="btn btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
</>
);
}
export default AddUserModelConfigPage;

View File

@@ -1,7 +1,99 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getChatScenes } from '../../data/conversations.js';
import { FiPaperclip, FiCode, FiSend } from 'react-icons/fi';
import { FiPaperclip, FiCode, FiSend, FiChevronDown } from 'react-icons/fi';
import { api } from '../../services/api.js';
function ModelSelector({ selectedModel, onSelectModel }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const platformModels = api.admin.modelConfigs.list().map(c => ({
id: c.id,
name: c.name,
level: 'platform',
isDefault: c.isActive,
}));
const projectModels = api.consoleModels.project.list().map(c => ({
id: c.id,
name: c.name,
level: 'project',
isDefault: c.isActive,
}));
const userModels = api.consoleModels.user.list().map(c => ({
id: c.id,
name: c.name,
level: 'user',
isDefault: c.isActive,
}));
const groups = [
{ key: 'platform', title: '平台模型', models: platformModels },
{ key: 'project', title: '项目模型', models: projectModels },
{ key: 'user', title: '个人模型', models: userModels },
];
useEffect(() => {
const handleClickOutside = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (model) => {
onSelectModel(model);
setOpen(false);
};
return (
<div className={`model-selector ${open ? 'model-selector--open' : ''}`} ref={ref}>
<div
className="model-selector__trigger"
onClick={() => setOpen(!open)}
>
<div className="model-selector__content">
<span className="model-selector__icon">
<FiCode size={12} />
</span>
<span className="model-selector__name">{selectedModel?.name || '选择模型'}</span>
{selectedModel?.isDefault && (
<span className="model-selector__tag">默认</span>
)}
</div>
<span className={`model-selector__arrow ${open ? 'model-selector__arrow--open' : ''}`}>
<FiChevronDown size={14} />
</span>
</div>
{open && (
<div className="model-selector__dropdown">
{groups.map(group => (
<div key={group.key} className="model-selector__group">
<div className="model-selector__group-title">{group.title}</div>
{group.models.map(model => (
<div
key={model.id}
className={`model-selector__item ${selectedModel?.id === model.id ? 'model-selector__item--selected' : ''}`}
onClick={() => handleSelect(model)}
>
<span className="model-selector__item-text">{model.name}</span>
{model.isDefault && (
<span className="model-selector__item-tag">默认</span>
)}
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function ChatPage() {
const { scene } = useParams();
@@ -10,6 +102,17 @@ function ChatPage() {
const html = chatScenes[currentScene] || '';
const chatMessagesRef = useRef(null);
const defaultPlatformModel = api.admin.modelConfigs.list().find(c => c.isActive);
const defaultProjectModel = api.consoleModels.project.list().find(c => c.isActive);
const defaultUserModel = api.consoleModels.user.list().find(c => c.isActive);
const initialModel = defaultUserModel || defaultProjectModel || defaultPlatformModel;
const [selectedModel, setSelectedModel] = useState(() => {
if (!initialModel) return null;
const level = defaultUserModel ? 'user' : (defaultProjectModel ? 'project' : 'platform');
return { id: initialModel.id, name: initialModel.name, level, isDefault: true };
});
useEffect(() => {
if (!chatMessagesRef.current) return;
@@ -30,7 +133,7 @@ function ChatPage() {
el.removeEventListener('click', handleClick);
});
};
}, [scene, html]); // 依赖场景和html内容
}, [scene, html]);
return (
<div className="chat-layout">
@@ -41,6 +144,10 @@ function ChatPage() {
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box">
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
/>
<div className="chat-input-main">
<textarea
className="chat-input"
@@ -69,4 +176,4 @@ function ChatPage() {
);
}
export default ChatPage;
export default ChatPage;

View File

@@ -0,0 +1,143 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiPlus } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES } from '../../data/configTypes.js';
import Modal from '../../components/common/Modal.jsx';
function ProjectModelConfigsPage() {
const navigate = useNavigate();
const [configs, setConfigs] = useState(api.consoleModels.project.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
setSelectedConfig(config);
setShowSetActiveModal(true);
};
const handleSetActiveConfirm = () => {
if (selectedConfig) {
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
setSelectedConfig(config);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
if (selectedConfig) {
api.consoleModels.project.delete(selectedConfig.id);
setConfigs([...api.consoleModels.project.list()]);
}
setShowDeleteModal(false);
setSelectedConfig(null);
};
return (
<div className="model-configs-page">
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">项目模型配置</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/project/models/add')}>
<FiPlus /> 新增配置
</button>
</div>
<div className="card-body">
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>配置名称</th>
<th>配置类型</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{configs.map(config => (
<tr key={config.id} className={config.isActive ? 'active-row' : ''}>
<td><strong>{config.name}</strong></td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<td>
{config.isActive ? (
<span className="status status-running">生效中</span>
) : (
<span className="status status-stopped">未生效</span>
)}
</td>
<td className="col-actions">
<div className="table-actions">
{!config.isActive && (
<button
className="text-btn text-btn-primary"
onClick={() => handleSetActiveClick(config)}
>
设为默认
</button>
)}
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/console/project/models/${config.id}/edit`)}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
onConfirm={handleSetActiveConfirm}
onCancel={() => {
setShowSetActiveModal(false);
setSelectedConfig(null);
}}
confirmText="确认切换"
cancelText="取消"
>
<p>确定将"{selectedConfig?.name}"设为项目默认模型配置吗</p>
<p>切换后原生效配置将变为备用状态</p>
</Modal>
<Modal
visible={showDeleteModal}
title="确认删除"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setShowDeleteModal(false);
setSelectedConfig(null);
}}
confirmText="删除"
cancelText="取消"
>
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
</div>
);
}
export default ProjectModelConfigsPage;

View File

@@ -0,0 +1,143 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiPlus } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES } from '../../data/configTypes.js';
import Modal from '../../components/common/Modal.jsx';
function UserModelConfigsPage() {
const navigate = useNavigate();
const [configs, setConfigs] = useState(api.consoleModels.user.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
setSelectedConfig(config);
setShowSetActiveModal(true);
};
const handleSetActiveConfirm = () => {
if (selectedConfig) {
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
setSelectedConfig(config);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
if (selectedConfig) {
api.consoleModels.user.delete(selectedConfig.id);
setConfigs([...api.consoleModels.user.list()]);
}
setShowDeleteModal(false);
setSelectedConfig(null);
};
return (
<div className="model-configs-page">
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">个人模型配置</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/user-models/add')}>
<FiPlus /> 新增配置
</button>
</div>
<div className="card-body">
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>配置名称</th>
<th>配置类型</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{configs.map(config => (
<tr key={config.id} className={config.isActive ? 'active-row' : ''}>
<td><strong>{config.name}</strong></td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<td>
{config.isActive ? (
<span className="status status-running">生效中</span>
) : (
<span className="status status-stopped">未生效</span>
)}
</td>
<td className="col-actions">
<div className="table-actions">
{!config.isActive && (
<button
className="text-btn text-btn-primary"
onClick={() => handleSetActiveClick(config)}
>
设为默认
</button>
)}
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/console/user-models/${config.id}/edit`)}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
onConfirm={handleSetActiveConfirm}
onCancel={() => {
setShowSetActiveModal(false);
setSelectedConfig(null);
}}
confirmText="确认切换"
cancelText="取消"
>
<p>确定将"{selectedConfig?.name}"设为个人默认模型配置吗</p>
<p>切换后原生效配置将变为备用状态</p>
</Modal>
<Modal
visible={showDeleteModal}
title="确认删除"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setShowDeleteModal(false);
setSelectedConfig(null);
}}
confirmText="删除"
cancelText="取消"
>
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
</div>
);
}
export default UserModelConfigsPage;

View File

@@ -11,6 +11,8 @@ import { mySkills, skillCategories, devDocs, developerOverview } from '../data/d
import { projectMembers } from '../data/members.js';
import { scheduledTasks } from '../data/tasks.js';
import { adminDepartments, adminUsers, adminProjects, adminOverview, adminLogs, modelConfigs } from '../data/adminData.js';
import { projectModelConfigs } from '../data/projectModelConfigs.js';
import { userModelConfigs } from '../data/userModelConfigs.js';
/**
* 用户相关 API
@@ -377,8 +379,81 @@ export const adminApi = {
};
/**
* 统一 API 导出对象
* 控制台模型配置相关 API项目级 + 个人级)
*/
export const consoleModelConfigsApi = {
project: {
list: () => projectModelConfigs,
getById: (id) => projectModelConfigs.find(c => c.id === id),
create: (data) => {
const newConfig = {
...data,
id: `proj_${String(projectModelConfigs.length + 1).padStart(3, '0')}`,
isActive: projectModelConfigs.length === 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
projectModelConfigs.push(newConfig);
return newConfig;
},
update: (id, data) => {
const index = projectModelConfigs.findIndex(c => c.id === id);
if (index === -1) return undefined;
projectModelConfigs[index] = { ...projectModelConfigs[index], ...data, updatedAt: new Date().toISOString() };
return projectModelConfigs[index];
},
delete: (id) => {
const index = projectModelConfigs.findIndex(c => c.id === id);
if (index === -1) return false;
projectModelConfigs.splice(index, 1);
return true;
},
setActive: (id) => {
const target = projectModelConfigs.find(c => c.id === id);
if (!target) return undefined;
projectModelConfigs.forEach(c => { c.isActive = false; });
target.isActive = true;
target.updatedAt = new Date().toISOString();
return target;
},
},
user: {
list: () => userModelConfigs,
getById: (id) => userModelConfigs.find(c => c.id === id),
create: (data) => {
const newConfig = {
...data,
id: `user_${String(userModelConfigs.length + 1).padStart(3, '0')}`,
isActive: userModelConfigs.length === 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
userModelConfigs.push(newConfig);
return newConfig;
},
update: (id, data) => {
const index = userModelConfigs.findIndex(c => c.id === id);
if (index === -1) return undefined;
userModelConfigs[index] = { ...userModelConfigs[index], ...data, updatedAt: new Date().toISOString() };
return userModelConfigs[index];
},
delete: (id) => {
const index = userModelConfigs.findIndex(c => c.id === id);
if (index === -1) return false;
userModelConfigs.splice(index, 1);
return true;
},
setActive: (id) => {
const target = userModelConfigs.find(c => c.id === id);
if (!target) return undefined;
userModelConfigs.forEach(c => { c.isActive = false; });
target.isActive = true;
target.updatedAt = new Date().toISOString();
return target;
},
},
};
export const api = {
user,
skills: skillsApi,
@@ -388,6 +463,7 @@ export const api = {
members: membersApi,
tasks: tasksApi,
admin: adminApi,
consoleModels: consoleModelConfigsApi,
};
export default api;

View File

@@ -9,3 +9,4 @@
@use 'pages/admin' as *;
@use 'pages/developer' as *;
@use 'pages/home' as *;
@use 'pages/model-selector' as *;

View File

@@ -4,3 +4,4 @@
@forward 'admin';
@forward 'developer';
@forward 'home';
@forward 'model-selector';

View File

@@ -0,0 +1,219 @@
@use '../../tokens' as *;
.model-selector {
position: relative;
z-index: 100;
}
.model-selector--open {
.model-selector__arrow--open {
transform: rotate(180deg);
color: var(--color-primary);
}
.model-selector__trigger {
background: var(--color-bg-3);
}
}
.model-selector__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-2;
padding: 6px $spacing-3;
background: var(--color-bg-2);
border: none;
border-bottom: 1px solid var(--color-border-2);
cursor: pointer;
transition: background var(--transition);
user-select: none;
&:hover {
background: var(--color-bg-3);
}
}
.model-selector__content {
display: flex;
align-items: center;
gap: $spacing-2;
flex: 1;
min-width: 0;
}
.model-selector__icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary) 0%, #8B5CF6 100%);
border-radius: $radius-sm;
color: #FFFFFF;
flex-shrink: 0;
}
.model-selector__name {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
@include text-truncate;
}
.model-selector__tag {
display: inline-flex;
align-items: center;
padding: 1px 6px;
background: linear-gradient(135deg, var(--color-primary) 0%, #60A5FA 100%);
color: #FFFFFF;
font-size: 10px;
font-weight: 600;
border-radius: 999px;
flex-shrink: 0;
}
.model-selector__arrow {
color: var(--color-text-3);
transition: transform var(--transition);
flex-shrink: 0;
display: flex;
align-items: center;
}
.model-selector__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-1);
border: 1px solid var(--color-primary);
border-top: none;
border-radius: 0 0 $radius-lg $radius-lg;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
overflow: hidden;
animation: dropdownExpand 0.2s ease-out;
max-height: 350px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-3);
border-radius: 3px;
&:hover {
background: var(--color-text-4);
}
}
}
@keyframes dropdownExpand {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.model-selector__group {
&:not(:last-child) {
border-bottom: 1px solid var(--color-border-2);
}
}
.model-selector__group-title {
display: flex;
align-items: center;
gap: 6px;
padding: 8px $spacing-3;
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border-2);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.model-selector__item {
display: flex;
align-items: center;
gap: $spacing-2;
padding: 8px $spacing-3;
padding-right: 32px;
cursor: pointer;
transition: background var(--transition-fast);
position: relative;
&:hover {
background: var(--color-primary-light);
}
&--selected {
background: var(--color-primary-light);
&::after {
content: '';
position: absolute;
right: $spacing-3;
top: 50%;
transform: translateY(-50%);
color: var(--color-primary);
font-weight: 700;
font-size: 12px;
}
}
}
.model-selector__item-text {
font-size: 13px;
color: var(--color-text-1);
flex: 1;
min-width: 0;
@include text-truncate;
}
.model-selector__item-tag {
display: inline-flex;
align-items: center;
padding: 1px 5px;
background: linear-gradient(135deg, var(--color-primary) 0%, #60A5FA 100%);
color: #FFFFFF;
font-size: 9px;
font-weight: 600;
border-radius: 999px;
flex-shrink: 0;
}
@include mobile {
.model-selector__trigger {
padding: 4px $spacing-2;
}
.model-selector__name {
font-size: 12px;
}
.model-selector__dropdown {
max-height: 280px;
}
.model-selector__item {
padding: 6px $spacing-2;
padding-right: 28px;
}
.model-selector__item-text {
font-size: 12px;
}
}