feat: 添加会议预览功能和相关UI组件

- 在 `MeetingAccessService` 中添加 `isPreviewPasswordRequired` 和 `assertCanPreviewMeeting` 方法
- 在 `PromptTemplateVO` 和 `PromptTemplateDTO` 中添加 `description` 字段
- 在前端添加 `meetingAnalysis.ts` 以处理会议分析数据
- 在 `PromptTemplates.tsx` 中显示模板描述
- 添加 `MeetingPreview.tsx` 组件,支持会议预览和密码验证
dev_na
chenhao 2026-04-15 17:55:57 +08:00
parent 2b30744d2e
commit db310fc803
16 changed files with 1463 additions and 5 deletions

View File

@ -294,6 +294,7 @@ CREATE TABLE biz_prompt_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID (0为系统级)
template_name VARCHAR(100) NOT NULL, -- 模板名称
description VARCHAR(255), -- 模板描述
category VARCHAR(20), -- 分类 (字典: biz_prompt_category)
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
creator_id BIGINT, -- 创建人ID

View File

@ -8,6 +8,7 @@ import lombok.Data;
public class LegacyPromptItemResponse {
private Long id;
private String name;
private String description;
@JsonProperty("is_default")
private Integer isDefault;
@ -16,6 +17,7 @@ public class LegacyPromptItemResponse {
LegacyPromptItemResponse response = new LegacyPromptItemResponse();
response.setId(source.getId());
response.setName(source.getTemplateName());
response.setDescription(source.getDescription());
response.setIsDefault(defaultItem ? 1 : 0);
return response;
}

View File

@ -7,6 +7,7 @@ public class PromptTemplateDTO {
private Long id;
private Long tenantId;
private String templateName;
private String description;
private String category;
private Integer isSystem;
private java.util.List<String> tags;

View File

@ -9,6 +9,7 @@ public class PromptTemplateVO {
private Long tenantId;
private Long creatorId;
private String templateName;
private String description;
private String category;
private Integer isSystem;
private java.util.List<String> tags;

View File

@ -16,6 +16,8 @@ public class PromptTemplate extends BaseEntity {
private String templateName;
private String description;
private String category;
private Integer isSystem;

View File

@ -6,6 +6,10 @@ import com.unisbase.security.LoginUser;
public interface MeetingAccessService {
Meeting requireMeeting(Long meetingId);
boolean isPreviewPasswordRequired(Meeting meeting);
void assertCanPreviewMeeting(Meeting meeting, String accessPassword);
void assertCanViewMeeting(Meeting meeting, LoginUser loginUser);
void assertCanEditMeeting(Meeting meeting, LoginUser loginUser);

View File

@ -22,6 +22,26 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
return meeting;
}
@Override
public boolean isPreviewPasswordRequired(Meeting meeting) {
return normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword()) != null;
}
@Override
public void assertCanPreviewMeeting(Meeting meeting, String accessPassword) {
String expectedPassword = normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword());
if (expectedPassword == null) {
return;
}
String providedPassword = normalizePreviewPassword(accessPassword);
if (providedPassword == null) {
throw new RuntimeException("该会议需要访问密码");
}
if (!expectedPassword.equals(providedPassword)) {
throw new RuntimeException("访问密码错误");
}
}
@Override
public void assertCanViewMeeting(Meeting meeting, LoginUser loginUser) {
if (isPlatformAdmin(loginUser)) {
@ -104,4 +124,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
String target = "," + loginUser.getUserId() + ",";
return ("," + meeting.getParticipants() + ",").contains(target);
}
private String normalizePreviewPassword(String accessPassword) {
if (accessPassword == null) {
return null;
}
String normalized = accessPassword.trim();
return normalized.isEmpty() ? null : normalized;
}
}

View File

@ -163,6 +163,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
private void copyProperties(PromptTemplateDTO dto, PromptTemplate entity) {
entity.setTemplateName(dto.getTemplateName());
entity.setDescription(dto.getDescription());
entity.setCategory(dto.getCategory());
entity.setIsSystem(dto.getIsSystem());
entity.setTenantId(dto.getTenantId());
@ -178,6 +179,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setTenantId(entity.getTenantId());
vo.setCreatorId(entity.getCreatorId());
vo.setTemplateName(entity.getTemplateName());
vo.setDescription(entity.getDescription());
vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem());
vo.setTags(entity.getTags());

View File

@ -51,6 +51,7 @@ unisbase:
permit-all-urls:
- /actuator/health
- /api/static/**
- /api/public/meetings/**
- /ws/**
internal-auth:
enabled: true

View File

@ -210,6 +210,15 @@ export interface MeetingTranscriptVO {
endTime: number;
}
export interface MeetingPreviewAccessVO {
passwordRequired: boolean;
}
export interface PublicMeetingPreviewVO {
meeting: MeetingVO;
transcripts: MeetingTranscriptVO[];
}
export const getMeetingDetail = (id: number) => {
return http.get<{ code: string; data: MeetingVO; msg: string }>(
`/api/biz/meeting/${id}`
@ -222,6 +231,21 @@ export const getTranscripts = (id: number) => {
);
};
export const getMeetingPreviewAccess = (id: number) => {
return http.get<{ code: string; data: MeetingPreviewAccessVO; msg: string }>(
`/api/public/meetings/${id}/preview/access`
);
};
export const getPublicMeetingPreview = (id: number, accessPassword?: string) => {
return http.get<{ code: string; data: PublicMeetingPreviewVO; msg: string }>(
`/api/public/meetings/${id}/preview`,
{
params: accessPassword ? { accessPassword } : undefined,
}
);
};
export interface MeetingSpeakerUpdateDTO {
meetingId: number;
speakerId: string;

View File

@ -5,6 +5,7 @@ export interface PromptTemplateVO {
tenantId: number;
creatorId: number;
templateName: string;
description?: string;
category: string;
isSystem: number;
tags?: string[];
@ -19,6 +20,7 @@ export interface PromptTemplateVO {
export interface PromptTemplateDTO {
id?: number;
templateName: string;
description?: string;
category: string;
isSystem: number;
tags?: string[];

View File

@ -0,0 +1,591 @@
.meeting-preview-page {
--preview-bg:
radial-gradient(circle at top left, rgba(252, 208, 157, 0.24), transparent 32%),
radial-gradient(circle at top right, rgba(82, 164, 255, 0.18), transparent 28%),
linear-gradient(180deg, #fffaf4 0%, #f8f1e7 52%, #efe6da 100%);
--preview-ink: #33261c;
--preview-muted: rgba(72, 53, 39, 0.72);
--preview-line: rgba(84, 57, 31, 0.12);
--preview-card: rgba(255, 250, 244, 0.74);
--preview-card-strong: rgba(255, 252, 247, 0.88);
--preview-shadow: 0 24px 64px rgba(105, 72, 40, 0.12);
--preview-accent: #b86432;
--preview-accent-soft: rgba(184, 100, 50, 0.14);
--preview-cool: #315f8b;
position: relative;
min-height: 100vh;
padding: 24px 16px 40px;
background: var(--preview-bg);
color: var(--preview-ink);
overflow: hidden;
}
.meeting-preview-page::before,
.meeting-preview-page::after {
content: "";
position: fixed;
pointer-events: none;
z-index: 0;
}
.meeting-preview-page::before {
top: 64px;
right: -54px;
width: 180px;
height: 180px;
border-radius: 50%;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.84) 0%, rgba(255, 255, 255, 0) 72%),
radial-gradient(circle at 36% 36%, rgba(184, 100, 50, 0.18), rgba(184, 100, 50, 0) 56%);
filter: blur(2px);
}
.meeting-preview-page::after {
left: -48px;
bottom: 120px;
width: 160px;
height: 160px;
border-radius: 32px;
border: 1px solid rgba(49, 95, 139, 0.14);
background:
linear-gradient(135deg, rgba(49, 95, 139, 0.12), rgba(49, 95, 139, 0)),
rgba(255, 255, 255, 0.32);
transform: rotate(16deg);
}
.meeting-preview-shell {
position: relative;
z-index: 1;
width: min(100%, 920px);
margin: 0 auto;
}
.meeting-preview-loading,
.meeting-preview-empty {
display: grid;
gap: 18px;
}
.meeting-preview-card {
position: relative;
overflow: hidden;
border: 1px solid var(--preview-line);
border-radius: 28px;
background: var(--preview-card);
box-shadow: var(--preview-shadow);
backdrop-filter: blur(18px);
}
.meeting-preview-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.66), rgba(255, 255, 255, 0.1) 42%, transparent 70%);
opacity: 0.8;
pointer-events: none;
}
.meeting-preview-hero,
.meeting-preview-section {
position: relative;
z-index: 1;
animation: preview-rise 0.5s ease both;
}
.meeting-preview-hero {
padding: 24px 20px 22px;
}
.meeting-preview-eyebrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.meeting-preview-eyebrow-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(84, 57, 31, 0.08);
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.meeting-preview-status {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.meeting-preview-status.is-complete {
background: rgba(59, 130, 89, 0.12);
color: #1f6a43;
}
.meeting-preview-status.is-processing {
background: rgba(49, 95, 139, 0.12);
color: var(--preview-cool);
}
.meeting-preview-status.is-warning {
background: rgba(184, 100, 50, 0.12);
color: var(--preview-accent);
}
.meeting-preview-title {
margin: 0;
font-family: Georgia, "Times New Roman", "Songti SC", serif;
font-size: clamp(32px, 8vw, 46px);
line-height: 1.02;
letter-spacing: -0.04em;
}
.meeting-preview-subtitle {
margin: 14px 0 0;
color: var(--preview-muted);
font-size: 15px;
line-height: 1.75;
}
.meeting-preview-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 22px;
}
.meeting-preview-metric {
padding: 14px 14px 12px;
border-radius: 18px;
background: var(--preview-card-strong);
border: 1px solid rgba(84, 57, 31, 0.08);
}
.meeting-preview-metric-label {
display: block;
margin-bottom: 6px;
color: var(--preview-muted);
font-size: 12px;
}
.meeting-preview-metric-value {
display: block;
font-size: 15px;
font-weight: 600;
line-height: 1.6;
word-break: break-word;
}
.meeting-preview-panels {
display: grid;
gap: 18px;
margin-top: 18px;
}
.meeting-preview-section {
padding: 22px 18px;
}
.meeting-preview-password-gate {
display: grid;
gap: 18px;
}
.meeting-preview-password-form {
display: grid;
gap: 12px;
}
.meeting-preview-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.meeting-preview-section-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.meeting-preview-section-title {
margin: 6px 0 0;
font-family: Georgia, "Times New Roman", "Songti SC", serif;
font-size: 22px;
letter-spacing: -0.03em;
}
.meeting-preview-section-extra {
font-size: 12px;
color: var(--preview-muted);
}
.meeting-preview-alert {
margin-bottom: 16px;
border-radius: 18px !important;
}
.meeting-preview-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.meeting-preview-tag {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--preview-card-strong);
border: 1px solid rgba(84, 57, 31, 0.08);
color: var(--preview-ink);
font-size: 13px;
}
.meeting-preview-overview {
margin-top: 16px;
padding: 18px;
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.4)),
rgba(255, 255, 255, 0.32);
border: 1px solid rgba(84, 57, 31, 0.08);
}
.meeting-preview-overview-label {
margin-bottom: 8px;
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.meeting-preview-overview-copy {
margin: 0;
font-size: 15px;
line-height: 1.9;
color: var(--preview-ink);
white-space: pre-wrap;
}
.meeting-preview-analysis-tabs {
margin: 18px 0 14px;
}
.meeting-preview-analysis-tabs .ant-segmented {
width: 100%;
padding: 5px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.meeting-preview-analysis-tabs .ant-segmented-item {
min-height: 40px;
border-radius: 12px;
}
.meeting-preview-analysis-panel {
display: grid;
gap: 12px;
}
.meeting-preview-list-empty {
padding: 26px 16px 10px;
}
.meeting-preview-chapter,
.meeting-preview-keypoint,
.meeting-preview-speaker-card,
.meeting-preview-todo {
border: 1px solid rgba(84, 57, 31, 0.08);
background: var(--preview-card-strong);
border-radius: 22px;
}
.meeting-preview-chapter,
.meeting-preview-keypoint {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 12px;
padding: 14px;
}
.meeting-preview-chapter-time,
.meeting-preview-keypoint-index {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10px 8px;
border-radius: 16px;
background: var(--preview-accent-soft);
color: var(--preview-accent);
font-weight: 700;
font-size: 13px;
}
.meeting-preview-item-title {
display: block;
margin-bottom: 6px;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
}
.meeting-preview-item-copy {
display: block;
color: var(--preview-muted);
font-size: 14px;
line-height: 1.8;
}
.meeting-preview-item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.meeting-preview-meta-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(49, 95, 139, 0.1);
color: var(--preview-cool);
font-size: 12px;
}
.meeting-preview-speaker-card {
padding: 16px;
}
.meeting-preview-speaker-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.meeting-preview-speaker-avatar {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: rgba(49, 95, 139, 0.14);
color: var(--preview-cool);
font-weight: 700;
}
.meeting-preview-speaker-name {
font-size: 15px;
font-weight: 700;
}
.meeting-preview-speaker-role {
margin-top: 2px;
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.meeting-preview-todo {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 16px;
}
.meeting-preview-todo-dot {
flex: none;
width: 10px;
height: 10px;
margin-top: 8px;
border-radius: 50%;
background: var(--preview-accent);
box-shadow: 0 0 0 6px rgba(184, 100, 50, 0.12);
}
.meeting-preview-markdown {
padding: 4px 0 0;
}
.meeting-preview-markdown .ant-empty {
padding: 24px 0 8px;
}
.meeting-preview-markdown h1,
.meeting-preview-markdown h2,
.meeting-preview-markdown h3,
.meeting-preview-markdown h4 {
margin-top: 1.4em;
margin-bottom: 0.7em;
font-family: Georgia, "Times New Roman", "Songti SC", serif;
line-height: 1.2;
letter-spacing: -0.03em;
}
.meeting-preview-markdown p,
.meeting-preview-markdown li {
color: var(--preview-ink);
font-size: 15px;
line-height: 1.92;
}
.meeting-preview-markdown blockquote {
margin: 18px 0;
padding: 12px 16px;
border-left: 3px solid rgba(184, 100, 50, 0.4);
border-radius: 0 16px 16px 0;
background: rgba(184, 100, 50, 0.06);
}
.meeting-preview-markdown pre {
overflow: auto;
padding: 14px;
border-radius: 18px;
background: rgba(51, 38, 28, 0.92);
}
.meeting-preview-markdown code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.meeting-preview-transcript-audio {
width: 100%;
margin-bottom: 16px;
}
.meeting-preview-transcript-list {
display: grid;
gap: 12px;
}
.meeting-preview-transcript-item {
width: 100%;
padding: 14px;
border: 1px solid rgba(84, 57, 31, 0.08);
border-radius: 22px;
background: var(--preview-card-strong);
text-align: left;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
}
.meeting-preview-transcript-item:hover {
transform: translateY(-2px);
border-color: rgba(184, 100, 50, 0.22);
box-shadow: 0 12px 26px rgba(105, 72, 40, 0.08);
}
.meeting-preview-transcript-item.is-active {
border-color: rgba(184, 100, 50, 0.28);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 244, 235, 0.92));
}
.meeting-preview-transcript-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.meeting-preview-transcript-speaker {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.meeting-preview-transcript-avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.meeting-preview-transcript-name {
min-width: 0;
font-size: 14px;
font-weight: 700;
}
.meeting-preview-transcript-time {
flex: none;
color: var(--preview-muted);
font-size: 12px;
}
.meeting-preview-transcript-copy {
color: var(--preview-ink);
font-size: 14px;
line-height: 1.82;
white-space: pre-wrap;
}
.meeting-preview-disclaimer {
margin-top: 18px;
padding: 0 8px;
color: rgba(72, 53, 39, 0.64);
font-size: 12px;
line-height: 1.8;
text-align: center;
}
@keyframes preview-rise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (min-width: 768px) {
.meeting-preview-page {
padding: 36px 28px 56px;
}
.meeting-preview-hero,
.meeting-preview-section {
padding-left: 24px;
padding-right: 24px;
}
.meeting-preview-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.meeting-preview-panels {
gap: 22px;
}
}

View File

@ -0,0 +1,595 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tag } from "antd";
import {
AudioOutlined,
CalendarOutlined,
ClockCircleOutlined,
FileTextOutlined,
LockOutlined,
RobotOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
import {
getMeetingPreviewAccess,
getPublicMeetingPreview,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
1: { label: "閺夌儐鍓欓崯鎾寸▔?, className: "is-processing", hint: "ùТ弿? },
2: { label: "闁诡剝宕电划銊︾▔?, className: "is-processing", hint: "AI ? },
3: { label: "鐎瑰憡褰冮悾顒勫箣?, className: "is-complete", hint: "? },
};
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
function splitDisplayItems(value?: string) {
return (value || "")
.split(/[??)
.map((item) => item.trim())
.filter(Boolean);
}
function transcriptColorSeed(speakerKey: string) {
const palette = ["#315f8b", "#b86432", "#557a46", "#6d4fa7", "#a33f57", "#0f766e"];
const score = Array.from(speakerKey).reduce((sum, char) => sum + char.charCodeAt(0), 0);
return palette[score % palette.length];
}
export default function MeetingPreview() {
const { id } = useParams();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
useEffect(() => {
let mounted = true;
const fetchData = async () => {
if (!id) {
setError("?);
setLoading(false);
return;
}
setLoading(true);
setError("");
setMeeting(null);
setTranscripts([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword("");
setPasswordError("");
try {
const meetingId = Number(id);
const accessRes = await getMeetingPreviewAccess(meetingId);
if (!mounted) {
return;
}
const requiresPassword = !!accessRes.data.data.passwordRequired;
setPasswordRequired(requiresPassword);
if (requiresPassword) {
setLoading(false);
return;
}
const previewRes = await getPublicMeetingPreview(meetingId);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
return;
}
setError(requestError?.response?.data?.msg || "Ь?);
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
mounted = false;
};
}, [id]);
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
const statusMeta = STATUS_META[meeting?.status || 0] || {
label: "ˇ?,
className: "is-warning",
hint: "ùУуХ?,
};
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
audioRef.current.play().catch(() => {});
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current || transcripts.length === 0) {
return;
}
const currentMs = audioRef.current.currentTime * 1000;
const currentItem = transcripts.find(
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
);
setActiveTranscriptId(currentItem?.id || null);
};
const handlePasswordSubmit = async () => {
if (!id) {
return;
}
setLoading(true);
setPasswordError("");
try {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || "閻犱礁娼″Λ鍓佲偓闈涙閻栨粓鏌ㄥ▎鎺濆殩");
} finally {
setLoading(false);
}
};
if (loading && (!passwordRequired || passwordVerified)) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section meeting-preview-password-gate">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<LockOutlined />
Access Check
</div>
<h2 className="meeting-preview-section-title">Password Required</h2>
</div>
</div>
<p className="meeting-preview-subtitle">
Enter access_password to view this meeting preview.
</p>
<div className="meeting-preview-password-form">
<Input.Password
value={accessPassword}
placeholder="Enter access_password"
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
/>
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
Open Preview
</Button>
</div>
{passwordError ? <Alert type="error" showIcon message={passwordError} /> : null}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title="Ь? subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description="? />
</div>
</div>
</div>
);
}
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell">
<section className="meeting-preview-card meeting-preview-hero">
<div className="meeting-preview-eyebrow">
<div className="meeting-preview-eyebrow-label">
<FileTextOutlined />
?
</div>
<span className={`meeting-preview-status ${statusMeta.className}`}>{statusMeta.label}</span>
</div>
<h1 className="meeting-preview-title">{meeting.title || "?}</h1>
<p className="meeting-preview-subtitle">{statusMeta.hint}</p>
<div className="meeting-preview-metrics">
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : "?}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">??/span>
<span className="meeting-preview-metric-value">{meeting.hostName || meeting.creatorName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">ɑ?/span>
<span className="meeting-preview-metric-value">{participants.length || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{tags.length || "?}</span>
</div>
</div>
</section>
<div className="meeting-preview-panels">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<CalendarOutlined />
?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
</div>
<div className="meeting-preview-metrics">
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.creatorName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.hostName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : "?}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.audioSaveStatus || "NONE"}</span>
</div>
</div>
{participants.length > 0 ? (
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">?/div>
<div className="meeting-preview-tags">
{participants.map((item) => (
<span key={item} className="meeting-preview-tag">
<TeamOutlined style={{ marginRight: 8 }} />
{item}
</span>
))}
</div>
</div>
) : null}
{tags.length > 0 ? (
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">?/div>
<div className="meeting-preview-tags">
{tags.map((item) => (
<Tag key={item} bordered={false} className="meeting-preview-tag">
{item}
</Tag>
))}
</div>
</div>
) : null}
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<RobotOutlined />
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
<div className="meeting-preview-section-extra">?/div>
</div>
{meeting.status < 3 ? (
<Alert
className="meeting-preview-alert"
type="info"
showIcon
message="?
description="ù广?
/>
) : null}
{analysis.keywords.length > 0 ? (
<div className="meeting-preview-tags">
{analysis.keywords.map((item) => (
<span key={item} className="meeting-preview-tag">
{item}
</span>
))}
</div>
) : null}
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">稿?/div>
<p className="meeting-preview-overview-copy">{analysis.overview || "Λ?}</p>
</div>
<div className="meeting-preview-analysis-tabs">
<Segmented<AnalysisTab>
block
value={analysisTab}
onChange={(value) => setAnalysisTab(value)}
options={[
{ label: "缂佹梻濮炬俊?, value: "chapters" },
{ label: "闁告瑦鍨奸埢?, value: "speakers" },
{ label: "閻熸洑鑳堕崑?, value: "actions" },
{ label: "鐎垫澘鎳庢慨?, value: "todos" },
]}
/>
</div>
<div className="meeting-preview-analysis-panel">
{analysisTab === "chapters" &&
(analysis.chapters.length ? (
analysis.chapters.map((item, index) => (
<div className="meeting-preview-chapter" key={`${item.title}-${index}`}>
<div className="meeting-preview-chapter-time">{item.time || "--:--"}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `缂佹梻濮炬俊?${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || "Λ?}</span>
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λ? />
</div>
))}
{analysisTab === "speakers" &&
(analysis.speakerSummaries.length ? (
analysis.speakerSummaries.map((item, index) => (
<div className="meeting-preview-speaker-card" key={`${item.speaker}-${index}`}>
<div className="meeting-preview-speaker-head">
<div className="meeting-preview-speaker-avatar">{(item.speaker || "?).slice(0, 1)}</div>
<div>
<div className="meeting-preview-speaker-name">{item.speaker || `闁告瑦鍨奸埢鍫熺?${index + 1}`}</div>
<div className="meeting-preview-speaker-role">?/div>
</div>
</div>
<div className="meeting-preview-item-copy">{item.summary || "Λ?}</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λ? />
</div>
))}
{analysisTab === "actions" &&
(analysis.keyPoints.length ? (
analysis.keyPoints.map((item, index) => (
<div className="meeting-preview-keypoint" key={`${item.title}-${index}`}>
<div className="meeting-preview-keypoint-index">{String(index + 1).padStart(2, "0")}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `閻熸洑鑳堕崑?${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || "Λх?}</span>
{(item.speaker || item.time) && (
<div className="meeting-preview-item-meta">
{item.speaker ? <span className="meeting-preview-meta-pill">{item.speaker}</span> : null}
{item.time ? <span className="meeting-preview-meta-pill">{item.time}</span> : null}
</div>
)}
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λу? />
</div>
))}
{analysisTab === "todos" &&
(analysis.todos.length ? (
analysis.todos.map((item, index) => (
<div className="meeting-preview-todo" key={`${item}-${index}`}>
<span className="meeting-preview-todo-dot" />
<span className="meeting-preview-item-copy">{item}</span>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λù? />
</div>
))}
</div>
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<FileTextOutlined />
AI ?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? (
<div className="markdown-body">
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
</div>
) : (
<Empty description="Λ? />
)}
</div>
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<AudioOutlined />
?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{ marginRight: 6 }} />
?
</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message="Т?
description={meeting.audioSaveMessage || "хуΥ?}
/>
) : null}
{meeting.audioUrl ? (
<audio
ref={audioRef}
className="meeting-preview-transcript-audio"
src={meeting.audioUrl}
controls
preload="metadata"
onTimeUpdate={handleAudioTimeUpdate}
/>
) : null}
<div className="meeting-preview-transcript-list">
{transcripts.length ? (
transcripts.map((item) => {
const speakerName = item.speakerLabel || item.speakerName || item.speakerId || "?;
const avatarColor = transcriptColorSeed(speakerName);
return (
<button
key={item.id}
type="button"
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
onClick={() => handleTranscriptSeek(item)}
>
<div className="meeting-preview-transcript-head">
<div className="meeting-preview-transcript-speaker">
<div className="meeting-preview-transcript-avatar" style={{ backgroundColor: avatarColor }}>
{(speakerName || "?).slice(0, 1)}
</div>
<div className="meeting-preview-transcript-name">
<UserOutlined style={{ marginRight: 6, color: avatarColor }} />
{speakerName}
</div>
</div>
<div className="meeting-preview-transcript-time">
{formatDurationRange(item.startTime, item.endTime)}
</div>
</div>
<div className="meeting-preview-transcript-copy">{item.content || "闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣"}</div>
</button>
);
})
) : (
<Empty description="闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣" />
)}
</div>
</section>
</div>
<div className="meeting-preview-disclaimer">
у?AI 娿ùхùΣ?
</div>
</div>
</div>
);
}

View File

@ -141,6 +141,11 @@ const PromptTemplates: React.FC = () => {
icon: null,
content: (
<div style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
{record.description ? (
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 8, background: 'var(--app-bg-surface-soft)' }}>
<Text type="secondary">{record.description}</Text>
</div>
) : null}
<ReactMarkdown>{record.promptContent}</ReactMarkdown>
</div>
),
@ -250,6 +255,11 @@ const PromptTemplates: React.FC = () => {
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 16, display: 'block', width: '100%' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
{item.description ? (
<Text type="secondary" style={{ fontSize: 12, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', marginTop: 6 }}>
{item.description}
</Text>
) : null}
{/*<Text type="secondary" style={{ fontSize: 12 }}>使用次数: {item.usageCount || 0}</Text>*/}
</div>
@ -399,6 +409,14 @@ const PromptTemplates: React.FC = () => {
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="模板描述">
<Input.TextArea
maxLength={255}
showCount
autoSize={{ minRows: 2, maxRows: 4 }}
placeholder="请输入模板描述"
/>
</Form.Item>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select
mode="tags"

View File

@ -0,0 +1,190 @@
import type { MeetingVO } from "../../api/business/meeting";
export type AnalysisChapter = {
time?: string;
title: string;
summary: string;
};
export type AnalysisSpeakerSummary = {
speaker: string;
summary: string;
};
export type AnalysisKeyPoint = {
title: string;
summary: string;
speaker?: string;
time?: string;
};
export type MeetingAnalysis = {
overview: string;
keywords: string[];
chapters: AnalysisChapter[];
speakerSummaries: AnalysisSpeakerSummary[];
keyPoints: AnalysisKeyPoint[];
todos: string[];
};
export const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: "",
keywords: [],
chapters: [],
speakerSummaries: [],
keyPoints: [],
todos: [],
};
const splitLines = (value?: string | null) =>
(value || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const parseLooseJson = (raw?: string | null) => {
const input = (raw || "").trim();
if (!input) return null;
const tryParse = (text: string) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
const direct = tryParse(input);
if (direct && typeof direct === "object") return direct;
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
if (fenced) {
const fencedParsed = tryParse(fenced);
if (fencedParsed && typeof fencedParsed === "object") return fencedParsed;
}
const start = input.indexOf("{");
const end = input.lastIndexOf("}");
if (start >= 0 && end > start) {
const wrapped = tryParse(input.slice(start, end + 1));
if (wrapped && typeof wrapped === "object") return wrapped;
}
return null;
};
const extractSection = (markdown: string, aliases: string[]) => {
const lines = markdown.split(/\r?\n/);
const lowerAliases = aliases.map((item) => item.toLowerCase());
const cleanHeading = (line: string) => line.replace(/^#{1,6}\s*/, "").trim().toLowerCase();
let start = -1;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line.startsWith("#")) continue;
const heading = cleanHeading(line);
if (lowerAliases.some((alias) => heading.includes(alias))) {
start = index + 1;
break;
}
}
if (start < 0) return "";
const buffer: string[] = [];
for (let index = start; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim().startsWith("#")) break;
buffer.push(line);
}
return buffer.join("\n").trim();
};
const parseBulletList = (content?: string | null) =>
splitLines(content)
.map((line) => line.replace(/^[-*•\s]+/, "").replace(/^\d+[.)]\s*/, "").trim())
.filter(Boolean);
const parseOverviewSection = (markdown: string) =>
extractSection(markdown, ["全文概要", "概要", "摘要", "概览"]) || markdown.replace(/^---[\s\S]*?---/, "").trim();
const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ["关键词", "关键字", "标签"]);
const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[,、/]/))
.map((item) => item.trim())
.filter(Boolean);
if (fromSection.length) {
return Array.from(new Set(fromSection)).slice(0, 12);
}
return Array.from(new Set((tags || "").split(",").map((item) => item.trim()).filter(Boolean))).slice(0, 12);
};
export const buildMeetingAnalysis = (
sourceAnalysis: MeetingVO["analysis"] | undefined,
summaryContent: string | undefined,
tags: string,
): MeetingAnalysis => {
const parseStructured = (parsed: Record<string, any>): MeetingAnalysis => {
const chapters = Array.isArray(parsed.chapters) ? parsed.chapters : [];
const speakerSummaries = Array.isArray(parsed.speakerSummaries) ? parsed.speakerSummaries : [];
const keyPoints = Array.isArray(parsed.keyPoints) ? parsed.keyPoints : [];
const todos = Array.isArray(parsed.todos)
? parsed.todos
: Array.isArray(parsed.actionItems)
? parsed.actionItems
: [];
return {
overview: String(parsed.overview || "").trim(),
keywords: Array.from(
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)),
).slice(0, 12),
chapters: chapters
.map((item: any) => ({
time: item?.time ? String(item.time).trim() : undefined,
title: String(item?.title || "").trim(),
summary: String(item?.summary || "").trim(),
}))
.filter((item: AnalysisChapter) => item.title || item.summary),
speakerSummaries: speakerSummaries
.map((item: any) => ({
speaker: String(item?.speaker || "").trim(),
summary: String(item?.summary || "").trim(),
}))
.filter((item: AnalysisSpeakerSummary) => item.speaker || item.summary),
keyPoints: keyPoints
.map((item: any) => ({
title: String(item?.title || "").trim(),
summary: String(item?.summary || "").trim(),
speaker: item?.speaker ? String(item.speaker).trim() : undefined,
time: item?.time ? String(item.time).trim() : undefined,
}))
.filter((item: AnalysisKeyPoint) => item.title || item.summary),
todos: todos.map((item: any) => String(item).trim()).filter(Boolean).slice(0, 10),
};
};
if (sourceAnalysis) {
return parseStructured(sourceAnalysis as Record<string, any>);
}
const raw = (summaryContent || "").trim();
if (!raw && !tags) return ANALYSIS_EMPTY;
const loose = parseLooseJson(raw);
if (loose) {
return parseStructured(loose);
}
return {
overview: parseOverviewSection(raw),
keywords: parseKeywordsSection(raw, tags),
chapters: [],
speakerSummaries: [],
keyPoints: [],
todos: [],
};
};

View File

@ -34,11 +34,7 @@ export default function AppRoutes() {
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/meetings/:id/preview"
element={
<RequireAuth>
<MeetingPreview />
</RequireAuth>
}
element={<MeetingPreview />}
/>
<Route
path="/"