From 181f4565b47be261d55176b8f272d7213a9539dd Mon Sep 17 00:00:00 2001 From: AlanPaine Date: Wed, 1 Apr 2026 08:36:52 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=86=E4=BA=AB=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=A0=B7=E5=BC=8F=E4=B8=8E=E9=81=93=E8=B7=AF=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/admin_settings.py | 13 + backend/app/api/endpoints/meetings.py | 4 +- backend/app/services/system_config_service.py | 21 ++ frontend/src/components/MainLayout.jsx | 10 +- frontend/src/config/api.js | 3 + frontend/src/pages/HomePage.jsx | 19 +- frontend/src/pages/MeetingPreview.jsx | 313 ++++++++++++++---- frontend/src/utils/configService.js | 41 +++ 8 files changed, 348 insertions(+), 76 deletions(-) diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py index 013c69a..ac1548f 100644 --- a/backend/app/api/endpoints/admin_settings.py +++ b/backend/app/api/endpoints/admin_settings.py @@ -9,6 +9,7 @@ from app.core.database import get_db_connection from app.core.response import create_api_response from app.services.async_transcription_service import AsyncTranscriptionService from app.services.llm_service import LLMService +from app.services.system_config_service import SystemConfigService router = APIRouter() llm_service = LLMService() @@ -800,6 +801,18 @@ async def test_audio_model_config(request: AudioModelTestRequest, current_user=D return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}") +@router.get("/system-config/public") +async def get_public_system_config(): + try: + return create_api_response( + code="200", + message="获取公开配置成功", + data=SystemConfigService.get_branding_config() + ) + except Exception as e: + return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}") + + @router.get("/admin/system-config") async def get_system_config_compat(current_user=Depends(get_current_admin_user)): """兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。""" diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index 6956794..7e5405d 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -1074,7 +1074,7 @@ def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): # 检查会议是否存在,并获取基本信息 query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, m.tags, m.user_id as creator_id, u.caption as creator_username, p.name as prompt_name, m.access_password FROM meetings m @@ -1175,6 +1175,7 @@ def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): cursor.execute(attendees_query, (meeting_id,)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] + tags = _process_tags(cursor, meeting.get('tags')) # 组装返回数据 preview_data = { @@ -1187,6 +1188,7 @@ def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): "prompt_name": meeting['prompt_name'], "attendees": attendees, "attendees_count": len(attendees), + "tags": tags, "has_password": bool(meeting.get('access_password')), "processing_status": progress_info # 附带进度信息供调试 } diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index 52a075f..b576cee 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -15,6 +15,15 @@ class SystemConfigService: DEFAULT_RESET_PASSWORD = 'default_reset_password' MAX_AUDIO_SIZE = 'max_audio_size' + # 品牌配置 + BRANDING_APP_NAME = 'branding_app_name' + BRANDING_HOME_HEADLINE = 'branding_home_headline' + BRANDING_HOME_TAGLINE = 'branding_home_tagline' + BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle' + BRANDING_PREVIEW_TITLE = 'branding_preview_title' + BRANDING_LOGIN_WELCOME = 'branding_login_welcome' + BRANDING_FOOTER_TEXT = 'branding_footer_text' + # 声纹配置 VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text' VOICEPRINT_MAX_SIZE = 'voiceprint_max_size' @@ -603,6 +612,18 @@ class SystemConfigService: except (ValueError, TypeError): return default + @classmethod + def get_branding_config(cls) -> Dict[str, str]: + return { + "app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"), + "home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"), + "home_tagline": str(cls.get_config(cls.BRANDING_HOME_TAGLINE, "让每一次谈话都产生价值。") or "让每一次谈话都产生价值。"), + "console_subtitle": str(cls.get_config(cls.BRANDING_CONSOLE_SUBTITLE, "智能会议控制台") or "智能会议控制台"), + "preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"), + "login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"), + "footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"), + } + # LLM模型配置获取方法(直接使用通用方法) @classmethod def get_llm_model_name(cls, default: str = "qwen-plus") -> str: diff --git a/frontend/src/components/MainLayout.jsx b/frontend/src/components/MainLayout.jsx index f74084b..07a1259 100644 --- a/frontend/src/components/MainLayout.jsx +++ b/frontend/src/components/MainLayout.jsx @@ -10,6 +10,7 @@ import { import { useNavigate, useLocation } from 'react-router-dom'; import menuService from '../services/menuService'; import { renderMenuIcon } from '../utils/menuIcons'; +import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService'; const { Header, Content, Sider } = Layout; const { useBreakpoint } = Grid; @@ -20,6 +21,7 @@ const MainLayout = ({ children, user, onLogout }) => { const [drawerOpen, setDrawerOpen] = useState(false); const [openKeys, setOpenKeys] = useState([]); const [activeMenuKey, setActiveMenuKey] = useState(null); + const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG); const navigate = useNavigate(); const location = useLocation(); const screens = useBreakpoint(); @@ -40,6 +42,10 @@ const MainLayout = ({ children, user, onLogout }) => { fetchMenus(); }, []); + useEffect(() => { + configService.getBrandingConfig().then(setBranding).catch(() => {}); + }, []); + useEffect(() => { if (isMobile) { setCollapsed(false); @@ -267,8 +273,8 @@ const MainLayout = ({ children, user, onLogout }) => { {!collapsed && (
-
iMeeting
-
智能会议控制台
+
{branding.app_name}
+
{branding.console_subtitle}
)} {!isMobile && ( diff --git a/frontend/src/config/api.js b/frontend/src/config/api.js index 190b6a1..51d6ddd 100644 --- a/frontend/src/config/api.js +++ b/frontend/src/config/api.js @@ -65,6 +65,9 @@ const API_CONFIG = { ITEM_DETAIL: (id) => `/api/admin/hot-word-items/${id}`, } }, + PUBLIC: { + SYSTEM_CONFIG: '/api/system-config/public', + }, TAGS: { LIST: '/api/tags' }, diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 0dd418d..6cf9e24 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Row, Col, Typography, Button, Form, Input, Space, Tabs, App @@ -12,13 +12,19 @@ import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import menuService from '../services/menuService'; import BrandLogo from '../components/BrandLogo'; +import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService'; const { Title, Paragraph, Text } = Typography; const HomePage = ({ onLogin }) => { const [loading, setLoading] = useState(false); + const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG); const { message } = App.useApp(); + useEffect(() => { + configService.getBrandingConfig().then(setBranding).catch(() => {}); + }, []); + const handleLogin = async (values) => { setLoading(true); try { @@ -63,16 +69,15 @@ const HomePage = ({ onLogin }) => { justifyContent: 'center' }}>
- +
- 智能协作 <span style={{ color: '#1677ff' }}>会议管理平台</span> + <span style={{ color: '#1677ff' }}>{branding.home_headline}</span> - 全流程会议辅助,让每一份交流都产生价值。 - 实时转录、自动总结、知识沉淀。 + {branding.home_tagline}
@@ -100,7 +105,7 @@ const HomePage = ({ onLogin }) => {
- 欢迎回来,请输入您的登录凭证。 + {branding.login_welcome}
@@ -150,7 +155,7 @@ const HomePage = ({ onLogin }) => {
- ©2026 iMeeting · 智能会议协作平台 + {branding.footer_text}
diff --git a/frontend/src/pages/MeetingPreview.jsx b/frontend/src/pages/MeetingPreview.jsx index 1a6b567..9cc7a3b 100644 --- a/frontend/src/pages/MeetingPreview.jsx +++ b/frontend/src/pages/MeetingPreview.jsx @@ -1,27 +1,21 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { - Layout, Card, Typography, Space, Button, - Result, Spin, App, Tag, Divider, Empty, Input -} from 'antd'; -import { - LockOutlined, EyeOutlined, EyeInvisibleOutlined, - CopyOutlined, CheckCircleOutlined, ShareAltOutlined, - PlayCircleFilled, PauseCircleFilled, - HomeOutlined, CalendarOutlined, LoginOutlined -} from '@ant-design/icons'; +import { Layout, Space, Button, App, Tag, Empty, Input, Tabs } from 'antd'; +import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, UserOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import MarkdownRenderer from '../components/MarkdownRenderer'; -import BrandLogo from '../components/BrandLogo'; +import MindMap from '../components/MindMap'; import tools from '../utils/tools'; +import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService'; +import './MeetingPreview.css'; -const { Header, Content } = Layout; -const { Title, Text, Paragraph } = Typography; +const { Content } = Layout; const MeetingPreview = () => { const { meeting_id } = useParams(); const { message } = App.useApp(); + const audioRef = useRef(null); const [meeting, setMeeting] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -29,9 +23,20 @@ const MeetingPreview = () => { const [passwordError, setPasswordError] = useState(''); const [passwordRequired, setPasswordRequired] = useState(false); const [isAuthorized, setIsAuthorized] = useState(false); + const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG); + const [transcript, setTranscript] = useState([]); + const [audioUrl, setAudioUrl] = useState(''); + const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1); + + useEffect(() => { + configService.getBrandingConfig().then(setBranding).catch(() => {}); + }, []); useEffect(() => { setMeeting(null); + setTranscript([]); + setAudioUrl(''); + setActiveSegmentIndex(-1); setError(null); setPassword(''); setPasswordError(''); @@ -40,6 +45,25 @@ const MeetingPreview = () => { fetchPreview(); }, [meeting_id]); + const fetchTranscriptAndAudio = async () => { + const [transcriptRes, audioRes] = await Promise.allSettled([ + apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))), + apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))), + ]); + + if (transcriptRes.status === 'fulfilled') { + setTranscript(Array.isArray(transcriptRes.value.data) ? transcriptRes.value.data : []); + } else { + setTranscript([]); + } + + if (audioRes.status === 'fulfilled') { + setAudioUrl(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`)); + } else { + setAudioUrl(''); + } + }; + const fetchPreview = async (pwd = '') => { setLoading(true); try { @@ -51,6 +75,7 @@ const MeetingPreview = () => { setPasswordRequired(false); setPasswordError(''); setError(null); + await fetchTranscriptAndAudio(); } catch (err) { const responseCode = String(err?.response?.data?.code || ''); if (responseCode === '401') { @@ -71,6 +96,40 @@ const MeetingPreview = () => { } }; + const handleCopyLink = async () => { + await navigator.clipboard.writeText(window.location.href); + message.success('分享链接已复制'); + }; + + const handleShare = async () => { + if (navigator.share) { + try { + await navigator.share({ title: meeting?.title || branding.preview_title, url: window.location.href }); + return; + } catch {} + } + await handleCopyLink(); + }; + + const handleTimeUpdate = () => { + if (!audioRef.current || !transcript.length) { + return; + } + const currentMs = audioRef.current.currentTime * 1000; + const index = transcript.findIndex( + (item) => currentMs >= item.start_time_ms && currentMs <= item.end_time_ms, + ); + setActiveSegmentIndex(index); + }; + + const jumpToSegment = (segment) => { + if (!audioRef.current || !segment) { + return; + } + audioRef.current.currentTime = (segment.start_time_ms || 0) / 1000; + audioRef.current.play().catch(() => {}); + }; + const handleVerify = () => { if (!password) { message.warning('请输入访问密码'); @@ -79,69 +138,191 @@ const MeetingPreview = () => { fetchPreview(password); }; - if (loading && !meeting) return
; + if (loading && !meeting) { + return ( +
+
+
+

正在加载会议预览...

+
+
+ ); + } - if (error) return } />; + if (error) { + return ( +
+
+
⚠️
+

加载失败

+

{error}

+ +
+
+ ); + } if (passwordRequired && !isAuthorized) { return ( -
- - - 此会议受密码保护 - 请输入访问密码以查看会议纪要 - - { - setPassword(e.target.value); - if (passwordError) { - setPasswordError(''); - } - }} - onPressEnter={handleVerify} - /> - {passwordError ? {passwordError} : null} - - -
- +
+
+
+
+ +
+

此会议受密码保护

+

请输入访问密码以查看会议纪要

+
+ { + setPassword(e.target.value); + if (passwordError) { + setPasswordError(''); + } + }} + onPressEnter={handleVerify} + /> +
+ {passwordError ?
{passwordError}
: null} + +
+ +
- +
); } return ( - -
- - - - -
- - - - {meeting.title} - - {meeting.tags?.map(t => {t.name})} - {tools.formatDateTime(meeting.meeting_time)} - - - - -
- {meeting.summary ? ( - - ) : ( - - )} + + +
+

{meeting.title}

+ +
+

会议信息

+
创建人:{meeting.creator_username || '未知'}
+
会议时间:{tools.formatDateTime(meeting.meeting_time)}
+
参会人员:{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}
+
计算人数:{meeting.attendees_count || meeting.attendees?.length || 0}人
+
总结模板:{meeting.prompt_name || '默认模板'}
+ {meeting.tags?.length ? ( +
+ 标签: + + {meeting.tags.map((tag) => {tag.name})} + +
+ ) : null}
- + +
+ + +
+ +
+ + + 会议总结 + + ), + children: ( +
+ {meeting.summary ? : } +
+ ), + }, + { + key: 'transcript', + label: ( + + + 会议转录 + + ), + children: ( +
+ {audioUrl ? ( +
+
+ ) : null} + {transcript.length ? ( +
+ {transcript.map((segment, index) => ( +
jumpToSegment(segment)} + > +
+ + + {segment.speaker_tag || `发言人 ${segment.speaker_id}`} + + {tools.formatDuration((segment.start_time_ms || 0) / 1000)} +
+
{segment.text_content}
+
+ ))} +
+ ) : ( +
暂无转录内容
+ )} +
+ ), + }, + { + key: 'mindmap', + label: ( + + + 思维导图 + + ), + children: ( +
+ +
+ ), + }, + ]} + /> +
+ +
+ {branding.footer_text} +
+
); diff --git a/frontend/src/utils/configService.js b/frontend/src/utils/configService.js index 65411c7..c80f77e 100644 --- a/frontend/src/utils/configService.js +++ b/frontend/src/utils/configService.js @@ -1,11 +1,23 @@ import apiClient from './apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +export const DEFAULT_BRANDING_CONFIG = { + app_name: '智听云平台', + home_headline: '智听云平台', + home_tagline: '让每一次谈话都产生价值。', + console_subtitle: '智能会议控制台', + preview_title: '会议预览', + login_welcome: '欢迎回来,请输入您的登录凭证。', + footer_text: '©2026 智听云平台', +}; + class ConfigService { constructor() { this.configs = null; this.loadPromise = null; this._pageSize = null; + this.brandingConfig = null; + this.brandingPromise = null; } async getConfigs() { @@ -64,6 +76,33 @@ class ConfigService { return 10; } + async getBrandingConfig() { + if (this.brandingConfig) { + return this.brandingConfig; + } + + if (this.brandingPromise) { + return this.brandingPromise; + } + + this.brandingPromise = this.loadBrandingConfigFromServer(); + this.brandingConfig = await this.brandingPromise; + return this.brandingConfig; + } + + async loadBrandingConfigFromServer() { + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG)); + return { + ...DEFAULT_BRANDING_CONFIG, + ...(response.data || {}), + }; + } catch (error) { + console.warn('Failed to load branding configs, using defaults:', error); + return { ...DEFAULT_BRANDING_CONFIG }; + } + } + // 格式化文件大小为可读格式 formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; @@ -78,6 +117,8 @@ class ConfigService { this.configs = null; this.loadPromise = null; this._pageSize = null; + this.brandingConfig = null; + this.brandingPromise = null; } }