feat: 添加会议预览功能和相关UI组件
- 在 `MeetingAccessService` 中添加 `isPreviewPasswordRequired` 和 `assertCanPreviewMeeting` 方法 - 在 `PromptTemplateVO` 和 `PromptTemplateDTO` 中添加 `description` 字段 - 在前端添加 `meetingAnalysis.ts` 以处理会议分析数据 - 在 `PromptTemplates.tsx` 中显示模板描述 - 添加 `MeetingPreview.tsx` 组件,支持会议预览和密码验证dev_na
parent
2b30744d2e
commit
db310fc803
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ public class PromptTemplate extends BaseEntity {
|
|||
|
||||
private String templateName;
|
||||
|
||||
private String description;
|
||||
|
||||
private String category;
|
||||
|
||||
private Integer isSystem;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ unisbase:
|
|||
permit-all-urls:
|
||||
- /actuator/health
|
||||
- /api/static/**
|
||||
- /api/public/meetings/**
|
||||
- /ws/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "濞村吋淇洪鍛棯椤忓浂娲i柕鍡曠閸ㄥ酣寮搁幇顒佸闁告鍠愰弸鍐啅閼碱剚鏅搁柟瀛樺姇閻n剟骞嬮幇鈹惧亾? },
|
||||
};
|
||||
|
||||
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="闁哄牜浜濇竟姗€宕氭0浣虹獥閻犱緡鍠楅弳鐔煎箲? />
|
||||
</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") : "闁哄牜浜i鏇犵磾?}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">濞戞挾绮€?闁告帗绋戠紓?/span>
|
||||
<span className="meeting-preview-metric-value">{meeting.hostName || meeting.creatorName || "闁哄牜浜i鏇犵磾?}</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 || "闁哄牜浜i鏇犵磾?}</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 || "闁哄牜浜i鏇犵磾?}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">濞戞挾绮€垫梹绂?/span>
|
||||
<span className="meeting-preview-metric-value">{meeting.hostName || "闁哄牜浜i鏇犵磾?}</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") : "闁哄牜浜i鏇犵磾?}
|
||||
</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 || "闁哄棗鍊瑰Λ銈咁潡閸屾繍娲i柛鎰噹椤?}</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 || "闁哄棗鍊瑰Λ銈囨啺娴e搫浠悹鍥х摠濡?}</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="闁哄棗鍊瑰Λ銈囨啺娴e搫浠柛銉у仱閵? />
|
||||
</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 }} />
|
||||
闁绘劗鎳撻崵顔尖枔娴e啯鍎伴柛娆樺灥閻戯附娼鍕従濡?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meeting.audioSaveStatus === "FAILED" ? (
|
||||
<Alert
|
||||
className="meeting-preview-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="鐟滅増娲熼悡鍫曞棘閸ワ附顐藉☉鎾崇Т瑜版煡鎮?
|
||||
description={meeting.audioSaveMessage || "濞村吋淇洪鍛啅閹绘帞鏆氶柟瀛樺姧缁辨繃鎷呴崱娑氬従濡増鍨崇换姘扁偓娑櫭妵鎴犳嫻閵夘垳绀夌憸鐗堟尭婢х娀宕i鍥у幋闁哄被鍎冲﹢鍛村棘閸パ呮憻閺夌儐鍓欑紞宥夊Υ?}
|
||||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
};
|
||||
|
|
@ -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="/"
|
||||
|
|
|
|||
Loading…
Reference in New Issue