优化了样式

main
mula.liu 2025-09-24 18:05:44 +08:00
parent 230a77c3cf
commit 2dc066a2ce
12 changed files with 734 additions and 32 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -15,20 +15,20 @@ mkdir -p logs
# 停止并删除现有容器
echo "📦 停止现有容器..."
docker-compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml down
# 构建新镜像
echo "🔨 构建Docker镜像..."
docker-compose -f docker-compose.prod.yml build --no-cache
docker compose --progress=plain -f docker-compose.prod.yml build --no-cache
# 启动服务
echo "▶️ 启动PM2服务..."
docker-compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml up -d
# 检查服务状态
echo "🔍 检查服务状态..."
sleep 15
docker-compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml ps
# 检查PM2进程状态
echo "🔄 检查PM2进程状态..."
@ -51,4 +51,4 @@ echo " ✅ 集群模式2个实例"
echo " ✅ 自动重启和故障恢复"
echo " ✅ 内存限制保护1GB"
echo " ✅ 详细日志管理"
echo " ✅ 进程监控和健康检查"
echo " ✅ 进程监控和健康检查"

BIN
dist.zip

Binary file not shown.

View File

@ -244,6 +244,92 @@
}
}
/* Dropdown Menu Styles */
.meeting-actions {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dropdown-trigger {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: #64748b;
}
.dropdown-trigger:hover {
background: #f1f5f9;
color: #334155;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #e2e8f0;
padding: 0.5rem;
margin-top: 0.5rem;
min-width: 120px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: none;
background: none;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-decoration: none;
transition: all 0.2s ease;
width: 100%;
text-align: left;
}
/* 确保Link组件内的dropdown-item正确显示 */
.dropdown-menu a {
text-decoration: none;
display: block;
width: 100%;
}
.dropdown-menu a .dropdown-item {
width: 100%;
}
.dropdown-item:hover {
background: #f3f4f6;
color: #111827;
}
.dropdown-item.delete-item {
color: #dc2626;
}
.dropdown-item.delete-item:hover {
background: #fef2f2;
color: #b91c1c;
}
.meeting-content {
background: white;
border-radius: 12px;

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Clock, Users, FileText, Calendar, User, Edit, Trash2, MoreVertical } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { Clock, Users, FileText, User, Edit, Calendar , Trash2, MoreVertical } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@ -11,7 +11,7 @@ import './MeetingTimeline.css';
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(null);
const [showDropdown, setShowDropdown] = useState(null);
const navigate = useNavigate();
// Close dropdown when clicking outside
React.useEffect(() => {
const handleClickOutside = () => {
@ -66,6 +66,12 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
return lines.length > maxLines || summary.length > maxLength;
};
const handleEditClick = (meetingId, e) => {
e.preventDefault();
navigate(`/meetings/edit/${meetingId}`)
setShowDropdown(null);
};
const handleDeleteClick = (meetingId, e) => {
e.preventDefault();
setShowDeleteConfirm(meetingId);
@ -141,15 +147,12 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
</button>
{showDropdown === meeting.meeting_id && (
<div className="dropdown-menu" onClick={(e) => e.stopPropagation()}>
<Link
to={`/meetings/edit/${meeting.meeting_id}`}
onClick={(e) => e.stopPropagation()}
<button className="dropdown-item"
onClick={(e) =>handleEditClick(meeting.meeting_id, e)}
>
<span className="dropdown-item">
<Edit size={16} />
编辑
</span>
</Link>
<Edit size={16} />
编辑
</button>
<button
className="dropdown-item delete-item"
onClick={(e) => handleDeleteClick(meeting.meeting_id, e)}

View File

@ -0,0 +1,250 @@
/* System Configuration Styles */
.system-configuration {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.config-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
}
.config-header h2 {
margin: 0 0 0.5rem 0;
color: #1e293b;
font-size: 1.75rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.config-header p {
margin: 0;
color: #64748b;
font-size: 1rem;
}
.config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.config-block {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
overflow: hidden;
}
.config-block-header {
padding: 1.5rem;
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
border-bottom: 1px solid #e2e8f0;
}
.config-block-header h3 {
margin: 0 0 0.5rem 0;
color: #334155;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.config-block-header p {
margin: 0;
color: #64748b;
font-size: 0.875rem;
}
.config-block-content {
padding: 1.5rem;
}
.config-form-group {
margin-bottom: 1.5rem;
}
.config-form-group:last-child {
margin-bottom: 0;
}
.config-form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.config-form-group .label-hint {
color: #6b7280;
font-weight: 400;
font-size: 0.8rem;
margin-left: 0.25rem;
}
.config-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s ease;
box-sizing: border-box;
font-family: inherit;
}
.config-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.config-textarea {
min-height: 120px;
resize: vertical;
line-height: 1.5;
}
.config-input-hint {
margin-top: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
line-height: 1.4;
}
.config-actions {
padding: 1.5rem 2rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.config-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.config-btn-secondary {
background: #e2e8f0;
color: #475569;
}
.config-btn-secondary:hover {
background: #cbd5e1;
}
.config-btn-primary {
background: linear-gradient(135deg, #3b82f6, #1e40af);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.config-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
.config-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.config-message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.config-message.success {
background: #f0f9ff;
border: 1px solid #bae6fd;
color: #0c4a6e;
}
.config-message.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.config-message.loading {
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
}
/* Responsive Design */
@media (max-width: 1024px) {
.config-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
@media (max-width: 768px) {
.system-configuration {
padding: 1rem;
}
.config-header h2 {
font-size: 1.5rem;
}
.config-block-header {
padding: 1rem;
}
.config-block-content {
padding: 1rem;
}
.config-actions {
padding: 1rem;
flex-direction: column;
}
.config-btn {
justify-content: center;
}
}

View File

@ -1,10 +1,272 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Settings, Brain, Shield, Save, RotateCcw, CheckCircle, AlertCircle, Loader } from 'lucide-react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import './SystemConfiguration.css';
const SystemConfiguration = () => {
const [configs, setConfigs] = useState({
model_name: '',
system_prompt: '',
DEFAULT_RESET_PASSWORD: '',
MAX_FILE_SIZE: 0,
MAX_IMAGE_SIZE: 0
});
const [originalConfigs, setOriginalConfigs] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
// MB
const bytesToMB = (bytes) => {
return Math.round(bytes / (1024 * 1024));
};
// MB
const mbToBytes = (mb) => {
return mb * 1024 * 1024;
};
//
useEffect(() => {
fetchConfigs();
}, []);
const fetchConfigs = async () => {
try {
setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_CONFIG));
const configData = response.data;
setConfigs(configData);
setOriginalConfigs(configData);
setMessage({ type: '', text: '' });
} catch (err) {
console.error('Failed to fetch configurations:', err);
setMessage({
type: 'error',
text: '加载配置失败:' + (err.response?.data?.detail || err.message)
});
} finally {
setLoading(false);
}
};
const handleInputChange = (key, value) => {
if (key === 'MAX_FILE_SIZE' || key === 'MAX_IMAGE_SIZE') {
// MB
const numValue = parseInt(value) || 0;
setConfigs(prev => ({
...prev,
[key]: mbToBytes(numValue)
}));
} else {
setConfigs(prev => ({
...prev,
[key]: value
}));
}
};
const handleSave = async () => {
try {
setSaving(true);
setMessage({ type: 'loading', text: '保存配置中...' });
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_CONFIG), configs);
setOriginalConfigs(configs);
setMessage({ type: 'success', text: '配置保存成功!' });
// 3
setTimeout(() => {
setMessage({ type: '', text: '' });
}, 3000);
} catch (err) {
console.error('Failed to save configurations:', err);
setMessage({
type: 'error',
text: '保存配置失败:' + (err.response?.data?.detail || err.message)
});
} finally {
setSaving(false);
}
};
const handleReset = () => {
setConfigs(originalConfigs);
setMessage({ type: '', text: '' });
};
const hasChanges = JSON.stringify(configs) !== JSON.stringify(originalConfigs);
if (loading) {
return (
<div className="system-configuration">
<div className="config-message loading">
<Loader className="loading-spinner" />
加载配置数据中...
</div>
</div>
);
}
return (
<div>
<h2>系统配置</h2>
<p>系统相关配置项</p>
<div className="system-configuration">
<div className="config-header">
<h2>
<Settings />
系统配置
</h2>
<p>管理系统的核心配置参数包括模型配置和管理员设置</p>
</div>
{message.text && (
<div className={`config-message ${message.type}`}>
{message.type === 'success' && <CheckCircle size={16} />}
{message.type === 'error' && <AlertCircle size={16} />}
{message.type === 'loading' && <Loader className="loading-spinner" size={16} />}
{message.text}
</div>
)}
<div className="config-grid">
{/* 模型配置块 */}
<div className="config-block">
<div className="config-block-header">
<h3>
<Brain />
模型配置
</h3>
<p>配置AI模型相关参数</p>
</div>
<div className="config-block-content">
<div className="config-form-group">
<label>
模型名称
<span className="label-hint">(model_name)</span>
</label>
<input
type="text"
className="config-input"
value={configs.model_name}
onChange={(e) => handleInputChange('model_name', e.target.value)}
placeholder="请输入模型名称"
/>
<div className="config-input-hint">
指定要使用的AI模型名称例如gpt-4, claude-3-sonnet等
</div>
</div>
<div className="config-form-group">
<label>
系统提示词
<span className="label-hint">(system_prompt)</span>
</label>
<textarea
className="config-input config-textarea"
value={configs.system_prompt}
onChange={(e) => handleInputChange('system_prompt', e.target.value)}
placeholder="请输入系统提示词"
rows={6}
/>
<div className="config-input-hint">
定义AI助手的行为和回答风格这将影响所有AI生成的内容
</div>
</div>
</div>
</div>
{/* 管理配置块 */}
<div className="config-block">
<div className="config-block-header">
<h3>
<Shield />
管理配置
</h3>
<p>系统管理相关设置</p>
</div>
<div className="config-block-content">
<div className="config-form-group">
<label>
默认重置密码
<span className="label-hint">(DEFAULT_RESET_PASSWORD)</span>
</label>
<input
type="text"
className="config-input"
value={configs.DEFAULT_RESET_PASSWORD}
onChange={(e) => handleInputChange('DEFAULT_RESET_PASSWORD', e.target.value)}
placeholder="请输入默认密码"
/>
<div className="config-input-hint">
管理员重置用户密码时使用的默认密码建议设置为安全的临时密码
</div>
</div>
<div className="config-form-group">
<label>
最大文件大小
<span className="label-hint">(MAX_FILE_SIZE)</span>
</label>
<input
type="number"
className="config-input"
value={bytesToMB(configs.MAX_FILE_SIZE)}
onChange={(e) => handleInputChange('MAX_FILE_SIZE', e.target.value)}
placeholder="请输入文件大小限制(MB)"
min="1"
max="1000"
/>
<div className="config-input-hint">
用户上传音频文件的大小限制单位为MB建议设置为50-200MB
</div>
</div>
<div className="config-form-group">
<label>
最大图片大小
<span className="label-hint">(MAX_IMAGE_SIZE)</span>
</label>
<input
type="number"
className="config-input"
value={bytesToMB(configs.MAX_IMAGE_SIZE)}
onChange={(e) => handleInputChange('MAX_IMAGE_SIZE', e.target.value)}
placeholder="请输入图片大小限制(MB)"
min="1"
max="100"
/>
<div className="config-input-hint">
用户上传图片的大小限制单位为MB建议设置为5-20MB
</div>
</div>
</div>
</div>
</div>
<div className="config-actions">
<button
className="config-btn config-btn-secondary"
onClick={handleReset}
disabled={!hasChanges || saving}
>
<RotateCcw size={16} />
重置更改
</button>
<button
className="config-btn config-btn-primary"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? (
<Loader className="loading-spinner" size={16} />
) : (
<Save size={16} />
)}
{saving ? '保存中...' : '保存配置'}
</button>
</div>
</div>
);
};

View File

@ -31,6 +31,12 @@ const API_CONFIG = {
UPLOAD_AUDIO: '/api/meetings/upload-audio',
UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`,
REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`
},
ADMIN: {
SYSTEM_CONFIG: '/api/admin/system-config'
},
TAGS: {
LIST: '/api/tags'
}
}
};

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import configService from '../utils/configService';
import { ArrowLeft, Upload, Users, Calendar, FileText, X, User, Plus, Tag } from 'lucide-react';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import DateTimePicker from '../components/DateTimePicker';
@ -21,11 +22,22 @@ const CreateMeeting = ({ user }) => {
const [audioFile, setAudioFile] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 100MB
useEffect(() => {
fetchUsers();
loadMaxFileSize();
}, []);
const loadMaxFileSize = async () => {
try {
const size = await configService.getMaxFileSize();
setMaxFileSize(size);
} catch (error) {
console.warn('Failed to load max file size config:', error);
}
};
const fetchUsers = async () => {
try {
// size
@ -74,9 +86,10 @@ const CreateMeeting = ({ user }) => {
setError('请上传支持的音频格式 (MP3, WAV, M4A)');
return;
}
// Check file size (max 100MB)
if (file.size > 100 * 1024 * 1024) {
setError('音频文件大小不能超过100MB');
// Check file size using dynamic config
if (file.size > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
setError(`音频文件大小不能超过${maxSizeMB}MB`);
return;
}
setAudioFile(file);
@ -276,7 +289,7 @@ const CreateMeeting = ({ user }) => {
<label htmlFor="audio-file" className="file-upload-label">
<Plus size={20} />
<span>选择音频文件</span>
<small>支持 MP3, WAV, M4A 格式最大100MB</small>
<small>支持 MP3, WAV, M4A 格式</small>
</label>
{audioFile && (
<div className="selected-file">

View File

@ -96,7 +96,7 @@ const Dashboard = ({ user, onLogout }) => {
setUserInfo(userResponse.data);
const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`));
console.log('Meetings response:', meetingsResponse.data);
//console.log('Meetings response:', meetingsResponse.data);
setMeetings(meetingsResponse.data);
} catch (err) {

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import configService from '../utils/configService';
import { ArrowLeft, Users, Calendar, FileText, X, User, Save, Upload, Plus, Image, Tag } from 'lucide-react';
import MDEditor, * as commands from '@uiw/react-md-editor';
import '@uiw/react-md-editor/markdown-editor.css';
@ -32,6 +33,8 @@ const EditMeeting = ({ user }) => {
const [meeting, setMeeting] = useState(null);
const [showUploadArea, setShowUploadArea] = useState(false);
const [showUploadConfirm, setShowUploadConfirm] = useState(false);
const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 100MB
const [maxImageSize, setMaxImageSize] = useState(10 * 1024 * 1024); // 10MB
const handleSummaryChange = useCallback((value) => {
setFormData(prev => ({ ...prev, summary: value || '' }));
@ -40,8 +43,20 @@ const EditMeeting = ({ user }) => {
useEffect(() => {
fetchMeetingData();
fetchUsers();
loadFileSizeConfig();
}, [meeting_id]);
const loadFileSizeConfig = async () => {
try {
const fileSize = await configService.getMaxFileSize();
const imageSize = await configService.getMaxImageSize();
setMaxFileSize(fileSize);
setMaxImageSize(imageSize);
} catch (error) {
console.warn('Failed to load file size config:', error);
}
};
const fetchMeetingData = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.EDIT(meeting_id)));
@ -117,9 +132,10 @@ const EditMeeting = ({ user }) => {
setError('请上传支持的音频格式 (MP3, WAV, M4A)');
return;
}
// Check file size (max 100MB)
if (file.size > 100 * 1024 * 1024) {
setError('音频文件大小不能超过100MB');
// Check file size using dynamic config
if (file.size > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
setError(`音频文件大小不能超过${maxSizeMB}MB`);
return;
}
setAudioFile(file);
@ -201,9 +217,10 @@ const EditMeeting = ({ user }) => {
return null;
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setError('图片大小不能超过10MB');
// Validate file size using dynamic config
if (file.size > maxImageSize) {
const maxSizeMB = Math.round(maxImageSize / (1024 * 1024));
setError(`图片大小不能超过${maxSizeMB}MB`);
return null;
}
@ -506,7 +523,7 @@ const EditMeeting = ({ user }) => {
<label htmlFor="audio-file" className="file-upload-label">
<Plus size={20} />
<span>选择新的音频文件</span>
<small>支持 MP3, WAV, M4A 格式最大100MB</small>
<small>支持 MP3, WAV, M4A 格式</small>
</label>
{audioFile && (
<div className="selected-file">

View File

@ -0,0 +1,65 @@
import apiClient from './apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
class ConfigService {
constructor() {
this.configs = null;
this.loadPromise = null;
}
async getConfigs() {
if (this.configs) {
return this.configs;
}
if (this.loadPromise) {
return this.loadPromise;
}
this.loadPromise = this.loadConfigsFromServer();
this.configs = await this.loadPromise;
return this.configs;
}
async loadConfigsFromServer() {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_CONFIG));
return response.data;
} catch (error) {
console.warn('Failed to load system configs, using defaults:', error);
// 返回默认配置
return {
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
MAX_IMAGE_SIZE: 10 * 1024 * 1024 // 10MB
};
}
}
async getMaxFileSize() {
const configs = await this.getConfigs();
return configs.MAX_FILE_SIZE || 100 * 1024 * 1024;
}
async getMaxImageSize() {
const configs = await this.getConfigs();
return configs.MAX_IMAGE_SIZE || 10 * 1024 * 1024;
}
// 格式化文件大小为可读格式
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// 清除缓存的配置(用于配置更新后)
clearCache() {
this.configs = null;
this.loadPromise = null;
}
}
export const configService = new ConfigService();
export default configService;