解决safari播放问题

main
mula.liu 2025-12-26 16:59:05 +08:00
parent 201a7f2433
commit b315eaaf3b
20 changed files with 1329 additions and 267 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
dist.zip

Binary file not shown.

View File

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iMeeting</title> <title>iMeeting - 智能会议助手</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/.DS_Store vendored 100644

Binary file not shown.

26
public/favicon.svg 100644
View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="100%" stop-color="#764ba2"/>
</linearGradient>
</defs>
<!-- 背景圆 -->
<circle cx="64" cy="64" r="60" fill="url(#bg)"/>
<!-- 三个人物圆圈 - 极简设计 -->
<circle cx="42" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
<circle cx="64" cy="48" r="13" fill="#ffffff"/>
<circle cx="86" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
<!-- AI对话气泡 -->
<g>
<!-- 气泡主体 -->
<rect x="36" y="74" width="56" height="26" rx="13" fill="#ffffff"/>
<!-- 气泡尾巴 -->
<path d="M 58 100 L 52 106 L 54 100 Z" fill="#ffffff"/>
<!-- AI文字 -->
<text x="52" y="91" fill="#667eea" font-size="16" font-weight="bold" font-family="system-ui, -apple-system, sans-serif">AI</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,11 +1,8 @@
import React from 'react'; import React from 'react';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import { FileText, Brain } from 'lucide-react'; import { FileText, Brain } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import MindMap from './MindMap'; import MindMap from './MindMap';
import MarkdownRenderer from './MarkdownRenderer';
import './ContentViewer.css'; import './ContentViewer.css';
const { TabPane } = Tabs; const { TabPane } = Tabs;
@ -48,16 +45,11 @@ const ContentViewer = ({
{summaryActions && <div className="tab-actions">{summaryActions}</div>} {summaryActions && <div className="tab-actions">{summaryActions}</div>}
</div> </div>
<div className="content-markdown"> <div className="content-markdown">
{content ? ( <MarkdownRenderer
<ReactMarkdown content={content}
remarkPlugins={[remarkGfm]} className=""
rehypePlugins={[rehypeRaw, rehypeSanitize]} emptyMessage={emptyMessage}
> />
{content}
</ReactMarkdown>
) : (
<div className="empty-content">{emptyMessage}</div>
)}
</div> </div>
</TabPane> </TabPane>

View File

@ -2,10 +2,7 @@ import React, { useState, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror'; import CodeMirror from '@uiw/react-codemirror';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import ReactMarkdown from 'react-markdown'; import MarkdownRenderer from './MarkdownRenderer';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import './MarkdownEditor.css'; import './MarkdownEditor.css';
const MarkdownEditor = ({ const MarkdownEditor = ({
@ -193,14 +190,11 @@ const MarkdownEditor = ({
</div> </div>
{showPreview ? ( {showPreview ? (
<div className="markdown-preview"> <MarkdownRenderer
<ReactMarkdown content={value}
remarkPlugins={[remarkGfm]} className="markdown-preview"
rehypePlugins={[rehypeRaw, rehypeSanitize]} emptyMessage="*暂无内容*"
> />
{value || '*暂无内容*'}
</ReactMarkdown>
</div>
) : ( ) : (
<CodeMirror <CodeMirror
ref={editorRef} ref={editorRef}

View File

@ -0,0 +1,285 @@
/* Unified Markdown Renderer Styles */
.markdown-renderer {
font-size: 1rem;
line-height: 1.8;
color: #475569;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.markdown-empty {
text-align: center;
color: #94a3b8;
font-style: italic;
padding: 2rem;
}
/* Headings */
.markdown-renderer h1 {
color: #1e293b;
font-size: 1.5rem;
margin: 1.5rem 0 0.75rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h1:first-child {
margin-top: 0;
}
.markdown-renderer h2 {
color: #374151;
font-size: 1.375rem;
margin: 1.25rem 0 0.625rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h2:first-child {
margin-top: 0;
}
.markdown-renderer h3 {
color: #475569;
font-size: 1.25rem;
margin: 1.125rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h3:first-child {
margin-top: 0;
}
.markdown-renderer h4 {
color: #475569;
font-size: 1.125rem;
margin: 1rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h5,
.markdown-renderer h6 {
color: #475569;
font-size: 1rem;
margin: 0.875rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
/* Paragraphs */
.markdown-renderer p {
margin: 0.75rem 0;
color: #475569;
line-height: 1.7;
}
.markdown-renderer p:first-child {
margin-top: 0;
}
.markdown-renderer p:last-child {
margin-bottom: 0;
}
/* Lists */
.markdown-renderer ul,
.markdown-renderer ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.markdown-renderer li {
margin: 0.5rem 0;
line-height: 1.6;
color: #475569;
}
.markdown-renderer li p {
margin: 0.25rem 0;
}
/* Text formatting */
.markdown-renderer strong {
color: #1e293b;
font-weight: 600;
}
.markdown-renderer em {
font-style: italic;
}
/* Inline code */
.markdown-renderer code {
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.875rem;
color: #e11d48;
border: 1px solid #e2e8f0;
}
/* Code blocks */
.markdown-renderer pre {
background: #1e293b;
padding: 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.25rem 0;
border: 1px solid #334155;
}
.markdown-renderer pre code {
background: transparent;
padding: 0;
color: #e2e8f0;
border: none;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.markdown-renderer blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
margin: 1rem 0;
padding: 1rem 1.25rem;
font-style: italic;
border-radius: 0 8px 8px 0;
}
.markdown-renderer blockquote p {
margin: 0.5rem 0;
}
/* Tables */
.markdown-renderer table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.markdown-renderer th,
.markdown-renderer td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
.markdown-renderer th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.markdown-renderer tbody tr:nth-child(even) {
background: #fafbfc;
}
.markdown-renderer tbody tr:hover {
background: #f1f5f9;
transition: background 0.2s ease;
}
/* Links */
.markdown-renderer a {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.markdown-renderer a:hover {
color: #2563eb;
border-bottom-color: #2563eb;
}
/* Horizontal rules */
.markdown-renderer hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 2rem 0;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
}
/* Images */
.markdown-renderer img {
max-width: 100%;
width: auto;
height: auto;
display: block;
margin: 1.25rem auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
object-fit: contain;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.markdown-renderer {
font-size: 0.875rem;
}
.markdown-renderer h1 {
font-size: 1.25rem;
}
.markdown-renderer h2 {
font-size: 1.125rem;
}
.markdown-renderer h3 {
font-size: 1rem;
}
.markdown-renderer h4,
.markdown-renderer h5,
.markdown-renderer h6 {
font-size: 0.9375rem;
}
.markdown-renderer pre {
padding: 0.9375rem;
font-size: 0.75rem;
}
.markdown-renderer table {
font-size: 0.8125rem;
}
.markdown-renderer th,
.markdown-renderer td {
padding: 0.5rem;
}
.markdown-renderer img {
margin: 0.9375rem auto;
border-radius: 6px;
}
}
/* Print styles */
@media print {
.markdown-renderer pre {
background: #f5f5f5;
border: 1px solid #ddd;
}
.markdown-renderer pre code {
color: #000;
}
.markdown-renderer a::after {
content: " (" attr(href) ")";
font-size: 0.85em;
color: #666;
}
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import './MarkdownRenderer.css';
/**
* 统一的Markdown渲染组件
*
* @param {string} content - Markdown内容
* @param {string} className - 自定义CSS类名可选
* @param {string} emptyMessage - 内容为空时显示的消息可选
*/
const MarkdownRenderer = ({ content, className = '', emptyMessage = '暂无内容' }) => {
if (!content || content.trim() === '') {
return <div className="markdown-empty">{emptyMessage}</div>;
}
return (
<div className={`markdown-renderer ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{content}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;

View File

@ -1,13 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Clock, Users, FileText, User, Edit, Calendar , Trash2, MoreVertical } from 'lucide-react'; 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';
import rehypeSanitize from 'rehype-sanitize';
import TagDisplay from './TagDisplay'; import TagDisplay from './TagDisplay';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
import MarkdownRenderer from './MarkdownRenderer';
import tools from '../utils/tools'; import tools from '../utils/tools';
import './MeetingTimeline.css'; import './MeetingTimeline.css';
@ -151,14 +148,10 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
<span>会议摘要</span> <span>会议摘要</span>
</div> </div>
<div className="summary-content"> <div className="summary-content">
<div className="markdown-content"> <MarkdownRenderer
<ReactMarkdown content={tools.truncateSummary(meeting.summary)}
remarkPlugins={[remarkGfm]} className="markdown-content"
rehypePlugins={[rehypeRaw, rehypeSanitize]} />
>
{tools.truncateSummary(meeting.summary)}
</ReactMarkdown>
</div>
{shouldShowMoreButton(meeting.summary) && ( {shouldShowMoreButton(meeting.summary) && (
<div className="summary-more-hint"> <div className="summary-more-hint">
<span className="more-text">点击查看完整摘要</span> <span className="more-text">点击查看完整摘要</span>

View File

@ -38,6 +38,7 @@ const API_CONFIG = {
SYSTEM_CONFIG: '/api/admin/system-config', SYSTEM_CONFIG: '/api/admin/system-config',
DASHBOARD_STATS: '/api/admin/dashboard/stats', DASHBOARD_STATS: '/api/admin/dashboard/stats',
ONLINE_USERS: '/api/admin/online-users', ONLINE_USERS: '/api/admin/online-users',
USER_STATS: '/api/admin/user-stats',
KICK_USER: (userId) => `/api/admin/kick-user/${userId}`, KICK_USER: (userId) => `/api/admin/kick-user/${userId}`,
TASKS_MONITOR: '/api/admin/tasks/monitor', TASKS_MONITOR: '/api/admin/tasks/monitor',
SYSTEM_RESOURCES: '/api/admin/system/resources' SYSTEM_RESOURCES: '/api/admin/system/resources'

View File

@ -412,6 +412,46 @@
background: #94a3b8; background: #94a3b8;
} }
/* 全宽面板 */
.admin-panel.full-width {
margin-bottom: 2rem;
}
/* 用户列表表格 */
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table thead {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
.users-table th {
padding: 1rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.users-table tbody tr {
border-bottom: 1px solid #e2e8f0;
transition: background-color 0.2s;
}
.users-table tbody tr:hover {
background: #f8fafc;
}
.users-table td {
padding: 1rem;
font-size: 0.875rem;
color: #1e293b;
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.stats-grid { .stats-grid {

View File

@ -43,6 +43,7 @@ const AdminDashboard = ({ user, onLogout }) => {
// //
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [onlineUsers, setOnlineUsers] = useState([]); const [onlineUsers, setOnlineUsers] = useState([]);
const [usersList, setUsersList] = useState([]);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [resources, setResources] = useState(null); const [resources, setResources] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -127,6 +128,7 @@ const AdminDashboard = ({ user, onLogout }) => {
await Promise.all([ await Promise.all([
fetchStats(), fetchStats(),
fetchOnlineUsers(), fetchOnlineUsers(),
fetchUsersList(),
fetchTasks(), fetchTasks(),
fetchResources() fetchResources()
]); ]);
@ -161,6 +163,17 @@ const AdminDashboard = ({ user, onLogout }) => {
} }
}; };
const fetchUsersList = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
if (response.code === '200') {
setUsersList(response.data.users || []);
}
} catch (err) {
console.error('获取用户列表失败:', err);
}
};
const fetchTasks = async () => { const fetchTasks = async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -395,6 +408,45 @@ const AdminDashboard = ({ user, onLogout }) => {
</div> </div>
)} )}
{/* 用户列表 */}
<div className="admin-panel full-width">
<div className="panel-header">
<h2>用户列表 ({usersList.length})</h2>
</div>
<div className="panel-content">
{usersList.length === 0 ? (
<div className="empty-state">暂无用户数据</div>
) : (
<table className="users-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>姓名</th>
<th>注册时间</th>
<th>最新登录时间</th>
<th>会议数量</th>
<th>会议时长</th>
</tr>
</thead>
<tbody>
{usersList.map(u => (
<tr key={u.user_id}>
<td>{u.user_id}</td>
<td>{u.username}</td>
<td>{u.caption}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '-'}</td>
<td>{u.last_login_time ? new Date(u.last_login_time).toLocaleString('zh-CN') : '-'}</td>
<td>{u.meeting_count || 0}</td>
<td>{u.total_duration_formatted || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
<div className="admin-content-grid"> <div className="admin-content-grid">
{/* 在线用户列表 */} {/* 在线用户列表 */}
<div className="admin-panel"> <div className="admin-panel">

View File

@ -304,6 +304,10 @@
.form-group input[type="text"]:focus, .form-group input[type="text"]:focus,
.form-group input[type="password"]:focus { outline: none; border-color: #8a63d2; box-shadow: 0 0 0 4px rgba(111, 66, 193, 0.1); } .form-group input[type="password"]:focus { outline: none; border-color: #8a63d2; box-shadow: 0 0 0 4px rgba(111, 66, 193, 0.1); }
.form-group.password-group { position: relative; margin-bottom: 0.5rem; } .form-group.password-group { position: relative; margin-bottom: 0.5rem; }
.password-input-wrapper { position: relative; display: flex; align-items: center; }
.password-input-wrapper input { width: 100%; padding-right: 45px; }
.password-toggle-btn { position: absolute; right: 12px; background: none; border: none; color: #888; cursor: pointer; padding: 8px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s ease; }
.password-toggle-btn:hover { background: #f1f3f5; color: #333; }
.remember-me-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 400; color: #555; font-size: 0.9rem; margin-top: 0.5rem; align-self: flex-end; } .remember-me-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 400; color: #555; font-size: 0.9rem; margin-top: 0.5rem; align-self: flex-end; }
.remember-me-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent-color); } .remember-me-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent-color); }
.remember-me-label span { user-select: none; } .remember-me-label span { user-select: none; }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Brain, Users, Calendar, TrendingUp, X, User, Lock, Library, Download, LogIn } from 'lucide-react'; import { Brain, Users, Calendar, TrendingUp, X, User, Lock, Library, Download, LogIn, Eye, EyeOff } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
@ -11,6 +11,7 @@ const HomePage = ({ onLogin }) => {
const [loginError, setLoginError] = useState(''); const [loginError, setLoginError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// localStorage // localStorage
useEffect(() => { useEffect(() => {
@ -181,15 +182,25 @@ const HomePage = ({ onLogin }) => {
<Lock size={18} /> <Lock size={18} />
密码 密码
</label> </label>
<input <div className="password-input-wrapper">
type="password" <input
id="password" type={showPassword ? "text" : "password"}
name="password" id="password"
value={loginForm.password} name="password"
onChange={handleInputChange} value={loginForm.password}
placeholder="请输入密码" onChange={handleInputChange}
required placeholder="请输入密码"
/> required
/>
<button
type="button"
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "隐藏密码" : "显示密码"}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<label htmlFor="rememberMe" className="remember-me-label"> <label htmlFor="rememberMe" className="remember-me-label">
<input <input
type="checkbox" type="checkbox"

View File

@ -1540,6 +1540,71 @@
font-size: 1.125rem; font-size: 1.125rem;
} }
/* 模板选择器样式 */
.prompt-selector {
margin-bottom: 1.5rem;
}
.prompt-label {
display: block;
color: #475569;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.75rem;
}
.prompt-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.prompt-tab {
position: relative;
padding: 0.5rem 1rem;
background: white;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.prompt-tab:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.prompt-tab.active {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
}
.default-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.prompt-tab.active .default-badge {
background: rgba(255, 255, 255, 0.3);
}
.prompt-tab:not(.active) .default-badge {
background: #f1f5f9;
color: #667eea;
}
.input-description { .input-description {
color: #64748b; color: #64748b;
font-size: 0.875rem; font-size: 0.875rem;
@ -1665,6 +1730,11 @@
padding: 1.5rem; padding: 1.5rem;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
font-size: 15px;
line-height: 1.8;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
} }
.summary-history-list { .summary-history-list {

View File

@ -4,12 +4,9 @@ import apiClient from '../utils/apiClient';
import configService from '../utils/configService'; import configService from '../utils/configService';
import tools from '../utils/tools'; import tools from '../utils/tools';
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload, ChevronLeft, ChevronRight, Loader, Lock, Unlock, Eye, EyeOff, Copy, Check } from 'lucide-react'; import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload, ChevronLeft, ChevronRight, Loader, Lock, Unlock, Eye, EyeOff, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api'; import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import ContentViewer from '../components/ContentViewer'; import ContentViewer from '../components/ContentViewer';
import MarkdownRenderer from '../components/MarkdownRenderer';
import TagDisplay from '../components/TagDisplay'; import TagDisplay from '../components/TagDisplay';
import ConfirmDialog from '../components/ConfirmDialog'; import ConfirmDialog from '../components/ConfirmDialog';
import Toast from '../components/Toast'; import Toast from '../components/Toast';
@ -55,6 +52,8 @@ const MeetingDetails = ({ user }) => {
const [summaryResult, setSummaryResult] = useState(null); const [summaryResult, setSummaryResult] = useState(null);
const [userPrompt, setUserPrompt] = useState(''); const [userPrompt, setUserPrompt] = useState('');
const [summaryHistory, setSummaryHistory] = useState([]); const [summaryHistory, setSummaryHistory] = useState([]);
const [promptList, setPromptList] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
const [summaryTaskId, setSummaryTaskId] = useState(null); const [summaryTaskId, setSummaryTaskId] = useState(null);
const [summaryTaskStatus, setSummaryTaskStatus] = useState(null); const [summaryTaskStatus, setSummaryTaskStatus] = useState(null);
@ -118,6 +117,7 @@ const MeetingDetails = ({ user }) => {
useEffect(() => { useEffect(() => {
fetchMeetingDetails(); fetchMeetingDetails();
loadFileSizeConfig(); loadFileSizeConfig();
fetchPromptList();
// Cleanup interval on unmount // Cleanup interval on unmount
return () => { return () => {
@ -382,6 +382,24 @@ const MeetingDetails = ({ user }) => {
} }
}; };
//
const fetchPromptList = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK')));
const prompts = response.data.prompts || []; // data.prompts
setPromptList(prompts);
//
const defaultPrompt = prompts.find(p => p.is_default) || prompts[0];
if (defaultPrompt) {
setSelectedPromptId(defaultPrompt.id);
}
} catch (error) {
console.warn('Failed to load prompt list:', error);
setPromptList([]);
}
};
// / // /
const fetchNavigationInfo = async () => { const fetchNavigationInfo = async () => {
try { try {
@ -596,6 +614,10 @@ const MeetingDetails = ({ user }) => {
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
if (audioRef.current) { if (audioRef.current) {
setDuration(audioRef.current.duration); setDuration(audioRef.current.duration);
// Safaripreload="metadata"onCanPlay
setAudioLoading(false);
setAudioCanPlay(true);
setAudioError(null);
} }
}; };
@ -882,17 +904,18 @@ const MeetingDetails = ({ user }) => {
// AI - 使API // AI - 使API
const generateSummary = async () => { const generateSummary = async () => {
if (summaryLoading) return; if (summaryLoading) return;
setSummaryLoading(true); setSummaryLoading(true);
setSummaryTaskProgress(0); setSummaryTaskProgress(0);
setSummaryTaskMessage('正在启动AI分析...'); setSummaryTaskMessage('正在启动AI分析...');
setSummaryTaskStatus('pending'); setSummaryTaskStatus('pending');
try { try {
const baseUrl = ""; const baseUrl = "";
// 使API // 使API prompt_id
const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary-async`, { const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary-async`, {
user_prompt: userPrompt user_prompt: userPrompt,
prompt_id: selectedPromptId
}); });
const taskId = response.data.task_id; const taskId = response.data.task_id;
@ -1383,6 +1406,7 @@ const MeetingDetails = ({ user }) => {
<audio <audio
key={audioUrl || 'no-audio'} // 使audioUrlkeyaudio key={audioUrl || 'no-audio'} // 使audioUrlkeyaudio
ref={audioRef} ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay} onCanPlay={handleCanPlay}
@ -1392,11 +1416,8 @@ const MeetingDetails = ({ user }) => {
onPlaying={handlePlaying} onPlaying={handlePlaying}
onError={handleAudioError} onError={handleAudioError}
onEnded={handleEnded} onEnded={handleEnded}
preload="auto" preload="metadata"
> />
<source src={audioUrl} type="audio/mpeg" />
您的浏览器不支持音频播放
</audio>
{/* 转录状态显示 */} {/* 转录状态显示 */}
{transcriptionStatus && ( {transcriptionStatus && (
@ -1845,8 +1866,29 @@ const MeetingDetails = ({ user }) => {
<div className="summary-modal-content"> <div className="summary-modal-content">
<div className="summary-input-section"> <div className="summary-input-section">
<h4>生成新的总结</h4> <h4>生成新的总结</h4>
{/* 模板选择器 */}
{promptList.length > 0 && (
<div className="prompt-selector">
<label className="prompt-label">选择总结模板</label>
<div className="prompt-tabs">
{promptList.map(prompt => (
<button
key={prompt.id}
className={`prompt-tab ${selectedPromptId === prompt.id ? 'active' : ''}`}
onClick={() => setSelectedPromptId(prompt.id)}
title={prompt.description}
>
{prompt.name}
{!!prompt.is_default && <span className="default-badge">默认</span>}
</button>
))}
</div>
</div>
)}
<p className="input-description"> <p className="input-description">
系统将使用通用提示词分析会议转录您可以添加额外要求 您可以添加额外要求
</p> </p>
<textarea <textarea
value={userPrompt} value={userPrompt}
@ -1911,14 +1953,10 @@ const MeetingDetails = ({ user }) => {
<div className="summary-result-header"> <div className="summary-result-header">
<h4>最新生成的总结</h4> <h4>最新生成的总结</h4>
</div> </div>
<div className="summary-result-content"> <MarkdownRenderer
<ReactMarkdown content={summaryResult.content}
remarkPlugins={[remarkGfm]} className="summary-result-content"
rehypePlugins={[rehypeRaw, rehypeSanitize]} />
>
{summaryResult.content}
</ReactMarkdown>
</div>
</div> </div>
)} )}

View File

@ -131,10 +131,71 @@
.section-title { .section-title {
color: #374151; color: #374151;
font-size: 20px; font-size: 20px;
margin: 0 0 20px; margin: 0 0 16px;
padding-bottom: 10px; padding-bottom: 10px;
} }
/* 操作按钮行 */
.action-buttons {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn svg {
flex-shrink: 0;
}
/* 复制按钮 */
.copy-btn {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
}
.copy-btn:hover {
background: linear-gradient(135deg, #5a67d8, #6b46c1);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35);
}
.copy-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.25);
}
/* 分享按钮 */
.share-btn {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25);
}
.share-btn:hover {
background: linear-gradient(135deg, #059669, #047857);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35);
}
.share-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.25);
}
.info-item { .info-item {
margin: 12px 0; margin: 12px 0;
font-size: 16px; font-size: 16px;
@ -220,147 +281,6 @@
color: #dc2626; color: #dc2626;
} }
/* Markdown 样式 */
.summary-content h1 {
color: #1e293b;
font-size: 22px;
margin: 25px 0 15px;
font-weight: 600;
}
.summary-content h2 {
color: #374151;
font-size: 20px;
margin: 20px 0 12px;
font-weight: 600;
}
.summary-content h3 {
color: #475569;
font-size: 18px;
margin: 18px 0 10px;
font-weight: 600;
}
.summary-content p {
margin: 12px 0;
color: #475569;
}
.summary-content ul,
.summary-content ol {
margin: 12px 0;
padding-left: 25px;
}
.summary-content li {
margin: 8px 0;
color: #475569;
}
.summary-content strong {
color: #1e293b;
font-weight: 600;
}
.summary-content blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
margin: 15px 0;
padding: 15px 20px;
font-style: italic;
}
.summary-content code {
background: #f1f5f9;
padding: 3px 8px;
border-radius: 4px;
font-size: 14px;
font-family: "Courier New", "Consolas", "Monaco", monospace;
color: #e11d48;
border: 1px solid #e2e8f0;
}
.summary-content pre {
background: #1e293b;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid #334155;
position: relative;
}
.summary-content pre code {
background: transparent;
padding: 0;
color: #e2e8f0;
border: none;
font-size: 13px;
line-height: 1.6;
}
.summary-content img {
max-width: 100%;
width: auto;
height: auto;
display: block;
margin: 20px auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
object-fit: contain;
}
.summary-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.summary-content th,
.summary-content td {
border: 1px solid #e2e8f0;
padding: 12px;
text-align: left;
}
.summary-content th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.summary-content tbody tr:nth-child(even) {
background: #fafbfc;
}
.summary-content tbody tr:hover {
background: #f1f5f9;
transition: background 0.2s ease;
}
.summary-content a {
color: #2563eb;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.summary-content a:hover {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
.summary-content hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 30px 0;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
}
.preview-footer { .preview-footer {
margin-top: 40px; margin-top: 40px;
padding-top: 20px; padding-top: 20px;
@ -429,43 +349,6 @@
font-size: 14px; font-size: 14px;
padding: 15px 0; padding: 15px 0;
} }
.summary-content h1 {
font-size: 18px;
}
.summary-content h2 {
font-size: 16px;
}
.summary-content h3 {
font-size: 15px;
}
.summary-content pre {
padding: 15px;
font-size: 12px;
}
.summary-content table {
font-size: 13px;
}
.summary-content th,
.summary-content td {
padding: 8px;
}
.summary-content img {
margin: 15px auto;
max-width: 100%;
border-radius: 6px;
}
.summary-content pre {
border-radius: 6px;
margin: 15px 0;
}
} }
/* 平板适配 */ /* 平板适配 */
@ -689,6 +572,212 @@
box-shadow: none; box-shadow: none;
} }
/* ========================================
======================================== */
.transcript-wrapper {
padding: 20px 0;
}
.preview-audio-player {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.preview-player-controls {
display: flex;
align-items: center;
gap: 32px;
flex-wrap: nowrap;
min-width: 0;
}
.preview-play-btn {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
outline: none;
}
.preview-play-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.preview-play-btn:active {
transform: scale(0.95);
}
.preview-progress-wrapper {
flex: 1;
min-width: 0;
overflow: visible;
}
.preview-time-slider {
display: flex;
align-items: center;
}
.preview-slider-track {
flex: 1;
height: 50px;
display: flex;
align-items: center;
cursor: pointer;
position: relative;
-webkit-tap-highlight-color: transparent;
touch-action: none;
user-select: none;
}
.preview-slider-track::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.preview-slider-fill {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
transition: width 0.1s ease;
pointer-events: none;
}
.preview-slider-thumb {
position: absolute;
right: 0;
top: 50%;
transform: translate(50%, -50%);
cursor: grab;
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.preview-slider-thumb:active {
cursor: grabbing;
}
.preview-current-time {
background: white;
color: #667eea;
font-size: 12px;
font-weight: 700;
padding: 5px 10px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
white-space: nowrap;
position: relative;
}
.preview-current-time::after {
content: '';
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid white;
}
.preview-slider-thumb::after {
content: '';
width: 16px;
height: 16px;
background: white;
border: 3px solid rgba(255, 255, 255, 0.5);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.transcript-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
overflow-y: auto;
}
.transcript-segment {
background: #f8fafc;
padding: 16px;
border-radius: 8px;
border-left: 3px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.transcript-segment:hover {
background: #f1f5f9;
}
.transcript-segment.active {
background: #eff6ff;
border-left-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
.segment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.speaker-name {
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.segment-time {
font-size: 12px;
color: #64748b;
}
.segment-text {
color: #475569;
font-size: 14px;
line-height: 1.6;
}
.empty-transcript {
text-align: center;
padding: 60px 20px;
color: #64748b;
font-size: 16px;
}
/* 移动端密码界面优化 */ /* 移动端密码界面优化 */
@media (max-width: 768px) { @media (max-width: 768px) {
.password-modal-content { .password-modal-content {
@ -719,4 +808,35 @@
padding: 12px 20px; padding: 12px 20px;
font-size: 15px; font-size: 15px;
} }
/* 预览页面播放器移动端优化 */
.preview-audio-player {
padding: 16px;
}
.preview-player-controls {
gap: 12px;
}
.preview-progress-wrapper {
width: 100%;
}
.preview-current-time {
font-size: 11px;
padding: 4px 8px;
}
.preview-current-time::after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid white;
bottom: -3px;
}
.preview-slider-thumb::after {
width: 14px;
height: 14px;
border-width: 2px;
}
} }

View File

@ -1,12 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { Lock, Eye, EyeOff, AlertCircle, Copy, Check, Share2, Play, Pause } from 'lucide-react';
import MindMap from '../components/MindMap'; import MindMap from '../components/MindMap';
import MarkdownRenderer from '../components/MarkdownRenderer';
import './MeetingPreview.css'; import './MeetingPreview.css';
const { TabPane } = Tabs; const { TabPane } = Tabs;
@ -17,6 +14,16 @@ const MeetingPreview = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network' const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network'
const [copied, setCopied] = useState(false);
const [shared, setShared] = useState(false);
//
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = React.useRef(null);
// //
const [isPasswordProtected, setIsPasswordProtected] = useState(false); const [isPasswordProtected, setIsPasswordProtected] = useState(false);
@ -54,6 +61,8 @@ const MeetingPreview = () => {
} else { } else {
setMeetingData(result.data); setMeetingData(result.data);
setIsPasswordProtected(false); setIsPasswordProtected(false);
//
fetchTranscriptAndAudio();
} }
setError(null); setError(null);
setErrorType(''); setErrorType('');
@ -76,6 +85,40 @@ const MeetingPreview = () => {
} }
}; };
const fetchTranscriptAndAudio = async () => {
try {
//
const transcriptResponse = await fetch(`/api/meetings/${meeting_id}/transcript`);
if (transcriptResponse.ok) {
const transcriptResult = await transcriptResponse.json();
if (transcriptResult.code === '200' && transcriptResult.data) {
//
const formattedSegments = transcriptResult.data.map(seg => ({
segment_id: seg.segment_id,
speaker_label: seg.speaker_tag || `发言人 ${seg.speaker_id}`,
start_time: seg.start_time_ms / 1000.0, //
end_time: seg.end_time_ms / 1000.0, //
text: seg.text_content
}));
setTranscript(formattedSegments);
}
}
// URL
const audioResponse = await fetch(`/api/meetings/${meeting_id}/audio`);
if (audioResponse.ok) {
const audioResult = await audioResponse.json();
if (audioResult.code === '200' && audioResult.data) {
// 使streamaudio URL
const audioUrl = `/api/meetings/${meeting_id}/audio/stream`;
setAudioUrl(audioUrl);
}
}
} catch (err) {
console.error('获取转录或音频失败:', err);
}
};
const handlePasswordVerify = async () => { const handlePasswordVerify = async () => {
if (!passwordInput.trim()) { if (!passwordInput.trim()) {
setPasswordError('请输入访问密码'); setPasswordError('请输入访问密码');
@ -126,6 +169,99 @@ const MeetingPreview = () => {
} }
}; };
//
const handleCopySummary = async () => {
try {
// Markdown
const plainText = meetingData.summary
.replace(/#+\s/g, '') //
.replace(/\*\*/g, '') //
.replace(/\*/g, '') //
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') //
.replace(/`/g, ''); //
// Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(plainText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// 使
const textArea = document.createElement('textarea');
textArea.value = plainText;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制失败:', err);
}
};
//
const handleShare = async () => {
const shareUrl = window.location.href;
const shareTitle = `${meetingData.title} - 会议总结`;
const shareText = `查看会议总结:${meetingData.title}`;
try {
// 使 Web Share API
if (navigator.share && typeof navigator.share === 'function') {
await navigator.share({
title: shareTitle,
text: shareText,
url: shareUrl
});
setShared(true);
setTimeout(() => setShared(false), 2000);
return;
}
} catch (err) {
//
if (err.name === 'AbortError') {
return;
}
console.warn('Web Share API 失败,使用降级方案:', err);
}
//
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
setShared(true);
setTimeout(() => setShared(false), 2000);
} else {
// 使 execCommand
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
setShared(true);
setTimeout(() => setShared(false), 2000);
}
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制链接失败:', err);
alert('分享链接:' + shareUrl);
}
};
const formatDateTime = (dateTime) => { const formatDateTime = (dateTime) => {
if (!dateTime) return ''; if (!dateTime) return '';
const date = new Date(dateTime); const date = new Date(dateTime);
@ -140,6 +276,128 @@ const MeetingPreview = () => {
}); });
}; };
//
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
};
const seekTo = (time) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
//
scrollToTranscriptSegment(time);
}
};
//
const scrollToTranscriptSegment = (time) => {
if (transcript.length === 0) return;
//
const segmentIndex = transcript.findIndex(
seg => time >= seg.start_time && time < seg.end_time
);
if (segmentIndex !== -1) {
// DOM
const segmentElements = document.querySelectorAll('.transcript-segment');
if (segmentElements[segmentIndex]) {
segmentElements[segmentIndex].scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
};
//
const handleProgressInteraction = (clientX, element) => {
const rect = element.getBoundingClientRect();
const x = clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
seekTo(percentage * duration);
};
const handleProgressClick = (e) => {
handleProgressInteraction(e.clientX, e.currentTarget);
};
const handleProgressTouch = (e) => {
if (e.touches.length > 0) {
handleProgressInteraction(e.touches[0].clientX, e.currentTarget);
}
};
//
const handleThumbMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
const handleMouseMove = (moveEvent) => {
const track = e.currentTarget.closest('.preview-slider-track');
if (track) {
handleProgressInteraction(moveEvent.clientX, track);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleThumbTouchStart = (e) => {
e.stopPropagation();
const handleTouchMove = (moveEvent) => {
if (moveEvent.touches.length > 0) {
const track = e.currentTarget.closest('.preview-slider-track');
if (track) {
handleProgressInteraction(moveEvent.touches[0].clientX, track);
}
}
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
};
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (loading) { if (loading) {
return ( return (
<div className="preview-container"> <div className="preview-container">
@ -236,6 +494,21 @@ const MeetingPreview = () => {
.map(attendee => attendee.caption) .map(attendee => attendee.caption)
.join('、'); .join('、');
//
let attendeesCount = meetingData.attendees_count || 0;
let isCalculatedFromTranscript = false;
// 0
if (attendeesCount === 0 && transcript.length > 0) {
const uniqueSpeakers = new Set(
transcript
.map(seg => seg.speaker_label)
.filter(label => label && label !== '未知')
);
attendeesCount = uniqueSpeakers.size;
isCalculatedFromTranscript = true;
}
return ( return (
<div className="preview-container"> <div className="preview-container">
<div className="preview-content"> <div className="preview-content">
@ -254,23 +527,59 @@ const MeetingPreview = () => {
{meetingData.creator_username} {meetingData.creator_username}
</div> </div>
<div className="info-item"> <div className="info-item">
<strong>人数</strong> <strong>{isCalculatedFromTranscript ? '计算人数:' : '人数:'}</strong>
{meetingData.attendees_count} {attendeesCount}
</div> </div>
</div> </div>
<div className="summary-section"> <div className="summary-section">
<h2 className="section-title">📝 {meetingData.prompt_name || '会议'} 总结</h2> <h2 className="section-title">📝 {meetingData.prompt_name || '会议'} 总结</h2>
{/* 操作按钮行 */}
<div className="action-buttons">
<button
className="action-btn copy-btn"
onClick={handleCopySummary}
title={copied ? '已复制' : '复制总结'}
>
{copied ? (
<>
<Check size={18} />
<span>已复制</span>
</>
) : (
<>
<Copy size={18} />
<span>复制总结</span>
</>
)}
</button>
<button
className="action-btn share-btn"
onClick={handleShare}
title={shared ? '已复制链接' : '分享链接'}
>
{shared ? (
<>
<Check size={18} />
<span>已复制链接</span>
</>
) : (
<>
<Share2 size={18} />
<span>分享链接</span>
</>
)}
</button>
</div>
<Tabs defaultActiveKey="summary" className="preview-tabs"> <Tabs defaultActiveKey="summary" className="preview-tabs">
<TabPane tab="摘要" key="summary"> <TabPane tab="摘要" key="summary">
<div className="summary-content"> <MarkdownRenderer
<ReactMarkdown content={meetingData.summary}
remarkPlugins={[remarkGfm]} className="summary-content"
rehypePlugins={[rehypeRaw, rehypeSanitize]} />
>
{meetingData.summary}
</ReactMarkdown>
</div>
</TabPane> </TabPane>
<TabPane tab="脑图" key="mindmap"> <TabPane tab="脑图" key="mindmap">
<div className="mindmap-wrapper"> <div className="mindmap-wrapper">
@ -281,6 +590,102 @@ const MeetingPreview = () => {
/> />
</div> </div>
</TabPane> </TabPane>
<TabPane tab="转录" key="transcript">
{audioUrl || transcript.length > 0 ? (
<div className="transcript-wrapper">
{/* 音频播放器 */}
{audioUrl && (
<div className="preview-audio-player">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
console.error('音频加载错误:', e);
console.error('错误详情:', audioRef.current?.error);
if (audioRef.current?.error) {
const error = audioRef.current.error;
let errorMsg = '音频加载失败';
switch (error.code) {
case 1: errorMsg = '音频加载被中止'; break;
case 2: errorMsg = '网络错误'; break;
case 3: errorMsg = '音频解码失败'; break;
case 4: errorMsg = '音频格式不支持'; break;
}
console.error(`${errorMsg} (错误代码: ${error.code})`);
}
}}
preload="metadata"
/>
<div className="preview-player-controls">
<button
className="preview-play-btn"
onClick={togglePlayPause}
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className="preview-progress-wrapper">
<div className="preview-time-slider">
<div
className="preview-slider-track"
onClick={handleProgressClick}
onTouchStart={handleProgressTouch}
onTouchMove={handleProgressTouch}
>
<div
className="preview-slider-fill"
style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
>
<div
className="preview-slider-thumb"
onMouseDown={handleThumbMouseDown}
onTouchStart={handleThumbTouchStart}
>
<span className="preview-current-time">
{formatTime(currentTime)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 转录列表 */}
{transcript.length > 0 ? (
<div className="transcript-list">
{transcript.map((segment, index) => {
const isActive = currentTime >= segment.start_time && currentTime < segment.end_time;
return (
<div
key={index}
className={`transcript-segment ${isActive ? 'active' : ''}`}
onClick={() => seekTo(segment.start_time)}
>
<div className="segment-header">
<span className="speaker-name">{segment.speaker_label || '未知'}</span>
<span className="segment-time">
{formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</span>
</div>
<div className="segment-text">{segment.text}</div>
</div>
);
})}
</div>
) : (
<div className="empty-transcript">暂无转录数据</div>
)}
</div>
) : (
<div className="empty-transcript">暂无转录和音频数据</div>
)}
</TabPane>
</Tabs> </Tabs>
</div> </div>