更新分享页面样式与道路页面描述

codex/dev
AlanPaine 2026-04-01 08:36:52 +00:00
parent a99cc389c8
commit 181f4565b4
8 changed files with 348 additions and 76 deletions

View File

@ -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。"""

View File

@ -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 # 附带进度信息供调试
}

View File

@ -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:

View File

@ -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 }) => {
</span>
{!collapsed && (
<div>
<div className="main-layout-brand-title">iMeeting</div>
<div className="main-layout-brand-subtitle">智能会议控制台</div>
<div className="main-layout-brand-title">{branding.app_name}</div>
<div className="main-layout-brand-subtitle">{branding.console_subtitle}</div>
</div>
)}
{!isMobile && (

View File

@ -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'
},

View File

@ -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'
}}>
<div style={{ marginBottom: 80 }}>
<BrandLogo title="iMeeting" size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
<BrandLogo title={branding.app_name} size={48} titleSize={24} gap={12} titleColor="#6f42c1" />
</div>
<div style={{ maxWidth: 600 }}>
<Title style={{ fontSize: 56, marginBottom: 24, fontWeight: 800 }}>
智能协作 <span style={{ color: '#1677ff' }}>会议管理平台</span>
<span style={{ color: '#1677ff' }}>{branding.home_headline}</span>
</Title>
<Paragraph style={{ fontSize: 20, color: '#4e5969', lineHeight: 1.6 }}>
全流程会议辅助让每一份交流都产生价值
实时转录自动总结知识沉淀
{branding.home_tagline}
</Paragraph>
</div>
</Col>
@ -100,7 +105,7 @@ const HomePage = ({ onLogin }) => {
<div style={{ marginBottom: 40 }}>
<Paragraph type="secondary" style={{ fontSize: 16 }}>
欢迎回来请输入您的登录凭证
{branding.login_welcome}
</Paragraph>
</div>
@ -150,7 +155,7 @@ const HomePage = ({ onLogin }) => {
<div style={{ marginTop: 100, textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
©2026 iMeeting · 智能会议协作平台
{branding.footer_text}
</Text>
</div>
</div>

View File

@ -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 <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" /></div>;
if (loading && !meeting) {
return (
<div className="preview-container">
<div className="preview-loading">
<div className="loading-spinner" />
<p>正在加载会议预览...</p>
</div>
</div>
);
}
if (error) return <Result status="error" title={error} extra={<Link to="/"><Button type="primary" icon={<HomeOutlined />}>返回首页</Button></Link>} />;
if (error) {
return (
<div className="preview-container">
<div className="preview-error">
<div className="error-icon"></div>
<h2>加载失败</h2>
<p>{error}</p>
<button type="button" className="error-retry-btn" onClick={() => fetchPreview(password)}>
重试
</button>
</div>
</div>
);
}
if (passwordRequired && !isAuthorized) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 400, borderRadius: 16, textAlign: 'center', boxShadow: '0 8px 24px rgba(0,0,0,0.05)' }}>
<LockOutlined style={{ fontSize: 48, color: '#1677ff', marginBottom: 24 }} />
<Title level={3}>此会议受密码保护</Title>
<Paragraph type="secondary">请输入访问密码以查看会议纪要</Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Input.Password
size="large"
placeholder="访问密码"
value={password}
status={passwordError ? 'error' : undefined}
onChange={e => {
setPassword(e.target.value);
if (passwordError) {
setPasswordError('');
}
}}
onPressEnter={handleVerify}
/>
{passwordError ? <Text type="danger">{passwordError}</Text> : null}
<Button type="primary" size="large" block icon={<EyeOutlined />} onClick={handleVerify}>立即查看</Button>
</Space>
<div style={{ marginTop: 24 }}>
<Link to="/"><Button type="link" icon={<HomeOutlined />}>返回首页</Button></Link>
<div className="preview-container">
<div className="password-protection-modal">
<div className="password-modal-content">
<div className="password-icon-large">
<LockOutlined style={{ fontSize: 36 }} />
</div>
<h2>此会议受密码保护</h2>
<p>请输入访问密码以查看会议纪要</p>
<div className="password-input-group">
<Input.Password
className={`password-input${passwordError ? ' error' : ''}`}
placeholder="访问密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (passwordError) {
setPasswordError('');
}
}}
onPressEnter={handleVerify}
/>
</div>
{passwordError ? <div className="password-error-message">{passwordError}</div> : null}
<button type="button" className="password-verify-btn" onClick={handleVerify}>
<EyeOutlined style={{ marginRight: 8 }} />
立即查看
</button>
<div style={{ marginTop: 24 }}>
<Link to="/"><Button type="link" icon={<HomeOutlined />}>返回首页</Button></Link>
</div>
</div>
</Card>
</div>
</div>
);
}
return (
<Layout style={{ minHeight: '100vh', background: '#f0f2f5' }}>
<Header style={{ background: '#fff', padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}>
<Space>
<BrandLogo title="iMeeting 会议预览" size={28} titleSize={18} gap={10} />
</Space>
<Link to="/"><Button icon={<LoginOutlined />}>登录系统</Button></Link>
</Header>
<Content style={{ padding: '40px 20px', maxWidth: 900, margin: '0 auto', width: '100%' }}>
<Card variant="borderless" style={{ borderRadius: 16 }}>
<Title level={2}>{meeting.title}</Title>
<Space wrap style={{ marginBottom: 20 }}>
{meeting.tags?.map(t => <Tag key={t.id} color="blue">{t.name}</Tag>)}
<Text type="secondary"><CalendarOutlined /> {tools.formatDateTime(meeting.meeting_time)}</Text>
</Space>
<Divider />
<div className="preview-content">
{meeting.summary ? (
<MarkdownRenderer content={meeting.summary} />
) : (
<Empty description="暂无会议总结内容" />
)}
<Layout style={{ minHeight: '100vh' }}>
<Content className="preview-container">
<div className="preview-content">
<h1 className="preview-title">{meeting.title}</h1>
<div className="meeting-info-section">
<h2 className="section-title">会议信息</h2>
<div className="info-item"><strong>创建人</strong>{meeting.creator_username || '未知'}</div>
<div className="info-item"><strong>会议时间</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
<div className="info-item"><strong>参会人员</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
<div className="info-item"><strong>计算人数</strong>{meeting.attendees_count || meeting.attendees?.length || 0}</div>
<div className="info-item"><strong>总结模板</strong>{meeting.prompt_name || '默认模板'}</div>
{meeting.tags?.length ? (
<div className="info-item">
<strong>标签</strong>
<Space wrap style={{ marginLeft: 8 }}>
{meeting.tags.map((tag) => <Tag key={tag.id || tag.name} color="blue">{tag.name}</Tag>)}
</Space>
</div>
) : null}
</div>
</Card>
<div className="action-buttons">
<button type="button" className="action-btn copy-btn" onClick={handleCopyLink}>
<CopyOutlined />
复制链接
</button>
<button type="button" className="action-btn share-btn" onClick={handleShare}>
<ShareAltOutlined />
立即分享
</button>
</div>
<div className="summary-section">
<Tabs
className="preview-tabs"
items={[
{
key: 'summary',
label: (
<Space size={8}>
<FileTextOutlined />
<span>会议总结</span>
</Space>
),
children: (
<div className="summary-content">
{meeting.summary ? <MarkdownRenderer content={meeting.summary} /> : <Empty description="暂无会议总结内容" />}
</div>
),
},
{
key: 'transcript',
label: (
<Space size={8}>
<AudioOutlined />
<span>会议转录</span>
</Space>
),
children: (
<div className="transcript-wrapper">
{audioUrl ? (
<div className="preview-audio-player">
<audio
ref={audioRef}
src={audioUrl}
controls
controlsList="nodownload noplaybackrate"
onTimeUpdate={handleTimeUpdate}
style={{ width: '100%' }}
/>
</div>
) : null}
{transcript.length ? (
<div className="transcript-list">
{transcript.map((segment, index) => (
<div
key={segment.segment_id}
className={`transcript-segment${activeSegmentIndex === index ? ' active' : ''}`}
onClick={() => jumpToSegment(segment)}
>
<div className="segment-header">
<span className="speaker-name">
<UserOutlined style={{ marginRight: 6 }} />
{segment.speaker_tag || `发言人 ${segment.speaker_id}`}
</span>
<span className="segment-time">{tools.formatDuration((segment.start_time_ms || 0) / 1000)}</span>
</div>
<div className="segment-text">{segment.text_content}</div>
</div>
))}
</div>
) : (
<div className="empty-transcript">暂无转录内容</div>
)}
</div>
),
},
{
key: 'mindmap',
label: (
<Space size={8}>
<PartitionOutlined />
<span>思维导图</span>
</Space>
),
children: (
<div className="mindmap-wrapper">
<MindMap content={meeting.summary} title={meeting.title} />
</div>
),
},
]}
/>
</div>
<div className="preview-footer">
{branding.footer_text}
</div>
</div>
</Content>
</Layout>
);

View File

@ -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;
}
}